From 4b5e57d15dd8261ee1e7ba50e042192fa6faefad Mon Sep 17 00:00:00 2001 From: hundredark Date: Fri, 23 Oct 2026 19:03:36 +0800 Subject: [PATCH 1/2] fix issues --- solana/icon.go | 57 ++++++++++++++++++++++++++++++------ solana/observer.go | 73 +++++++++++++++++++++++++--------------------- solana/request.go | 2 +- solana/solana.go | 2 +- 4 files changed, 90 insertions(+), 44 deletions(-) diff --git a/solana/icon.go b/solana/icon.go index 3ef2006..1841a7c 100644 --- a/solana/icon.go +++ b/solana/icon.go @@ -8,7 +8,10 @@ import ( "fmt" "image" "io" + "mime" "net/http" + "strings" + "time" "github.com/MixinNetwork/bot-api-go-client/v3" solanaApp "github.com/MixinNetwork/computer/apps/solana" @@ -18,22 +21,58 @@ import ( "github.com/fogleman/gg" ) -const size = 512 +const ( + size = 512 + maxIconBytes = 5 << 20 + maxIconDimension = 4096 + maxIconPixels = 4 << 20 + imageHost = "images.mixin.one" +) -func readImageFromUrl(url string) (*image.NRGBA, error) { - if url[len(url)-5:] == "=s128" { - url = url[:len(url)-5] + "=s512" +func readImageFromUrl(ctx context.Context, url string) (*image.NRGBA, error) { + if strings.HasSuffix(url, "=s128") { + url = strings.TrimSuffix(url, "=s128") + "=s512" + } + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err } - res, err := http.Get(url) - if err != nil || res.StatusCode != 200 { - return nil, fmt.Errorf("http.Get(%s) => %v, %v", url, res, err) + if req.URL.Scheme != "https" || req.URL.Host != imageHost || req.URL.User != nil { + return nil, fmt.Errorf("icon URL must originate from https://images.mixin.one") + } + // RoundTrip performs one request without following redirects to another origin. + res, err := http.DefaultTransport.RoundTrip(req) + if err != nil { + return nil, err } defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("icon HTTP status: %d", res.StatusCode) + } + contentType, _, err := mime.ParseMediaType(res.Header.Get("Content-Type")) + if err != nil || !strings.HasPrefix(contentType, "image/") { + return nil, fmt.Errorf("invalid icon content type: %q", res.Header.Get("Content-Type")) + } + if res.ContentLength > maxIconBytes { + return nil, fmt.Errorf("icon exceeds %d bytes", maxIconBytes) + } - data, err := io.ReadAll(res.Body) + data, err := io.ReadAll(io.LimitReader(res.Body, maxIconBytes+1)) if err != nil { return nil, err } + if len(data) > maxIconBytes { + return nil, fmt.Errorf("icon exceeds %d bytes", maxIconBytes) + } + config, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return nil, err + } + if config.Width <= 0 || config.Height <= 0 || config.Width > maxIconDimension || config.Height > maxIconDimension || config.Width > maxIconPixels/config.Height { + return nil, fmt.Errorf("invalid icon dimensions: %dx%d", config.Width, config.Height) + } img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { return nil, err @@ -64,7 +103,7 @@ func getWebpBase64(img image.Image) (string, error) { } func (node *Node) processAssetIcon(ctx context.Context, asset *bot.AssetNetwork) (string, error) { - icon, err := readImageFromUrl(asset.IconURL) + icon, err := readImageFromUrl(ctx, asset.IconURL) if err != nil { return "", err } diff --git a/solana/observer.go b/solana/observer.go index 6b9a9d2..72fbce2 100644 --- a/solana/observer.go +++ b/solana/observer.go @@ -198,9 +198,8 @@ func (node *Node) deployOrConfirmAssetsLoop(ctx context.Context) { for { err := node.deployOrConfirmAssets(ctx) if err != nil { - panic(err) + logger.Printf("node.deployOrConfirmAssets() => %v", err) } - time.Sleep(loopInterval) } } @@ -354,6 +353,7 @@ func (node *Node) deployOrConfirmAssets(ctx context.Context) error { } for _, a := range es { + time.Sleep(time.Second) old, err := node.store.ReadDeployedAsset(ctx, a.AssetId) if err != nil { return err @@ -362,43 +362,50 @@ func (node *Node) deployOrConfirmAssets(ctx context.Context) error { continue } - id, tx, assets, err := node.CreateMintTransaction(ctx, a.AssetId) - if err != nil || tx == nil { - return err - } - payer := solana.MustPrivateKeyFromBase58(node.conf.SolanaKey) - _, err = tx.PartialSign(solanaApp.BuildSignersGetter(payer)) - if err != nil { - panic(err) - } - rpcTx, err := node.SendTransactionUtilConfirm(ctx, tx, nil) - if err != nil { - return err - } - tx, err = rpcTx.Transaction.GetTransaction() - if err != nil { - return err - } - - extra := []byte{byte(len(assets))} - for _, asset := range assets { - extra = append(extra, uuid.Must(uuid.FromString(asset.AssetId)).Bytes()...) - extra = append(extra, solana.MustPublicKeyFromBase58(asset.Address).Bytes()...) - } - err = node.sendObserverTransactionToGroup(ctx, &common.Operation{ - Id: id, - Type: OperationTypeDeployExternalAssets, - Extra: extra, - }, nil) + err = node.deployExternalAsset(ctx, a) if err != nil { - return err + logger.Printf("deploy external asset %s => %v", a.AssetId, err) + continue } - - return node.store.MarkExternalAssetDeployed(ctx, assets, tx.Signatures[0].String()) } return nil } +func (node *Node) deployExternalAsset(ctx context.Context, a *store.ExternalAsset) error { + id, tx, assets, err := node.CreateMintTransaction(ctx, a.AssetId) + if err != nil { + return err + } + if tx == nil { + return fmt.Errorf("missing mint transaction for %s", a.AssetId) + } + payer := solana.MustPrivateKeyFromBase58(node.conf.SolanaKey) + _, err = tx.PartialSign(solanaApp.BuildSignersGetter(payer)) + if err != nil { + return err + } + rpcTx, err := node.SendTransactionUtilConfirm(ctx, tx, nil) + if err != nil { + return err + } + tx, err = rpcTx.Transaction.GetTransaction() + if err != nil { + return err + } + extra := []byte{byte(len(assets))} + for _, asset := range assets { + extra = append(extra, uuid.Must(uuid.FromString(asset.AssetId)).Bytes()...) + extra = append(extra, solana.MustPublicKeyFromBase58(asset.Address).Bytes()...) + } + err = node.sendObserverTransactionToGroup(ctx, &common.Operation{ + Id: id, Type: OperationTypeDeployExternalAssets, Extra: extra, + }, nil) + if err != nil { + return err + } + return node.store.MarkExternalAssetDeployed(ctx, assets, tx.Signatures[0].String()) +} + func (node *Node) createNonceAccounts(ctx context.Context) error { count, err := node.store.CountNonceAccounts(ctx) if err != nil || count > 100 { diff --git a/solana/request.go b/solana/request.go index d785eb3..c8cc9cc 100644 --- a/solana/request.go +++ b/solana/request.go @@ -207,7 +207,7 @@ func (node *Node) parseSignerResponse(out *mtg.Action) (*store.Request, error) { func (node *Node) parseUserRequest(out *mtg.Action) (*store.Request, error) { a, m := mtg.DecodeMixinExtraHEX(out.Extra) if a != node.conf.AppId { - panic(out.Extra) + return nil, fmt.Errorf("invalid user app id: %s", a) } if len(m) == 0 { return nil, fmt.Errorf("node.parseUserRequest(%v)", out) diff --git a/solana/solana.go b/solana/solana.go index 86cd8dc..05b8514 100644 --- a/solana/solana.go +++ b/solana/solana.go @@ -404,7 +404,7 @@ func (node *Node) CreateMintTransaction(ctx context.Context, asset string) (stri // Account funding must use current rent rather than the node's cached quote. rent, err := node.solana.RPCGetMinimumBalanceForRentExemption(ctx, solanaApp.MintSize) if err != nil { - panic(err) + return "", nil, nil, err } tx, err := node.solana.CreateMints(ctx, node.SolanaPayer(), node.getMTGAddress(ctx), assets, rent) if err != nil { From b4fc51807a4e6c7e96fba3727dd8adc5383a64d7 Mon Sep 17 00:00:00 2001 From: hundredark Date: Fri, 23 Oct 2026 19:51:49 +0800 Subject: [PATCH 2/2] slight improve --- apps/solana/rpc.go | 66 +++++++++++++++++++++++++++++++++++++++-- solana/computer_test.go | 13 ++++---- solana/observer.go | 46 ++++++++++++++++++---------- solana/solana.go | 26 ++++++++-------- 4 files changed, 112 insertions(+), 39 deletions(-) diff --git a/apps/solana/rpc.go b/apps/solana/rpc.go index f594386..e0abbce 100644 --- a/apps/solana/rpc.go +++ b/apps/solana/rpc.go @@ -97,10 +97,18 @@ type MintData struct { } func (c *Client) RPCGetAsset(ctx context.Context, address string) (*Asset, error) { + return c.rpcGetAsset(ctx, address, rpc.CommitmentProcessed) +} + +func (c *Client) RPCGetConfirmedAsset(ctx context.Context, address string) (*Asset, error) { + return c.rpcGetAsset(ctx, address, rpc.CommitmentConfirmed) +} + +func (c *Client) rpcGetAsset(ctx context.Context, address string, commitment rpc.CommitmentType) (*Asset, error) { for { account, err := c.rpcClient.GetAccountInfoWithOpts(ctx, solana.MPK(address), &rpc.GetAccountInfoOpts{ Encoding: "jsonParsed", - Commitment: rpc.CommitmentProcessed, + Commitment: commitment, }) if mtg.CheckRetryableError(err) { time.Sleep(time.Millisecond * 300) @@ -144,6 +152,60 @@ func (c *Client) RPCGetAsset(ctx context.Context, address string) (*Asset, error } } +// RPCGetMintCreationSignature recovers the actual creation transaction rather +// than using a freshly constructed transaction's signature after a restart. +func (c *Client) RPCGetMintCreationSignature(ctx context.Context, address string) (string, error) { + mint := solana.MustPublicKeyFromBase58(address) + opts := &rpc.GetSignaturesForAddressOpts{Commitment: rpc.CommitmentConfirmed} + for { + signatures, err := c.rpcClient.GetSignaturesForAddressWithOpts(ctx, mint, opts) + if err != nil { + return "", err + } + if len(signatures) == 0 { + return "", fmt.Errorf("mint creation transaction not found: %s", address) + } + for _, signature := range signatures { + if signature.Err != nil { + continue + } + result, err := c.RPCGetTransaction(ctx, signature.Signature.String()) + if err != nil { + return "", err + } + if result == nil || result.Meta.Err != nil { + continue + } + tx, err := result.Transaction.GetTransaction() + if err != nil { + return "", err + } + if !tx.IsSigner(mint) { + continue + } + for _, ix := range tx.Message.Instructions { + program, err := tx.Message.Program(ix.ProgramIDIndex) + if err != nil || program != system.ProgramID { + continue + } + accounts, err := ix.ResolveInstructionAccounts(&tx.Message) + if err != nil { + return "", err + } + create, ok := DecodeCreateAccount(accounts, ix.Data) + if ok && create.GetNewAccount().PublicKey == mint && *create.Owner == solana.TokenProgramID && *create.Space == MintSize { + return signature.Signature.String(), nil + } + } + } + before := signatures[len(signatures)-1].Signature + if before == opts.Before { + return "", fmt.Errorf("mint transaction history did not advance: %s", address) + } + opts.Before = before + } +} + func (c *Client) RPCGetBalance(ctx context.Context, account solana.PublicKey) (uint64, error) { for { result, err := c.rpcClient.GetBalance(ctx, account, rpc.CommitmentProcessed) @@ -217,7 +279,7 @@ func (c *Client) RPCGetTransaction(ctx context.Context, signature string) (*rpc. time.Sleep(time.Millisecond * 300) continue } - if err != nil || r.Meta == nil { + if err != nil || r == nil || r.Meta == nil { return nil, fmt.Errorf("solana.GetTransaction(%s) => %v", signature, err) } diff --git a/solana/computer_test.go b/solana/computer_test.go index 60ec9f2..7de6c74 100644 --- a/solana/computer_test.go +++ b/solana/computer_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/MixinNetwork/bot-api-go-client/v3" + solanaApp "github.com/MixinNetwork/computer/apps/solana" "github.com/MixinNetwork/computer/store" "github.com/MixinNetwork/mixin/crypto" "github.com/MixinNetwork/mixin/logger" @@ -851,7 +852,7 @@ func testObserverDeployAsset(ctx context.Context, require *require.Assertions, n }, }) require.Nil(err) - id, _, assets, err := node.CreateMintTransaction(ctx, common.SafeLitecoinChainId) + id, _, asset, err := node.CreateMintTransaction(ctx, common.SafeLitecoinChainId) require.Nil(err) as, err := node.store.ListUndeployedAssets(ctx) require.Nil(err) @@ -870,18 +871,16 @@ func testObserverDeployAsset(ctx context.Context, require *require.Assertions, n require.Equal("90", as[0].PriceUSD) var extra []byte - extra = append(extra, byte(len(assets))) - for _, asset := range assets { - extra = append(extra, uuid.Must(uuid.FromString(asset.AssetId)).Bytes()...) - extra = append(extra, solana.MustPublicKeyFromBase58(asset.Address).Bytes()...) - } + extra = append(extra, byte(1)) + extra = append(extra, uuid.Must(uuid.FromString(asset.AssetId)).Bytes()...) + extra = append(extra, solana.MustPublicKeyFromBase58(asset.Address).Bytes()...) out := testBuildObserverRequest(node, id, OperationTypeDeployExternalAssets, extra) for _, node := range nodes { testStep(ctx, require, node, out) } - err = node.store.MarkExternalAssetDeployed(ctx, assets, "MBsH9LRbrx4u3kMkFkGuDyxjj3Pio55Puwv66dtR2M3CDfaR7Ef7VEKHDGM7GhB3fE1Jzc7k3zEZ6hvJ399UBNi") + err = node.store.MarkExternalAssetDeployed(ctx, []*solanaApp.DeployedAsset{asset}, "MBsH9LRbrx4u3kMkFkGuDyxjj3Pio55Puwv66dtR2M3CDfaR7Ef7VEKHDGM7GhB3fE1Jzc7k3zEZ6hvJ399UBNi") require.Nil(err) as, err = node.store.ListUndeployedAssets(ctx) require.Nil(err) diff --git a/solana/observer.go b/solana/observer.go index 72fbce2..cf8487c 100644 --- a/solana/observer.go +++ b/solana/observer.go @@ -372,38 +372,52 @@ func (node *Node) deployOrConfirmAssets(ctx context.Context) error { } func (node *Node) deployExternalAsset(ctx context.Context, a *store.ExternalAsset) error { - id, tx, assets, err := node.CreateMintTransaction(ctx, a.AssetId) + id, tx, asset, err := node.CreateMintTransaction(ctx, a.AssetId) if err != nil { return err } if tx == nil { return fmt.Errorf("missing mint transaction for %s", a.AssetId) } - payer := solana.MustPrivateKeyFromBase58(node.conf.SolanaKey) - _, err = tx.PartialSign(solanaApp.BuildSignersGetter(payer)) - if err != nil { - return err - } - rpcTx, err := node.SendTransactionUtilConfirm(ctx, tx, nil) + mint, err := node.solana.RPCGetConfirmedAsset(ctx, asset.Address) if err != nil { return err } - tx, err = rpcTx.Transaction.GetTransaction() - if err != nil { - return err - } - extra := []byte{byte(len(assets))} - for _, asset := range assets { - extra = append(extra, uuid.Must(uuid.FromString(asset.AssetId)).Bytes()...) - extra = append(extra, solana.MustPublicKeyFromBase58(asset.Address).Bytes()...) + var hash string + if mint != nil { + if err := validateExternalAssetMint(asset.Address, node.getMTGAddress(ctx).String(), mint); err != nil { + return err + } + hash, err = node.solana.RPCGetMintCreationSignature(ctx, asset.Address) + if err != nil { + return err + } + } else { + payer := solana.MustPrivateKeyFromBase58(node.conf.SolanaKey) + _, err = tx.PartialSign(solanaApp.BuildSignersGetter(payer)) + if err != nil { + return err + } + rpcTx, err := node.SendTransactionUtilConfirm(ctx, tx, nil) + if err != nil { + return err + } + tx, err = rpcTx.Transaction.GetTransaction() + if err != nil { + return err + } + hash = tx.Signatures[0].String() } + extra := []byte{1} + extra = append(extra, uuid.Must(uuid.FromString(asset.AssetId)).Bytes()...) + extra = append(extra, solana.MustPublicKeyFromBase58(asset.Address).Bytes()...) err = node.sendObserverTransactionToGroup(ctx, &common.Operation{ Id: id, Type: OperationTypeDeployExternalAssets, Extra: extra, }, nil) if err != nil { return err } - return node.store.MarkExternalAssetDeployed(ctx, assets, tx.Signatures[0].String()) + return node.store.MarkExternalAssetDeployed(ctx, []*solanaApp.DeployedAsset{asset}, hash) } func (node *Node) createNonceAccounts(ctx context.Context) error { diff --git a/solana/solana.go b/solana/solana.go index 05b8514..d05ea4d 100644 --- a/solana/solana.go +++ b/solana/solana.go @@ -356,9 +356,9 @@ func (node *Node) InitializeAccount(ctx context.Context, account string) error { return err } -func (node *Node) CreateMintTransaction(ctx context.Context, asset string) (string, *solana.Transaction, []*solanaApp.DeployedAsset, error) { +func (node *Node) CreateMintTransaction(ctx context.Context, asset string) (string, *solana.Transaction, *solanaApp.DeployedAsset, error) { tid := fmt.Sprintf("GROUP:%s:OBSERVER:%s:MEMBERS:%v:%d", node.group.GenesisId(), node.id, node.GetMembers(), node.conf.MTG.Genesis.Threshold) - var assets []*solanaApp.DeployedAsset + var da *solanaApp.DeployedAsset if common.CheckTestEnvironment(ctx) { tid = common.UniqueId(tid, common.SafeLitecoinChainId) ltc, err := common.SafeReadAssetUntilSufficient(ctx, common.SafeLitecoinChainId) @@ -369,14 +369,12 @@ func (node *Node) CreateMintTransaction(ctx context.Context, asset string) (stri id = common.UniqueId(id, common.SafeLitecoinChainId) seed := crypto.Sha256Hash(uuid.Must(uuid.FromString(id)).Bytes()) key := solanaApp.PrivateKeyFromSeed(seed[:]) - assets = []*solanaApp.DeployedAsset{ - { - AssetId: ltc.AssetID, - Address: "EFShFtXaMF1n1f6k3oYRd81tufEXzUuxYM6vkKrChVs8", - Uri: "https://uploads.mixin.one/mixin/attachments/1739005826-2dc1afa3f3327f4d29cbb02e3b41cf57d4842f3c444e8e829871699ac43d21b2", - PrivateKey: &key, - Asset: ltc, - }, + da = &solanaApp.DeployedAsset{ + AssetId: ltc.AssetID, + Address: "EFShFtXaMF1n1f6k3oYRd81tufEXzUuxYM6vkKrChVs8", + Uri: "https://uploads.mixin.one/mixin/attachments/1739005826-2dc1afa3f3327f4d29cbb02e3b41cf57d4842f3c444e8e829871699ac43d21b2", + PrivateKey: &key, + Asset: ltc, } } else { na, err := common.SafeReadAssetUntilSufficient(ctx, asset) @@ -392,13 +390,13 @@ func (node *Node) CreateMintTransaction(ctx context.Context, asset string) (stri id = common.UniqueId(id, node.SafeUser().SpendPrivateKey) seed := crypto.Sha256Hash([]byte(id)) key := solanaApp.PrivateKeyFromSeed(seed[:]) - assets = append(assets, &solanaApp.DeployedAsset{ + da = &solanaApp.DeployedAsset{ AssetId: asset, Address: key.PublicKey().String(), Uri: uri, Asset: na, PrivateKey: &key, - }) + } } // Account funding must use current rent rather than the node's cached quote. @@ -406,11 +404,11 @@ func (node *Node) CreateMintTransaction(ctx context.Context, asset string) (stri if err != nil { return "", nil, nil, err } - tx, err := node.solana.CreateMints(ctx, node.SolanaPayer(), node.getMTGAddress(ctx), assets, rent) + tx, err := node.solana.CreateMints(ctx, node.SolanaPayer(), node.getMTGAddress(ctx), []*solanaApp.DeployedAsset{da}, rent) if err != nil { return "", nil, nil, err } - return tid, tx, assets, nil + return tid, tx, da, nil } func (node *Node) CreateNonceAccount(ctx context.Context, index int) (string, string, error) {