From 6820430fe47ccdeb6e33133d805d3a64d641b0d1 Mon Sep 17 00:00:00 2001 From: hundredark Date: Mon, 24 Aug 2026 10:17:28 +0800 Subject: [PATCH 01/13] fix user check --- solana/mvm.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/solana/mvm.go b/solana/mvm.go index 2ac37d3..11db961 100644 --- a/solana/mvm.go +++ b/solana/mvm.go @@ -6,7 +6,6 @@ import ( "encoding/hex" "fmt" "math/big" - "slices" "strings" "github.com/MixinNetwork/bot-api-go-client/v3" @@ -199,10 +198,8 @@ func (node *Node) processSystemCall(ctx context.Context, req *store.Request) ([] if err != nil { panic(err) } - if !slices.ContainsFunc(mix.Members(), func(m string) bool { - return slices.Contains(req.Output.Senders, m) - }) && !common.CheckTestEnvironment(ctx) { - // TODO use better and general authentication without MM api + if !common.CheckTestEnvironment(ctx) && + (mix.Threshold != byte(req.Output.SendersThreshold) || bot.HashMembers(mix.Members()) != req.Output.SendersHash) { return node.failRequest(ctx, req, "") } From 9fb27686e3103131c448da65a32053be200f525d Mon Sep 17 00:00:00 2001 From: hundredark Date: Mon, 24 Aug 2026 10:20:52 +0800 Subject: [PATCH 02/13] fail request when duplicate call id --- solana/mvm.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/solana/mvm.go b/solana/mvm.go index 11db961..dcb6dfe 100644 --- a/solana/mvm.go +++ b/solana/mvm.go @@ -231,6 +231,15 @@ func (node *Node) processSystemCall(ctx context.Context, req *store.Request) ([] return node.failRequest(ctx, req, "") } + old, err := node.store.ReadSystemCallByRequestId(ctx, cid, 0) + if err != nil { + panic(err) + } + if old != nil { + logger.Printf("store.ReadSystemCallByRequestId(%s) => %s", cid, old) + return node.failRequest(ctx, req, "") + } + rb := node.readStorageExtraFromObserver(ctx, *storage) call, tx, err := node.buildSystemCallFromBytes(ctx, req, cid, rb, false) if err != nil { @@ -241,7 +250,7 @@ func (node *Node) processSystemCall(ctx context.Context, req *store.Request) ([] call.Public = hex.EncodeToString(user.FingerprintWithPath()) call.SkipPostProcess = skipPostProcess - old, err := node.store.ReadSystemCallByMessage(ctx, call.MessageHash) + old, err = node.store.ReadSystemCallByMessage(ctx, call.MessageHash) if err != nil { panic(err) } From 494a88302f722e3d7b67dd09bb5fcb9d958d7537 Mon Sep 17 00:00:00 2001 From: hundredark Date: Mon, 24 Aug 2026 10:34:01 +0800 Subject: [PATCH 03/13] fail request when invalid user --- solana/mvm.go | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/solana/mvm.go b/solana/mvm.go index dcb6dfe..314369c 100644 --- a/solana/mvm.go +++ b/solana/mvm.go @@ -52,16 +52,19 @@ func (node *Node) processAddUser(ctx context.Context, req *store.Request) ([]*mt } mix := string(req.ExtraBytes()) - _, err = bot.NewMixAddressFromString(mix) - logger.Printf("common.NewAddressFromString(%s) => %v", mix, err) + mmix, err := bot.NewMixAddressFromString(mix) + logger.Printf("bot.NewMixAddressFromString(%s) => %v", mix, err) if err != nil { return node.failRequest(ctx, req, "") } + if !checkUser(ctx, req, mmix) { + return node.failRequest(ctx, req, "") + } old, err := node.store.ReadUserByMixAddress(ctx, mix) - logger.Printf("store.ReadUserByAddress(%s) => %v %v", mix, old, err) + logger.Printf("store.ReadUserByMixAddress(%s) => %v %v", mix, old, err) if err != nil { - panic(fmt.Errorf("store.ReadUserByAddress(%s) => %v", mix, err)) + panic(fmt.Errorf("store.ReadUserByMixAddress(%s) => %v", mix, err)) } else if old != nil { return node.failRequest(ctx, req, "") } @@ -107,6 +110,13 @@ func (node *Node) processUserDeposit(ctx context.Context, req *store.Request) ([ } else if user == nil { return node.failRequest(ctx, req, "") } + mix, err := bot.NewMixAddressFromString(user.MixAddress) + if err != nil { + panic(err) + } + if !checkUser(ctx, req, mix) { + return node.failRequest(ctx, req, "") + } asset, err := common.SafeReadAssetUntilSufficient(ctx, req.AssetId) if err != nil || asset == nil { @@ -198,8 +208,7 @@ func (node *Node) processSystemCall(ctx context.Context, req *store.Request) ([] if err != nil { panic(err) } - if !common.CheckTestEnvironment(ctx) && - (mix.Threshold != byte(req.Output.SendersThreshold) || bot.HashMembers(mix.Members()) != req.Output.SendersHash) { + if !checkUser(ctx, req, mix) { return node.failRequest(ctx, req, "") } @@ -236,7 +245,7 @@ func (node *Node) processSystemCall(ctx context.Context, req *store.Request) ([] panic(err) } if old != nil { - logger.Printf("store.ReadSystemCallByRequestId(%s) => %s", cid, old) + logger.Printf("store.ReadSystemCallByRequestId(%s) => %v", cid, old) return node.failRequest(ctx, req, "") } @@ -1103,3 +1112,11 @@ func (node *Node) confirmBurnRelatedSystemCall(ctx context.Context, req *store.R } return txs, "" } + +func checkUser(ctx context.Context, req *store.Request, mix *bot.MixAddress) bool { + if common.CheckTestEnvironment(ctx) { + return true + } + + return mix.Threshold == byte(req.Output.SendersThreshold) && bot.HashMembers(mix.Members()) == req.Output.SendersHash +} From b14c113591ad4434c405ff369f2f72c9767c105d Mon Sep 17 00:00:00 2001 From: hundredark Date: Mon, 24 Aug 2026 11:15:15 +0800 Subject: [PATCH 04/13] handle alt errors --- apps/solana/common.go | 3 +++ solana/solana.go | 11 +++++++---- solana/system_call.go | 6 +++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/solana/common.go b/apps/solana/common.go index d5ce791..b422994 100644 --- a/apps/solana/common.go +++ b/apps/solana/common.go @@ -414,6 +414,9 @@ func DecodeNonceAdvance(accounts solana.AccountMetaSlice, data []byte) (*system. } func NonceAccountFromTx(tx *solana.Transaction) (*system.AdvanceNonceAccount, error) { + if len(tx.Message.Instructions) == 0 { + return nil, fmt.Errorf("transaction has no instructions") + } ins := tx.Message.Instructions[0] accounts, err := ins.ResolveInstructionAccounts(&tx.Message) if err != nil { diff --git a/solana/solana.go b/solana/solana.go index 25b3938..b77bcca 100644 --- a/solana/solana.go +++ b/solana/solana.go @@ -3,6 +3,7 @@ package solana import ( "context" "encoding/hex" + "errors" "fmt" "maps" "math/big" @@ -32,6 +33,8 @@ const ( SolanaTxRetry = 10 ) +var errInvalidAddressLookup = errors.New("invalid address lookup") + func (node *Node) addressLookupTableLoop(ctx context.Context) { for { time.Sleep(time.Minute) @@ -703,23 +706,23 @@ func (node *Node) processTransactionWithAddressLookups(ctx context.Context, txx } for index, info := range infos.Value { if info == nil { - return fmt.Errorf("get account info: not found") + return fmt.Errorf("%w: get account info: not found", errInvalidAddressLookup) } key := tblKeys[index] tableContent, err := lookup.DecodeAddressLookupTableState(info.Data.GetBinary()) if err != nil { - return fmt.Errorf("decode address lookup table state: %s %w", key, err) + return fmt.Errorf("%w: decode address lookup table state: %s %v", errInvalidAddressLookup, key, err) } resolutions[key] = tableContent.Addresses } if err := txx.Message.SetAddressTables(resolutions); err != nil { - return fmt.Errorf("set address tables: %w", err) + return fmt.Errorf("%w: set address tables: %v", errInvalidAddressLookup, err) } if err := txx.Message.ResolveLookups(); err != nil { - return fmt.Errorf("resolve lookups: %w ", err) + return fmt.Errorf("%w: resolve lookups: %v", errInvalidAddressLookup, err) } return nil diff --git a/solana/system_call.go b/solana/system_call.go index fca5c29..8a657d4 100644 --- a/solana/system_call.go +++ b/solana/system_call.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/base64" + "errors" "fmt" "math/big" "slices" @@ -298,7 +299,7 @@ func (node *Node) getSubSystemCallFromExtra(ctx context.Context, req *store.Requ return node.buildSystemCallFromBytes(ctx, req, id, raw, true) } -// should only return error when fail to parse nonce advance instruction; +// should only return error when fail to resolve address lookups or parse nonce advance instruction; // without fields of superior, type, public, skip_postprocess func (node *Node) buildSystemCallFromBytes(ctx context.Context, req *store.Request, id string, raw []byte, withdrawn bool) (*store.SystemCall, *solana.Transaction, error) { tx, err := solana.TransactionFromBytes(raw) @@ -308,6 +309,9 @@ func (node *Node) buildSystemCallFromBytes(ctx context.Context, req *store.Reque } err = node.processTransactionWithAddressLookups(ctx, tx) if err != nil { + if errors.Is(err, errInvalidAddressLookup) { + return nil, nil, err + } panic(err) } advance, err := solanaApp.NonceAccountFromTx(tx) From 9942061c41623245a3609800c6f94ad69b31caf4 Mon Sep 17 00:00:00 2001 From: hundredark Date: Mon, 24 Aug 2026 11:51:55 +0800 Subject: [PATCH 05/13] restrict the amount of nonce account per user --- solana/http.go | 5 +++++ store/nonce.go | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/solana/http.go b/solana/http.go index e377a56..b084af5 100644 --- a/solana/http.go +++ b/solana/http.go @@ -5,6 +5,7 @@ package solana import ( _ "embed" "encoding/json" + "errors" "fmt" "net/http" "time" @@ -270,6 +271,10 @@ func (node *Node) httpLockNonce(w http.ResponseWriter, r *http.Request, params m err = node.store.LockNonceAccountWithMix(ctx, nonce.Address, body.Mix) if err != nil { + if errors.Is(err, store.ErrNonceAccountLimit) { + common.RenderJSON(w, r, http.StatusTooManyRequests, map[string]any{"error": "nonce limit"}) + return + } common.RenderError(w, r, err) return } diff --git a/store/nonce.go b/store/nonce.go index 27cfed2..4bcf8aa 100644 --- a/store/nonce.go +++ b/store/nonce.go @@ -3,6 +3,7 @@ package store import ( "context" "database/sql" + "errors" "fmt" "strings" "time" @@ -22,6 +23,10 @@ type NonceAccount struct { UpdatedAt time.Time } +const MaxNonceAccountsPerMix = 5 + +var ErrNonceAccountLimit = errors.New("nonce account limit reached") + var nonceAccountCols = []string{"address", "hash", "mix", "call_id", "updated_by", "created_at", "updated_at"} func nonceAccountFromRow(row Row) (*NonceAccount, error) { @@ -102,6 +107,15 @@ func (s *SQLite3Store) LockNonceAccountWithMix(ctx context.Context, address, mix } defer common.Rollback(tx) + var count int + err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM nonce_accounts WHERE mix=?", mix).Scan(&count) + if err != nil { + return fmt.Errorf("SELECT nonce_accounts %v", err) + } + if count >= MaxNonceAccountsPerMix { + return fmt.Errorf("%w: %s", ErrNonceAccountLimit, mix) + } + err = s.execOne(ctx, tx, "UPDATE nonce_accounts SET mix=?, updated_at=? WHERE address=? AND mix IS NULL AND call_id IS NULL", mix, time.Now().UTC(), address) if err != nil { From b25b85d435b71ffa257c5d10fec858fe85cab2f0 Mon Sep 17 00:00:00 2001 From: hundredark Date: Mon, 24 Aug 2026 13:09:09 +0800 Subject: [PATCH 06/13] should check the length of all kinds of extra --- solana/mvm.go | 49 ++++++++++++++++++++++++++++++++++++++++++++---- solana/signer.go | 43 +++++++++++++++++++++++++++++++++--------- 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/solana/mvm.go b/solana/mvm.go index 314369c..e242e67 100644 --- a/solana/mvm.go +++ b/solana/mvm.go @@ -291,6 +291,10 @@ func (node *Node) processConfirmNonce(ctx context.Context, req *store.Request) ( } extra := req.ExtraBytes() + if len(extra) < 1+uuid.Size { + logger.Printf("invalid extra length for confirm nonce: %d", len(extra)) + return node.failRequest(ctx, req, "") + } flag, extra := extra[0], extra[1:] callId := uuid.Must(uuid.FromBytes(extra[0:16])).String() @@ -428,6 +432,15 @@ func (node *Node) processDeployExternalAssetsCall(ctx context.Context, req *stor var as []*solanaApp.DeployedAsset extra := req.ExtraBytes() + if len(extra) < 1 { + logger.Printf("invalid extra length for deploy external assets: %d", len(extra)) + return node.failRequest(ctx, req, "") + } + assetSize := uuid.Size + solana.PublicKeyLength + if len(extra) != 1+int(extra[0])*assetSize { + logger.Printf("invalid extra length for deploy external assets: %d", len(extra)) + return node.failRequest(ctx, req, "") + } n, extra := extra[0], extra[1:] offset := 0 for len(as) < int(n) { @@ -489,15 +502,27 @@ func (node *Node) processConfirmCall(ctx context.Context, req *store.Request) ([ } extra := req.ExtraBytes() + if len(extra) < 1 { + logger.Printf("invalid extra length for confirm call: %d", len(extra)) + return node.failRequest(ctx, req, "") + } flag, extra := extra[0], extra[1:] switch flag { case FlagConfirmCallSuccess: + if len(extra) < 1 { + logger.Printf("invalid extra length for successful confirm call: %d", len(extra)) + return node.failRequest(ctx, req, "") + } n, extra := int(extra[0]), extra[1:] if n == 0 || n > 2 { logger.Printf("invalid length of signature: %d", n) return node.failRequest(ctx, req, "") } + if len(extra) < n*solana.SignatureLength { + logger.Printf("invalid signature payload length: %d %d", len(extra), n) + return node.failRequest(ctx, req, "") + } var calls []*store.SystemCall @@ -572,6 +597,10 @@ func (node *Node) processConfirmCall(ctx context.Context, req *store.Request) ([ } return nil, "" case FlagConfirmCallFail: + if len(extra) < uuid.Size { + logger.Printf("invalid extra length for failed confirm call: %d", len(extra)) + return node.failRequest(ctx, req, "") + } callId := uuid.Must(uuid.FromBytes(extra[:16])).String() call, err := node.store.ReadSystemCallByRequestId(ctx, callId, 0) logger.Printf("store.ReadSystemCallByRequestId(%s) => %v %v", callId, call, err) @@ -594,6 +623,10 @@ func (node *Node) processObserverRequestSign(ctx context.Context, req *store.Req } extra := req.ExtraBytes() + if len(extra) != uuid.Size { + logger.Printf("invalid extra length for sign request: %d", len(extra)) + return node.failRequest(ctx, req, "") + } callId := uuid.Must(uuid.FromBytes(extra[:16])).String() call, err := node.store.ReadSystemCallByRequestId(ctx, callId, common.RequestStatePending) logger.Printf("store.ReadSystemCallByRequestId(%s) => %v %v", callId, call, err) @@ -645,6 +678,10 @@ func (node *Node) processObserverCreateDepositCall(ctx context.Context, req *sto } extra := req.ExtraBytes() + if len(extra) < solana.PublicKeyLength+solana.SignatureLength { + logger.Printf("invalid extra length for deposit call: %d", len(extra)) + return node.failRequest(ctx, req, "") + } userAddress := solana.PublicKeyFromBytes(extra[:32]) signature := solana.SignatureFromBytes(extra[32:96]) @@ -873,19 +910,23 @@ func (node *Node) refundAndFailRequest(ctx context.Context, req *store.Request, } func (node *Node) failSystemCall(ctx context.Context, req *store.Request, call *store.SystemCall) ([]*mtg.Transaction, string) { + if call == nil { + return node.failRequest(ctx, req, "") + } + extra := req.ExtraBytes() + if len(extra) < 1+uuid.Size { + logger.Printf("invalid extra length for failed system call: %d", len(extra)) + return node.failRequest(ctx, req, "") + } switch call.State { case common.RequestStatePending, common.RequestStateFailed: default: return node.failRequest(ctx, req, "") } - extra := req.ExtraBytes() flag, extra := extra[0], extra[1:] storage := extra[16:] - if call == nil { - panic(req) - } if flag == FlagConfirmCallSuccess { storage = nil } diff --git a/solana/signer.go b/solana/signer.go index 03122d8..977bea2 100644 --- a/solana/signer.go +++ b/solana/signer.go @@ -204,20 +204,21 @@ func (node *Node) loopPendingSessions(ctx context.Context) { func (node *Node) acceptIncomingMessages(ctx context.Context) { for { mm, err := node.network.ReceiveMessage(ctx) - logger.Debugf("network.ReceiveMessage() => %s %x %s %v", mm.Peer, mm.Data, mm.CreatedAt, err) if err != nil { panic(err) } + logger.Debugf("network.ReceiveMessage() => %s %x %s %v", mm.Peer, mm.Data, mm.CreatedAt, err) err = node.writeRequestTime(ctx, store.MPCMessageTimeKey, mm.CreatedAt) if err != nil { panic(err) } sessionId, msg, err := unmarshalSessionMessage(mm.Data) - logger.Verbosef("node.acceptIncomingMessages(%x, %d) => %s %s %x", sessionId, msg.RoundNumber, mm.Peer, mm.CreatedAt, msg.SSID) if err != nil { + logger.Printf("node.unmarshalSessionMessage(%x) => %v", mm.Data, err) continue } + logger.Verbosef("node.acceptIncomingMessages(%x, %d) => %s %s %x", sessionId, msg.RoundNumber, mm.Peer, mm.CreatedAt, msg.SSID) if msg.SSID == nil { continue } @@ -414,7 +415,7 @@ func (node *Node) getSession(sessionId []byte) *MultiPartySession { } func marshalSessionMessage(sessionId []byte, msg *protocol.Message) []byte { - if len(sessionId) > 32 { + if len(sessionId) != uuid.Size { panic(hex.EncodeToString(sessionId)) } msb := []byte{byte(len(sessionId))} @@ -423,15 +424,15 @@ func marshalSessionMessage(sessionId []byte, msg *protocol.Message) []byte { } func unmarshalSessionMessage(b []byte) ([]byte, *protocol.Message, error) { - if len(b) < 16 { + if len(b) < 1 || int(b[0]) != uuid.Size { return nil, nil, fmt.Errorf("unmarshalSessionMessage(%x) short", b) } - if len(b[1:]) <= int(b[0]) { + if len(b) <= 1+uuid.Size { return nil, nil, fmt.Errorf("unmarshalSessionMessage(%x) short", b) } - sessionId := b[1 : 1+b[0]] + sessionId := b[1 : 1+uuid.Size] var msg protocol.Message - err := msg.UnmarshalBinary(b[1+b[0]:]) + err := msg.UnmarshalBinary(b[1+uuid.Size:]) return sessionId, &msg, err } @@ -591,6 +592,10 @@ func (node *Node) processSignerKeygenResults(ctx context.Context, req *store.Req } extra := req.ExtraBytes() + if len(extra) != uuid.Size+ed25519.PublicKeySize { + logger.Printf("invalid extra length for keygen result: %d", len(extra)) + return node.failRequest(ctx, req, "") + } sid := uuid.FromBytesOrNil(extra[:16]).String() public := extra[16:] @@ -599,6 +604,10 @@ func (node *Node) processSignerKeygenResults(ctx context.Context, req *store.Req if err != nil { panic(err) } + if s == nil || s.Operation != OperationTypeKeygenInput { + logger.Printf("invalid keygen session: %v", s) + return node.failRequest(ctx, req, "") + } sender := req.Output.Senders[0] err = node.store.WriteSessionSignerIfNotExist(ctx, s.Id, sender, public, req.Output.SequencerCreatedAt, sender == string(node.id)) @@ -690,6 +699,10 @@ func (node *Node) processSignerPrepare(ctx context.Context, req *store.Request) } extra := req.ExtraBytes() + if len(extra) != uuid.Size+len(PrepareExtra) { + logger.Printf("invalid extra length for signer prepare: %d", len(extra)) + return node.failRequest(ctx, req, "") + } session := uuid.Must(uuid.FromBytes(extra[:16])).String() extra = extra[16:] if !bytes.Equal(extra, PrepareExtra) { @@ -736,16 +749,28 @@ func (node *Node) processSignerSignatureResponse(ctx context.Context, req *store panic(req.Action) } extra := req.ExtraBytes() + if len(extra) != uuid.Size && len(extra) != uuid.Size+ed25519.SignatureSize { + logger.Printf("invalid extra length for signature response: %d", len(extra)) + return node.failRequest(ctx, req, "") + } sid := uuid.FromBytesOrNil(extra[:16]).String() signature := extra[16:] s, err := node.store.ReadSession(ctx, sid) - if err != nil || s == nil { + if err != nil { panic(fmt.Errorf("store.ReadSession(%s) => %v %v", sid, s, err)) } + if s == nil || s.Operation != OperationTypeSignInput { + logger.Printf("invalid sign session: %v", s) + return node.failRequest(ctx, req, "") + } call, err := node.store.ReadSystemCallByRequestId(ctx, s.RequestId, 0) - if err != nil || call == nil { + if err != nil { panic(fmt.Errorf("store.ReadSystemCallByRequestId(%s) => %v %v", s.RequestId, call, err)) } + if call == nil { + logger.Printf("invalid call for sign session: %v", s) + return node.failRequest(ctx, req, "") + } if call.Signature.Valid || call.State != common.RequestStatePending { logger.Printf("invalid call %s: %d %s", call.RequestId, call.State, call.Signature.String) return node.failRequest(ctx, req, "") From f315e94938f392948e4bec055bebe727a089dea3 Mon Sep 17 00:00:00 2001 From: hundredark Date: Mon, 24 Aug 2026 14:10:49 +0800 Subject: [PATCH 07/13] fix priority fee --- apps/solana/transaction.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/solana/transaction.go b/apps/solana/transaction.go index 8984145..9452ef5 100644 --- a/apps/solana/transaction.go +++ b/apps/solana/transaction.go @@ -388,12 +388,19 @@ func (c *Client) getPriorityFeeInstruction(ctx context.Context) *computebudget.I if err != nil { panic(err) } + fee := getAveragePriorityFee(recentFees) + return computebudget.NewSetComputeUnitPriceInstruction(fee).Build() +} + +func getAveragePriorityFee(recentFees []rpc.PriorizationFeeResult) uint64 { + if len(recentFees) == 0 { + return 1000 + } total := decimal.NewFromInt(0) for _, fee := range recentFees { total = total.Add(decimal.NewFromUint64(fee.PrioritizationFee)) } - fee := total.Div(decimal.NewFromInt(int64(len(recentFees)))).BigInt().Uint64() - return computebudget.NewSetComputeUnitPriceInstruction(fee).Build() + return total.Div(decimal.NewFromInt(int64(len(recentFees)))).BigInt().Uint64() } func ExtractTransfersFromTransaction(ctx context.Context, tx *solana.Transaction, meta *rpc.TransactionMeta, exception *solana.PublicKey) ([]*Transfer, error) { From 4d275257ae4c24ac1f29b2552f8f10dfb325a6a2 Mon Sep 17 00:00:00 2001 From: hundredark Date: Mon, 24 Aug 2026 14:19:19 +0800 Subject: [PATCH 08/13] slihgt fix --- solana/rpc.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/solana/rpc.go b/solana/rpc.go index 0886413..6778f8b 100644 --- a/solana/rpc.go +++ b/solana/rpc.go @@ -228,8 +228,16 @@ func (node *Node) RPCCheckNFT(ctx context.Context, account string) (bool, error) if err != nil { return false, err } + return isNFTAccount(acc) +} + +func isNFTAccount(acc *rpc.GetAccountInfoResult) (bool, error) { + data := acc.GetBinary() + if len(data) == 0 { + return false, nil + } var tm token.Mint - err = bin.NewBinDecoder(acc.Value.Data.GetBinary()).Decode(&tm) + err := bin.NewBinDecoder(data).Decode(&tm) if err != nil { return false, fmt.Errorf("solana.NewBinDecoder() => %v", err) } From 0ef7eb6b4e1f00aa7e0a8e003d2feb4c86e442e6 Mon Sep 17 00:00:00 2001 From: hundredark Date: Mon, 24 Aug 2026 14:50:37 +0800 Subject: [PATCH 09/13] improve ExtractTransfersFromTransaction --- apps/solana/transaction.go | 55 +++++++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/apps/solana/transaction.go b/apps/solana/transaction.go index 9452ef5..f7cbc12 100644 --- a/apps/solana/transaction.go +++ b/apps/solana/transaction.go @@ -20,6 +20,8 @@ import ( "github.com/shopspring/decimal" ) +const solanaInnerIndexBase = int64(1_000_000_000) + func (c *Client) CreateNonceAccount(ctx context.Context, key, nonce string, rent uint64) (*solana.Transaction, error) { payer, err := solana.PrivateKeyFromBase58(key) if err != nil { @@ -447,13 +449,12 @@ func ExtractTransfersFromTransaction(ctx context.Context, tx *solana.Transaction } for index, ix := range msg.Instructions { - baseIndex := int64(index+1) * 10000 if transfer := extractTransfersFromInstruction(&msg, ix, tokenAccounts, owners, transfers); transfer != nil { if exception != nil && exception.String() == transfer.Receiver { continue } transfer.Signature = hash - transfer.Index = baseIndex + transfer.Index = int64(index) transfers = append(transfers, transfer) } @@ -463,7 +464,7 @@ func ExtractTransfersFromTransaction(ctx context.Context, tx *solana.Transaction continue } transfer.Signature = hash - transfer.Index = baseIndex + int64(innerIndex) + 1 + transfer.Index = (int64(index)+1)*solanaInnerIndexBase + int64(innerIndex) transfers = append(transfers, transfer) } } @@ -479,10 +480,23 @@ func ExtractTransferFromTransactionByIndex(ctx context.Context, tx *solana.Trans msg := tx.Message var ( - tokenAccounts = map[solana.PublicKey]token.Account{} - owners = []*solana.PublicKey{} + innerInstructions = map[uint16][]solana.CompiledInstruction{} + tokenAccounts = map[solana.PublicKey]token.Account{} + owners = []*solana.PublicKey{} ) + for _, inner := range meta.InnerInstructions { + sis := make([]solana.CompiledInstruction, len(inner.Instructions)) + for idx, ii := range inner.Instructions { + sis[idx] = solana.CompiledInstruction{ + ProgramIDIndex: ii.ProgramIDIndex, + Accounts: ii.Accounts, + Data: ii.Data, + } + } + innerInstructions[inner.Index] = sis + } + bs := meta.PreTokenBalances bs = append(bs, meta.PostTokenBalances...) for _, balance := range bs { @@ -499,7 +513,36 @@ func ExtractTransferFromTransactionByIndex(ctx context.Context, tx *solana.Trans } } - return extractTransfersFromInstruction(&msg, msg.Instructions[index], tokenAccounts, owners, nil) + ix, ok := instructionByTransferIndex(&msg, innerInstructions, index) + if !ok { + return nil + } + return extractTransfersFromInstruction(&msg, ix, tokenAccounts, owners, nil) +} + +func instructionByTransferIndex(msg *solana.Message, innerInstructions map[uint16][]solana.CompiledInstruction, index int64) (solana.CompiledInstruction, bool) { + if index < 0 { + return solana.CompiledInstruction{}, false + } + + if index < solanaInnerIndexBase { + if index >= int64(len(msg.Instructions)) { + return solana.CompiledInstruction{}, false + } + return msg.Instructions[index], true + } + + outerIndex := index/solanaInnerIndexBase - 1 + if outerIndex < 0 || outerIndex >= int64(len(msg.Instructions)) { + return solana.CompiledInstruction{}, false + } + + innerIndex := index % solanaInnerIndexBase + inners := innerInstructions[uint16(outerIndex)] + if innerIndex < 0 || innerIndex >= int64(len(inners)) { + return solana.CompiledInstruction{}, false + } + return inners[innerIndex], true } func ExtractMintsFromTransaction(tx *solana.Transaction) []string { From 10e8719da7cd88491a2a99f309358710334b1d7e Mon Sep 17 00:00:00 2001 From: hundredark Date: Mon, 24 Aug 2026 14:57:48 +0800 Subject: [PATCH 10/13] improve refund check --- solana/mvm.go | 2 +- solana/solana.go | 2 +- solana/system_call.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/solana/mvm.go b/solana/mvm.go index e242e67..00eacf4 100644 --- a/solana/mvm.go +++ b/solana/mvm.go @@ -954,7 +954,7 @@ func (node *Node) failSystemCall(ctx context.Context, req *store.Request, call * } } - os, _, err := node.GetSystemCallReferenceOutputs(ctx, main.UserIdFromPublicPath(), main.RequestHash, 0) + os, _, err := node.GetSystemCallReferenceOutputs(ctx, main.UserIdFromPublicPath(), main.RequestHash, common.RequestStatePending) if err != nil { panic(err) } diff --git a/solana/solana.go b/solana/solana.go index b77bcca..efaf6b6 100644 --- a/solana/solana.go +++ b/solana/solana.go @@ -618,7 +618,7 @@ func (node *Node) CreateRefundWithdrawalTransaction(ctx context.Context, prepare return nil } - os, _, err := node.GetSystemCallReferenceOutputs(ctx, call.UserIdFromPublicPath(), call.RequestHash, 0) + os, _, err := node.GetSystemCallReferenceOutputs(ctx, call.UserIdFromPublicPath(), call.RequestHash, common.RequestStatePending) if err != nil { panic(fmt.Errorf("node.GetSystemCallReferenceTxs(%s) => %v", call.RequestId, err)) } diff --git a/solana/system_call.go b/solana/system_call.go index 8a657d4..ab48ad7 100644 --- a/solana/system_call.go +++ b/solana/system_call.go @@ -272,7 +272,7 @@ func (node *Node) getPostProcessCall(ctx context.Context, req *store.Request, fl return nil, err } - os, _, err := node.GetSystemCallReferenceOutputs(ctx, main.UserIdFromPublicPath(), main.RequestHash, 0) + os, _, err := node.GetSystemCallReferenceOutputs(ctx, main.UserIdFromPublicPath(), main.RequestHash, common.RequestStatePending) if err != nil { panic(fmt.Errorf("node.GetSystemCallReferenceTxs(%s) => %v", main.RequestId, err)) } From 70c845e67f8211e7e20a0328b9006b03776127a7 Mon Sep 17 00:00:00 2001 From: hundredark Date: Mon, 24 Aug 2026 23:08:25 +0800 Subject: [PATCH 11/13] more checks when deploying assets --- apps/solana/rpc.go | 13 +++++++++++-- solana/mvm.go | 47 +++++++++++++++++++++++++++++++++++++++------- solana/solana.go | 2 +- 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/apps/solana/rpc.go b/apps/solana/rpc.go index c30e9b9..6039e38 100644 --- a/apps/solana/rpc.go +++ b/apps/solana/rpc.go @@ -37,7 +37,10 @@ type AssetMetadata struct { type Asset struct { Address string `json:"address"` Id string `json:"id"` + ProgramId string `json:"program_id"` + Supply string `json:"supply"` Decimals uint32 `json:"decimals"` + IsInitialized bool `json:"is_initialized"` MintAuthority string `json:"mint_authority"` FreezeAuthority string `json:"freeze_authority"` } @@ -106,14 +109,17 @@ func (c *Client) RPCGetAsset(ctx context.Context, address string) (*Asset, error if err != nil { return nil, fmt.Errorf("solana.RPCGetAsset(%s) => %v", address, err) } + if account == nil || account.Value == nil { + return nil, nil + } data, err := account.Value.Data.MarshalJSON() if err != nil { - panic(err) + return nil, fmt.Errorf("solana.RPCGetAsset(%s) marshal => %v", address, err) } var mint MintData err = json.Unmarshal(data, &mint) if err != nil { - panic(err) + return nil, fmt.Errorf("solana.RPCGetAsset(%s) unmarshal => %v", address, err) } mintAuthority := "" @@ -127,7 +133,10 @@ func (c *Client) RPCGetAsset(ctx context.Context, address string) (*Asset, error asset := &Asset{ Address: address, Id: GenerateAssetId(address), + ProgramId: account.Value.Owner.String(), + Supply: mint.Parsed.Info.Supply, Decimals: uint32(mint.Parsed.Info.Decimals), + IsInitialized: mint.Parsed.Info.IsInitialized, MintAuthority: mintAuthority, FreezeAuthority: freezeAuthority, } diff --git a/solana/mvm.go b/solana/mvm.go index 00eacf4..653a698 100644 --- a/solana/mvm.go +++ b/solana/mvm.go @@ -466,13 +466,16 @@ func (node *Node) processDeployExternalAssetsCall(ctx context.Context, req *stor return node.failRequest(ctx, req, "") } if !common.CheckTestEnvironment(ctx) { // TODO should not skip the test - mint, err := node.RPCGetAsset(ctx, address) - if err != nil || mint == nil || - mint.Decimals != uint32(solanaApp.AssetDecimal) || - mint.MintAuthority != node.getMTGAddress(ctx).String() || - mint.FreezeAuthority != "" { - // TODO check symbol and name - panic(fmt.Errorf("solana.RPCGetAsset(%s) => %v", address, mint)) + // Deployment validation must use current on-chain state. The general + // asset lookup is cached and could otherwise preserve a stale supply. + mint, err := node.solana.RPCGetAsset(ctx, address) + if err != nil { + panic(fmt.Errorf("solana.RPCGetAsset(%s) => %v", address, err)) + } + err = validateExternalAssetMint(address, node.getMTGAddress(ctx).String(), mint) + if err != nil { + logger.Printf("validateExternalAssetMint(%s) => %v", address, err) + return node.failRequest(ctx, req, "") } } as = append(as, &solanaApp.DeployedAsset{ @@ -493,6 +496,36 @@ func (node *Node) processDeployExternalAssetsCall(ctx context.Context, req *stor return nil, "" } +func validateExternalAssetMint(address, mtg string, mint *solanaApp.Asset) error { + if mint == nil { + return fmt.Errorf("mint not found") + } + if mint.Address != address { + return fmt.Errorf("invalid address: %s", mint.Address) + } + if mint.ProgramId != solana.TokenProgramID.String() { + return fmt.Errorf("invalid program: %s", mint.ProgramId) + } + if !mint.IsInitialized { + return fmt.Errorf("mint is not initialized") + } + if mint.Decimals != uint32(solanaApp.AssetDecimal) { + return fmt.Errorf("invalid decimals: %d", mint.Decimals) + } + if mint.MintAuthority != mtg { + return fmt.Errorf("invalid mint authority: %s", mint.MintAuthority) + } + if mint.FreezeAuthority != "" { + return fmt.Errorf("invalid freeze authority: %s", mint.FreezeAuthority) + } + + supply, ok := new(big.Int).SetString(mint.Supply, 10) + if !ok || supply.Sign() != 0 { + return fmt.Errorf("invalid initial supply: %s", mint.Supply) + } + return nil +} + func (node *Node) processConfirmCall(ctx context.Context, req *store.Request) ([]*mtg.Transaction, string) { if req.Role != RequestRoleObserver { panic(req.Role) diff --git a/solana/solana.go b/solana/solana.go index efaf6b6..0533fd5 100644 --- a/solana/solana.go +++ b/solana/solana.go @@ -884,7 +884,7 @@ func (node *Node) VerifySubSystemCall(ctx context.Context, tx *solana.Transactio switch programKey { case system.ProgramID: if _, ok := solanaApp.DecodeCreateAccount(accounts, ix.Data); ok { - continue + return fmt.Errorf("create account is not allowed in subsystem call") } if transfer, ok := solanaApp.DecodeSystemTransfer(accounts, ix.Data); ok { recipient := transfer.GetRecipientAccount().PublicKey From 1c4119f949b3b82a39590fd76b0025576bb130fa Mon Sep 17 00:00:00 2001 From: hundredark Date: Mon, 24 Aug 2026 23:23:38 +0800 Subject: [PATCH 12/13] expire call if invalid fee --- solana/mvm.go | 12 ++++-- solana/observer.go | 22 ++++++++++- solana/system_call.go | 85 ++++++++++++++++++++++++++++++++++++------- 3 files changed, 100 insertions(+), 19 deletions(-) diff --git a/solana/mvm.go b/solana/mvm.go index 653a698..0833a4d 100644 --- a/solana/mvm.go +++ b/solana/mvm.go @@ -217,6 +217,13 @@ func (node *Node) processSystemCall(ctx context.Context, req *store.Request) ([] if err != nil || storage == nil { return node.failRequest(ctx, req, "") } + // External-asset deployments are MTG consensus state and can be checked + // deterministically before the call is persisted. + err = node.validateSystemCallReferencedAssets(ctx, os) + if err != nil { + logger.Printf("node.validateSystemCallReferencedAssets(%s) => %v", req.Id, err) + return node.failRequest(ctx, req, "") + } cid := uuid.Must(uuid.FromBytes(data[8:24])).String() skipPostProcess := false @@ -325,10 +332,9 @@ func (node *Node) processConfirmNonce(ctx context.Context, req *store.Request) ( if err != nil { panic(err) } - as := node.GetSystemCallRelatedAsset(ctx, os) - switch flag { case ConfirmFlagNonceAvailable: + as := node.GetSystemCallRelatedAsset(ctx, os) var sessions []*store.Session prepare, tx, err := node.getSubSystemCallFromExtra(ctx, req, extra[16:]) if err != nil { @@ -933,7 +939,7 @@ func (node *Node) failDepositRequest(ctx context.Context, out *mtg.Action, compa } func (node *Node) refundAndFailRequest(ctx context.Context, req *store.Request, members []string, threshod int, call *store.SystemCall, os []*store.UserOutput) ([]*mtg.Transaction, string) { - as := node.GetSystemCallRelatedAsset(ctx, os) + as := aggregateSystemCallReferenceAssets(os) txs, compaction := node.buildRefundTxs(ctx, req, call.RequestId, as, members, threshod) err := node.store.RefundOutputsWithRequest(ctx, req, call, os, txs, compaction) if err != nil { diff --git a/solana/observer.go b/solana/observer.go index 76ed918..26e9ebd 100644 --- a/solana/observer.go +++ b/solana/observer.go @@ -602,11 +602,29 @@ func (node *Node) handleUnconfirmedCalls(ctx context.Context) error { extra := []byte{ConfirmFlagNonceAvailable} extra = append(extra, uuid.Must(uuid.FromString(call.RequestId)).Bytes()...) + failureReason := "" if nonce == nil || !nonce.Valid(call.RequestId) { - logger.Printf("observer.expireSystemCall(%v %v %v)", call, nonce, err) + failureReason = "expired or invalid nonce" + } else { + req, err := node.store.ReadRequestByHash(ctx, call.RequestHash) + if err != nil { + return err + } + os, _, err := node.GetSystemCallReferenceOutputs(ctx, call.UserIdFromPublicPath(), call.RequestHash, common.RequestStatePending) + if err != nil { + return err + } + err = node.validateSystemCallParameters(ctx, req, os) + if err != nil { + failureReason = err.Error() + } + } + + if failureReason != "" { + logger.Printf("observer.expireSystemCall(%v %v %s)", call, nonce, failureReason) id = common.UniqueId(id, "expire-nonce") extra[0] = ConfirmFlagNonceExpired - err = node.store.WriteFailedCallIfNotExist(ctx, call, "expired or invalid nonce") + err = node.store.WriteFailedCallIfNotExist(ctx, call, failureReason) if err != nil { return err } diff --git a/solana/system_call.go b/solana/system_call.go index ab48ad7..d33d47a 100644 --- a/solana/system_call.go +++ b/solana/system_call.go @@ -136,6 +136,25 @@ func (node *Node) getSystemCallReferenceTx(ctx context.Context, uid, hash string // be used to create prepare call by observer with fee from payer (isolatedFee = true) // be used to create post call by observer with fee to calculate rest SOL func (node *Node) GetSystemCallRelatedAsset(ctx context.Context, os []*store.UserOutput) []*ReferencedTxAsset { + assets := aggregateSystemCallReferenceAssets(os) + for _, asset := range assets { + if asset.Solana { + continue + } + deployed, err := node.store.ReadDeployedAsset(ctx, asset.AssetId) + if err != nil || deployed == nil { + panic(fmt.Errorf("store.ReadDeployedAsset(%s) => %v %v", asset.AssetId, deployed, err)) + } + asset.Address = deployed.Address + asset.Decimal = solanaApp.AssetDecimal + } + return assets +} + +// Aggregate asset identity and amount without requiring a Solana mint mapping. +// GetSystemCallRelatedAsset fills that mapping for Solana transactions, while +// failed calls can use the aggregate directly for Mixin refunds. +func aggregateSystemCallReferenceAssets(os []*store.UserOutput) []*ReferencedTxAsset { am := make(map[string]*ReferencedTxAsset) for _, output := range os { logger.Printf("node.GetReferencedTxAsset() => %v", output) @@ -144,11 +163,7 @@ func (node *Node) GetSystemCallRelatedAsset(ctx context.Context, os []*store.Use address := output.Asset.AssetKey decimal := output.Asset.Precision if !isSolAsset { - da, err := node.store.ReadDeployedAsset(ctx, output.AssetId) - if err != nil || da == nil { - panic(fmt.Errorf("store.ReadDeployedAsset(%s) => %v %v", output.AssetId, da, err)) - } - address = da.Address + address = "" decimal = solanaApp.AssetDecimal } ra := &ReferencedTxAsset{ @@ -176,27 +191,69 @@ func (node *Node) GetSystemCallRelatedAsset(ctx context.Context, os []*store.Use return assets } -// should only return error when no valid fees found -func (node *Node) getSystemCallFeeFromXIN(ctx context.Context, call *store.SystemCall) (*store.UserOutput, error) { - req, err := node.store.ReadRequestByHash(ctx, call.RequestHash) +func (node *Node) validateSystemCallParameters(ctx context.Context, req *store.Request, os []*store.UserOutput) error { + if req == nil { + return fmt.Errorf("missing system call request") + } + _, err := node.readSystemCallFeeInfo(ctx, req) if err != nil { - panic(err) + return err } + return node.validateSystemCallReferencedAssets(ctx, os) +} + +func (node *Node) validateSystemCallReferencedAssets(ctx context.Context, os []*store.UserOutput) error { + checked := make(map[string]bool) + for _, output := range os { + if output.ChainId == solanaApp.SolanaChainBase || checked[output.AssetId] { + continue + } + checked[output.AssetId] = true + deployed, err := node.store.ReadDeployedAsset(ctx, output.AssetId) + if err != nil { + panic(fmt.Errorf("store.ReadDeployedAsset(%s) => %v", output.AssetId, err)) + } + if deployed == nil { + return fmt.Errorf("external asset is not deployed: %s", output.AssetId) + } + } + return nil +} + +func (node *Node) readSystemCallFeeInfo(ctx context.Context, req *store.Request) (*store.FeeInfo, error) { extra := req.ExtraBytes() - if len(extra) != 41 { + switch len(extra) { + case 25: return nil, nil + case 41: + default: + return nil, fmt.Errorf("invalid system call extra length: %d", len(extra)) } - feeId := uuid.Must(uuid.FromBytes(extra[25:])).String() - var fee *store.FeeInfo - fee, err = node.store.ReadFeeInfoById(ctx, feeId) + feeId := uuid.Must(uuid.FromBytes(extra[25:])).String() + fee, err := node.store.ReadFeeInfoById(ctx, feeId) logger.Printf("store.ReadFeeInfoById(%s) => %v %v", feeId, fee, err) if err != nil { - panic(err) + panic(fmt.Errorf("store.ReadFeeInfoById(%s) => %v", feeId, err)) } if fee == nil { // TODO check fee timestamp against the call timestamp not too old return nil, fmt.Errorf("invalid fee id: %s", feeId) } + return fee, nil +} + +// should only return error when no valid fees found +func (node *Node) getSystemCallFeeFromXIN(ctx context.Context, call *store.SystemCall) (*store.UserOutput, error) { + req, err := node.store.ReadRequestByHash(ctx, call.RequestHash) + if err != nil || req == nil { + panic(fmt.Errorf("store.ReadRequestByHash(%s) => %v %v", call.RequestHash, req, err)) + } + fee, err := node.readSystemCallFeeInfo(ctx, req) + if err != nil { + return nil, err + } else if fee == nil { + return nil, nil + } ratio := decimal.RequireFromString(fee.Ratio) plan, err := node.store.ReadLatestOperationParams(ctx, req.CreatedAt) From 6ede86a4f2b299c90e442e06c751a7359ef80073 Mon Sep 17 00:00:00 2001 From: hundredark Date: Tue, 25 Aug 2026 00:16:25 +0800 Subject: [PATCH 13/13] slight fixes --- solana/mvm.go | 5 +++-- solana/system_call.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/solana/mvm.go b/solana/mvm.go index 0833a4d..de095c6 100644 --- a/solana/mvm.go +++ b/solana/mvm.go @@ -1197,6 +1197,7 @@ func checkUser(ctx context.Context, req *store.Request, mix *bot.MixAddress) boo if common.CheckTestEnvironment(ctx) { return true } - - return mix.Threshold == byte(req.Output.SendersThreshold) && bot.HashMembers(mix.Members()) == req.Output.SendersHash + senders := append([]string(nil), req.Output.Senders...) + return mix.Threshold == byte(req.Output.SendersThreshold) && + bot.HashMembers(mix.Members()) == bot.HashMembers(senders) } diff --git a/solana/system_call.go b/solana/system_call.go index d33d47a..d2d5972 100644 --- a/solana/system_call.go +++ b/solana/system_call.go @@ -74,7 +74,7 @@ func (node *Node) GetSystemCallReferenceOutputs(ctx context.Context, uid, reques if storage == nil { storage = hash } else if storage.String() != hash.String() { - panic(storage.String()) + return nil, nil, fmt.Errorf("multiple storage references: %s != %s", storage.String(), hash.String()) } } return outputs, storage, nil