From 6820430fe47ccdeb6e33133d805d3a64d641b0d1 Mon Sep 17 00:00:00 2001 From: hundredark Date: Mon, 24 Aug 2026 10:17:28 +0800 Subject: [PATCH 01/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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 From 296d28793ed2ada6ef970a54f1ed5bb97d9c31cb Mon Sep 17 00:00:00 2001 From: hundredark Date: Tue, 25 Aug 2026 19:37:44 +0800 Subject: [PATCH 14/18] fix restore request state --- solana/computer_test.go | 123 ++++++++++++++++++++++++++++++++++++++++ solana/mvm.go | 2 +- solana/system_call.go | 11 +++- store/call.go | 10 +++- store/request.go | 4 +- store/test.go | 22 +++++++ 6 files changed, 165 insertions(+), 7 deletions(-) diff --git a/solana/computer_test.go b/solana/computer_test.go index 4e8fe69..4169bdc 100644 --- a/solana/computer_test.go +++ b/solana/computer_test.go @@ -287,6 +287,100 @@ func TestPostprocessCompaction(t *testing.T) { } } +func TestFailedPostprocessCompaction(t *testing.T) { + require := require.New(t) + ctx, nodes, mds := testPrepare(require, false) + + testObserverRequestGenerateKey(ctx, require, nodes) + testObserverRequestCreateNonceAccount(ctx, require, nodes) + testObserverSetPriceParams(ctx, require, nodes) + testObserverUpdateNetworInfo(ctx, require, nodes) + testObserverDeployLitecoinAsset(ctx, require, nodes) + user := testUserRequestAddUsers(ctx, require, nodes) + main := testUserRequestSystemCall(ctx, require, nodes, mds, user) + + node := nodes[0] + prepareId := common.UniqueId(main.RequestId, "prepare") + prepare, err := node.store.ReadSystemCallByRequestId(ctx, prepareId, common.RequestStatePending) + require.Nil(err) + require.NotNil(prepare) + + confirmId := common.UniqueId(prepare.RequestId, "confirm-fail") + postId := common.UniqueId(confirmId, "post-process") + nonce := node.ReadSpareNonceAccountWithCall(ctx, postId) + postTx, err := node.CreatePrepareTransaction(ctx, main, nonce, nil) + require.Nil(err) + require.NotNil(postTx) + raw, err := postTx.MarshalBinary() + require.Nil(err) + + extra := []byte{FlagConfirmCallFail} + extra = append(extra, uuid.Must(uuid.FromString(prepare.RequestId)).Bytes()...) + extra = attachSystemCall(extra, postId, raw) + out := testBuildObserverRequest(node, "329346e1-34c2-4de0-8e35-729518eda8bd", OperationTypeConfirmCall, extra) + + testSpendAllGroupOutputs(ctx, require, nodes, common.SafeLitecoinChainId) + + for _, node := range nodes { + testStep(ctx, require, node, out) + + prepare, err := node.store.ReadSystemCallByRequestId(ctx, prepareId, common.RequestStateFailed) + require.Nil(err) + require.NotNil(prepare) + main, err := node.store.ReadSystemCallByRequestId(ctx, main.RequestId, common.RequestStateFailed) + require.Nil(err) + require.NotNil(main) + outputs, _, err := node.GetSystemCallReferenceOutputs(ctx, user.UserId, main.RequestHash, common.RequestStateDone) + require.Nil(err) + require.Len(outputs, 2) + sub, err := node.store.ReadSystemCallByRequestId(ctx, postId, common.RequestStatePending) + require.Nil(err) + require.NotNil(sub) + ar, handled, err := node.store.ReadActionResult(ctx, out.OutputId, out.OutputId) + require.Nil(err) + require.True(handled) + require.Equal(common.SafeLitecoinChainId, ar.Compaction) + require.Len(ar.Transactions, 0) + req, err := node.store.ReadRequest(ctx, out.OutputId) + require.Nil(err) + require.Equal(uint8(common.RequestStateFailed), req.State) + } + + // Simulate a compacted failure persisted by older nodes before the request was + // left retryable. + err = nodes[0].store.TestSetRequestState(ctx, out.OutputId, common.RequestStateDone) + require.Nil(err) + + sequence += 100 + _, err = testWriteOutputForNodes(ctx, mds, node.conf.AppId, common.SafeLitecoinChainId, "", "", sequence, decimal.RequireFromString("0.0108")) + require.Nil(err) + out.Sequence = sequence + + for _, node := range nodes { + testStep(ctx, require, node, out) + + prepare, err := node.store.ReadSystemCallByRequestId(ctx, prepareId, common.RequestStateFailed) + require.Nil(err) + require.NotNil(prepare) + main, err := node.store.ReadSystemCallByRequestId(ctx, main.RequestId, common.RequestStateFailed) + require.Nil(err) + require.NotNil(main) + require.Len(main.GetRefundIds(), 1) + outputs, _, err := node.GetSystemCallReferenceOutputs(ctx, user.UserId, main.RequestHash, common.RequestStateDone) + require.Nil(err) + require.Len(outputs, 2) + ar, handled, err := node.store.ReadActionResult(ctx, out.OutputId, out.OutputId) + require.Nil(err) + require.True(handled) + require.Equal("", ar.Compaction) + require.Len(ar.Transactions, 1) + require.Equal(common.SafeLitecoinChainId, ar.Transactions[0].AssetId) + req, err := node.store.ReadRequest(ctx, out.OutputId) + require.Nil(err) + require.Equal(uint8(common.RequestStateDone), req.State) + } +} + func testObserverConfirmPostProcessCall(ctx context.Context, require *require.Assertions, nodes []*Node, sub *store.SystemCall) { node := nodes[0] err := node.store.UpdateNonceAccount(ctx, sub.NonceAccount, "6c8hGTPpTd4RMbYyM3wQgnwxZbajKhovhfDgns6bvmrX", sub.RequestId) @@ -676,6 +770,35 @@ func testObserverSetPriceParams(ctx context.Context, require *require.Assertions } } +func testObserverDeployLitecoinAsset(ctx context.Context, require *require.Assertions, nodes []*Node) { + extra := []byte{1} + extra = append(extra, uuid.Must(uuid.FromString(common.SafeLitecoinChainId)).Bytes()...) + extra = append(extra, solana.MustPublicKeyFromBase58("EFShFtXaMF1n1f6k3oYRd81tufEXzUuxYM6vkKrChVs8").Bytes()...) + + out := testBuildObserverRequest(nodes[0], uuid.Must(uuid.NewV4()).String(), OperationTypeDeployExternalAssets, extra) + for _, node := range nodes { + testStep(ctx, require, node, out) + + asset, err := node.store.ReadDeployedAsset(ctx, common.SafeLitecoinChainId) + require.Nil(err) + require.NotNil(asset) + require.Equal("EFShFtXaMF1n1f6k3oYRd81tufEXzUuxYM6vkKrChVs8", asset.Address) + } +} + +func testSpendAllGroupOutputs(ctx context.Context, require *require.Assertions, nodes []*Node, assetId string) { + for _, node := range nodes { + for { + os := node.group.ListOutputsForAsset(ctx, node.conf.AppId, assetId, 0, sequence, mtg.SafeUtxoStateUnspent, mtg.OutputsBatchSize) + if len(os) == 0 { + break + } + err := node.group.TestUpdateOutputsState(ctx, os, "spent") + require.Nil(err) + } + } +} + func testObserverDeployAsset(ctx context.Context, require *require.Assertions, nodes []*Node) { node := nodes[0] diff --git a/solana/mvm.go b/solana/mvm.go index de095c6..43028f5 100644 --- a/solana/mvm.go +++ b/solana/mvm.go @@ -993,7 +993,7 @@ func (node *Node) failSystemCall(ctx context.Context, req *store.Request, call * } } - os, _, err := node.GetSystemCallReferenceOutputs(ctx, main.UserIdFromPublicPath(), main.RequestHash, common.RequestStatePending) + os, _, err := node.GetSystemCallReferenceOutputs(ctx, main.UserIdFromPublicPath(), main.RequestHash, systemCallReferenceOutputState(call)) if err != nil { panic(err) } diff --git a/solana/system_call.go b/solana/system_call.go index d2d5972..f157875 100644 --- a/solana/system_call.go +++ b/solana/system_call.go @@ -31,6 +31,15 @@ type ReferencedTxAsset struct { Fee bool } +func systemCallReferenceOutputState(call *store.SystemCall) byte { + // Failed system calls may be replayed after compaction; the first attempt has + // already moved their references from pending to done. + if call.State == common.RequestStateFailed { + return common.RequestStateDone + } + return common.RequestStatePending +} + // should only return error when mtg could not find outputs from referenced transaction // all assets needed in system call should be referenced // extra amount of XIN is used for fees in system call like rent @@ -329,7 +338,7 @@ func (node *Node) getPostProcessCall(ctx context.Context, req *store.Request, fl return nil, err } - os, _, err := node.GetSystemCallReferenceOutputs(ctx, main.UserIdFromPublicPath(), main.RequestHash, common.RequestStatePending) + os, _, err := node.GetSystemCallReferenceOutputs(ctx, main.UserIdFromPublicPath(), main.RequestHash, systemCallReferenceOutputState(main)) if err != nil { panic(fmt.Errorf("node.GetSystemCallReferenceTxs(%s) => %v", main.RequestId, err)) } diff --git a/store/call.go b/store/call.go index 88acd83..84d4336 100644 --- a/store/call.go +++ b/store/call.go @@ -356,8 +356,8 @@ func (s *SQLite3Store) FailSystemCallWithRequest(ctx context.Context, req *Reque for _, tx := range txs { ids = append(ids, tx.TraceId) } - query = "UPDATE system_calls SET state=?, refund_traces=?, updated_at=? WHERE superior_id=? AND call_type=? AND state=?" - _, err = tx.ExecContext(ctx, query, common.RequestStateFailed, strings.Join(ids, ","), req.CreatedAt, call.Superior, CallTypeMain, common.RequestStatePending) + query = "UPDATE system_calls SET state=?, refund_traces=?, updated_at=? WHERE superior_id=? AND call_type=? AND (state=? OR state=?)" + err = s.execOne(ctx, tx, query, common.RequestStateFailed, strings.Join(ids, ","), req.CreatedAt, call.Superior, CallTypeMain, common.RequestStatePending, common.RequestStateFailed) if err != nil { return fmt.Errorf("SQLite3Store UPDATE system_calls %v", err) } @@ -395,7 +395,11 @@ func (s *SQLite3Store) FailSystemCallWithRequest(ctx context.Context, req *Reque } } - err = s.finishRequest(ctx, tx, req, txs, compaction) + if compaction == "" { + err = s.finishRequest(ctx, tx, req, txs, compaction) + } else { + err = s.failRequest(ctx, tx, req, txs, compaction) + } if err != nil { return err } diff --git a/store/request.go b/store/request.go index 0836a0a..81eba80 100644 --- a/store/request.go +++ b/store/request.go @@ -230,8 +230,8 @@ func (s *SQLite3Store) ResetRequest(ctx context.Context, reqId string, reqSequen } defer common.Rollback(tx) - _, err = tx.ExecContext(ctx, "UPDATE requests SET state=?, sequence=?, updated_at=? WHERE request_id=? AND state=?", - common.RequestStateInitial, reqSequence, time.Now().UTC(), reqId, common.RequestStateFailed) + err = s.execOne(ctx, tx, "UPDATE requests SET state=?, sequence=?, updated_at=? WHERE request_id=? AND (state=? OR state=?)", + common.RequestStateInitial, reqSequence, time.Now().UTC(), reqId, common.RequestStateFailed, common.RequestStateDone) if err != nil { return fmt.Errorf("UPDATE requests %v", err) } diff --git a/store/test.go b/store/test.go index 7efd682..7c41f9b 100644 --- a/store/test.go +++ b/store/test.go @@ -69,6 +69,28 @@ func (s *SQLite3Store) TestWriteCall(ctx context.Context, call *SystemCall) erro return tx.Commit() } +func (s *SQLite3Store) TestSetRequestState(ctx context.Context, id string, state byte) error { + if !common.CheckTestEnvironment(ctx) { + panic(ctx) + } + s.mutex.Lock() + defer s.mutex.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer common.Rollback(tx) + + err = s.execOne(ctx, tx, "UPDATE requests SET state=?, updated_at=? WHERE request_id=?", + state, time.Now().UTC(), id) + if err != nil { + return fmt.Errorf("UPDATE requests %v", err) + } + + return tx.Commit() +} + func (s *SQLite3Store) TestWriteSignSession(ctx context.Context, call *SystemCall, sessions []*Session) error { if !common.CheckTestEnvironment(ctx) { panic(ctx) From 89eec1efa61a6b4dc98397c69f2ac95b63d6e822 Mon Sep 17 00:00:00 2001 From: hundredark Date: Tue, 25 Aug 2026 22:49:15 +0800 Subject: [PATCH 15/18] fix confirmation of call and verification of sub call --- solana/computer_test.go | 66 ++++++-- solana/mvm.go | 346 ++++++++++++++++++++++++++-------------- solana/observer.go | 41 +++-- solana/request.go | 58 +++++++ solana/solana.go | 63 +++++++- solana/system_call.go | 101 +++++++++++- solana/test.go | 7 + store/call.go | 49 ++++-- 8 files changed, 560 insertions(+), 171 deletions(-) diff --git a/solana/computer_test.go b/solana/computer_test.go index 4169bdc..583cad1 100644 --- a/solana/computer_test.go +++ b/solana/computer_test.go @@ -245,13 +245,16 @@ func TestPostprocessCompaction(t *testing.T) { user := testUserRequestAddUsers(ctx, require, nodes) call := testUserRequestSystemCall(ctx, require, nodes, mds, user) testConfirmWithdrawal(ctx, require, nodes, call) - testObserverConfirmMainCall(ctx, require, nodes, call) + sub := testObserverConfirmMainCall(ctx, require, nodes, call) node := nodes[0] id := "329346e1-34c2-4de0-8e35-729518eda8bd" signature := solana.MustSignatureFromBase58("5s3UBMymdgDHwYvuaRdq9SLq94wj5xAgYEsDDB7TQwwuLy1TTYcSf6rF4f2fDfF7PnA9U75run6r1pKm9K1nusCR") - extra := []byte{FlagConfirmCallSuccess, 1} - extra = append(extra, signature[:]...) + extra := encodeConfirmCallRecords([]confirmCallRecord{{ + Status: FlagConfirmCallSuccess, + CallId: sub.RequestId, + Signature: signature, + }}) out := testBuildObserverRequest(node, id, OperationTypeConfirmCall, extra) for i := range 2 { @@ -308,14 +311,46 @@ func TestFailedPostprocessCompaction(t *testing.T) { confirmId := common.UniqueId(prepare.RequestId, "confirm-fail") postId := common.UniqueId(confirmId, "post-process") nonce := node.ReadSpareNonceAccountWithCall(ctx, postId) - postTx, err := node.CreatePrepareTransaction(ctx, main, nonce, nil) - require.Nil(err) + postTx := node.CreateRefundWithdrawalTransaction(ctx, prepare, main, nonce) require.NotNil(postTx) raw, err := postTx.MarshalBinary() require.Nil(err) - extra := []byte{FlagConfirmCallFail} - extra = append(extra, uuid.Must(uuid.FromString(prepare.RequestId)).Bytes()...) + failedSignature := solana.MustSignatureFromBase58("sKxPceKjZb4PiywqwMP3Wyf9YoPqdgZZ5MLNxkpRv7qgTXVxK8UQRGkjMT47qJht5muuUPpqynkrM5C6BcYSELg") + successSignature := solana.MustSignatureFromBase58("2tPHv7kbUeHRWHgVKKddQqXnjDhuX84kTyCvRy1BmCM4m4Fkq4vJmNAz8A7fXqckrSNRTAKuPmAPWnzr5T7eCChb") + rejectedExtras := [][]byte{ + // Prepare is never confirmed successfully without its Main call. + encodeConfirmCallRecords([]confirmCallRecord{{ + Status: FlagConfirmCallSuccess, + CallId: prepare.RequestId, + Signature: successSignature, + }}), + // This failed Prepare has refundable Solana withdrawals, so omitting its + // refund transaction must not advance either call. + encodeConfirmCallRecords([]confirmCallRecord{{ + Status: FlagConfirmCallFail, + CallId: prepare.RequestId, + Signature: failedSignature, + }}), + } + for _, rejectedExtra := range rejectedExtras { + rejected := testBuildObserverRequest(node, uuid.Must(uuid.NewV4()).String(), OperationTypeConfirmCall, rejectedExtra) + for _, node := range nodes { + testStep(ctx, require, node, rejected) + prepare, err := node.store.ReadSystemCallByRequestId(ctx, prepareId, common.RequestStatePending) + require.Nil(err) + require.NotNil(prepare) + main, err := node.store.ReadSystemCallByRequestId(ctx, main.RequestId, common.RequestStatePending) + require.Nil(err) + require.NotNil(main) + } + } + + extra := encodeConfirmCallRecords([]confirmCallRecord{{ + Status: FlagConfirmCallFail, + CallId: prepare.RequestId, + Signature: failedSignature, + }}) extra = attachSystemCall(extra, postId, raw) out := testBuildObserverRequest(node, "329346e1-34c2-4de0-8e35-729518eda8bd", OperationTypeConfirmCall, extra) @@ -393,8 +428,11 @@ func testObserverConfirmPostProcessCall(ctx context.Context, require *require.As id := uuid.Must(uuid.NewV4()).String() signature := solana.MustSignatureFromBase58("5s3UBMymdgDHwYvuaRdq9SLq94wj5xAgYEsDDB7TQwwuLy1TTYcSf6rF4f2fDfF7PnA9U75run6r1pKm9K1nusCR") - extra := []byte{FlagConfirmCallSuccess, 1} - extra = append(extra, signature[:]...) + extra := encodeConfirmCallRecords([]confirmCallRecord{{ + Status: FlagConfirmCallSuccess, + CallId: sub.RequestId, + Signature: signature, + }}) err = node.store.WritePendingBurnSystemCallIfNotExists(ctx, sub, &common.Operation{ Id: id, @@ -462,11 +500,11 @@ func testObserverConfirmMainCall(ctx context.Context, require *require.Assertion solana.MustSignatureFromBase58("2tPHv7kbUeHRWHgVKKddQqXnjDhuX84kTyCvRy1BmCM4m4Fkq4vJmNAz8A7fXqckrSNRTAKuPmAPWnzr5T7eCChb"), solana.MustSignatureFromBase58("42fwVqHYmfLqoqQ3XgELu72FL6t2Q2HCqY7XzkVCdVsWHGoT6DHBk7qzoUkpfjqs42ygSSnFWzarQZdpUX9tLK6r"), } - extra := []byte{FlagConfirmCallSuccess} - extra = append(extra, byte(len(signatures))) - for _, sig := range signatures { - extra = append(extra, sig[:]...) - } + prepareId := common.UniqueId(call.RequestId, "prepare") + extra := encodeConfirmCallRecords([]confirmCallRecord{ + {Status: FlagConfirmCallSuccess, CallId: prepareId, Signature: signatures[0]}, + {Status: FlagConfirmCallSuccess, CallId: call.RequestId, Signature: signatures[1]}, + }) extra = attachSystemCall(extra, cid, raw) var postprocess *store.SystemCall diff --git a/solana/mvm.go b/solana/mvm.go index 43028f5..7bd50d0 100644 --- a/solana/mvm.go +++ b/solana/mvm.go @@ -14,7 +14,6 @@ import ( mc "github.com/MixinNetwork/mixin/common" "github.com/MixinNetwork/mixin/crypto" "github.com/MixinNetwork/mixin/logger" - "github.com/MixinNetwork/mixin/util/base58" "github.com/MixinNetwork/safe/apps/mixin" "github.com/MixinNetwork/safe/common" "github.com/MixinNetwork/safe/mtg" @@ -346,6 +345,11 @@ func (node *Node) processConfirmNonce(ctx context.Context, req *store.Request) ( prepare.Public = hex.EncodeToString(user.FingerprintWithEmptyPath()) prepare.State = common.RequestStatePending + err = node.VerifySubSystemCallEnvelope(tx, node.getMTGAddress(ctx)) + logger.Printf("node.VerifySubSystemCallEnvelope(%s) => %v", prepare.RequestId, err) + if err != nil { + return node.failRequest(ctx, req, "") + } err = node.VerifySubSystemCall(ctx, tx, solana.MustPublicKeyFromBase58(node.conf.SolanaDepositEntry), solana.MustPublicKeyFromBase58(user.ChainAddress)) logger.Printf("node.VerifySubSystemCall(%s) => %v", user.ChainAddress, err) if err != nil { @@ -532,6 +536,27 @@ func validateExternalAssetMint(address, mtg string, mint *solanaApp.Asset) error return nil } +// processConfirmCall accepts the following record combinations: +// +// 1 [success:Main]: confirm a Main call that has no pending Prepare; optional +// storage contains its normal post-process call. +// 2 [fail:Main]: fail a Main call that has no pending Prepare; optional +// storage contains its cleanup call. +// 3 [fail:Prepare]: fail Prepare and its Main call; storage contains the +// Solana-asset refund call exactly when such a refund exists. +// 4 [success:Prepare, success:Main]: confirm both calls; optional storage +// contains Main's normal post-process call. +// 5 [success:Prepare, fail:Main]: confirm Prepare and fail Main; optional +// storage contains Main's cleanup call. +// 6 [success:Deposit/PostProcess]: finish one terminal call; +// storage must be empty. +// 7 [fail:Deposit/PostProcess]: fail one terminal call; +// storage must be empty. +// +// No other ordering is valid: there are at most two records, a failed record +// must be last, and a two-record sequence must be Prepare followed by its Main. +// Every record must also match a Solana transaction whose Meta.Err agrees with +// the reported status. func (node *Node) processConfirmCall(ctx context.Context, req *store.Request) ([]*mtg.Transaction, string) { if req.Role != RequestRoleObserver { panic(req.Role) @@ -540,117 +565,134 @@ func (node *Node) processConfirmCall(ctx context.Context, req *store.Request) ([ panic(req.Action) } - extra := req.ExtraBytes() - if len(extra) < 1 { - logger.Printf("invalid extra length for confirm call: %d", len(extra)) + records, storage, err := decodeConfirmCallRecords(req.ExtraBytes()) + if err != nil { + logger.Printf("decodeConfirmCallRecords(%s) => %v", req.Id, err) 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) + calls := make([]*store.SystemCall, 0, len(records)) + transactions := make([]*rpc.GetTransactionResult, 0, len(records)) + // Verify every reported result independently against the transaction that + // Solana confirmed. validateConfirmCall also marks the in-memory call with + // the target state/hash; database writes still happen only after the whole + // record combination is accepted. + for _, record := range records { + call, transaction, err := node.validateConfirmCall(ctx, req, record.Status, record.CallId, record.Signature.String()) + logger.Printf("node.validateConfirmCall(%d %s %s) => %v", record.Status, record.CallId, record.Signature.String(), err) + if err != nil { return node.failRequest(ctx, req, "") } + calls = append(calls, call) + transactions = append(transactions, transaction) + } - var calls []*store.SystemCall - - signature := base58.Encode(extra[:64]) - call, tx, err := node.checkConfirmCallSignature(ctx, signature) - logger.Printf("node.checkConfirmCallSignature(%s) => %v", signature, err) - if err != nil { - if strings.Contains(err.Error(), "failed solana tx") { - return node.failSystemCall(ctx, req, call) - } + // A two-call confirmation is only valid for the execution sequence created + // by the observer: a successful Prepare followed by its Main call. + if len(calls) == 2 { + if records[0].Status != FlagConfirmCallSuccess || calls[0].Type != store.CallTypePrepare || + calls[1].Type != store.CallTypeMain || calls[0].Superior != calls[1].RequestId { + logger.Printf("invalid confirm call sequence: %v %v", calls[0], calls[1]) return node.failRequest(ctx, req, "") } - - switch call.Type { - case store.CallTypeDeposit, store.CallTypePostProcess: - return node.confirmBurnRelatedSystemCall(ctx, req, call, tx, signature) - case store.CallTypePrepare: - calls = append(calls, call) - if n == 2 { - signature := base58.Encode(extra[64:128]) - call, _, err = node.checkConfirmCallSignature(ctx, signature) - logger.Printf("node.checkConfirmCallSignature(%s) => %v", signature, err) - if err != nil { - if strings.Contains(err.Error(), "failed solana tx") { - return node.failSystemCall(ctx, req, call) - } - return node.failRequest(ctx, req, "") - } - calls = append(calls, call) - } + } + if len(calls) == 1 { + switch calls[0].Type { case store.CallTypeMain: - if n == 2 { - panic(call.Type) - } - calls = append(calls, call) - default: - panic(call.Type) - } - - var session *store.Session - var outputs []*store.UserOutput - var post *store.SystemCall - if call.Type == store.CallTypeMain { - os, _, err := node.GetSystemCallReferenceOutputs(ctx, call.UserIdFromPublicPath(), call.RequestHash, common.RequestStatePending) + // If Main still has a pending Prepare, its result cannot be confirmed alone. + needPrepare, err := node.store.CheckUnfinishedSubCalls(ctx, calls[0]) if err != nil { panic(err) } - outputs = os - - post, err = node.getPostProcessCall(ctx, req, flag, call, extra[n*64:]) - logger.Printf("node.getPostProcessCall(%v %v) => %v %v", req, call, post, err) - if err != nil { + if needPrepare { + logger.Printf("main confirm call is missing prepare evidence: %s", calls[0].RequestId) return node.failRequest(ctx, req, "") } - if post != nil { - session = &store.Session{ - Id: post.RequestId, - RequestId: post.RequestId, - MixinHash: req.MixinHash.String(), - MixinIndex: req.Output.OutputIndex, - Index: 0, - Operation: OperationTypeSignInput, - Public: post.Public, - Extra: post.MessageHex(), - CreatedAt: req.CreatedAt, - } + case store.CallTypePrepare: + // The observer never executes Prepare independently: ListSignedCalls always + // pairs it with Main. Accepting a standalone success would unnecessarily + // widen the confirmation protocol and allow Main evidence to be omitted. + if records[0].Status == FlagConfirmCallSuccess { + return node.failRequest(ctx, req, "") } } - err = node.store.ConfirmSystemCallsWithRequest(ctx, req, calls, post, session, outputs) - if err != nil { - panic(err) + } + // Execution stops at the first failed transaction, so a failure can only be + // the final record in the reported sequence. + for i, record := range records { + if record.Status == FlagConfirmCallFail && i != len(records)-1 { + logger.Printf("failed confirm call record must be last: %d", i) + return node.failRequest(ctx, req, "") } - return nil, "" - case FlagConfirmCallFail: - if len(extra) < uuid.Size { - logger.Printf("invalid extra length for failed confirm call: %d", len(extra)) + } + + call := calls[len(calls)-1] + flag := records[len(records)-1].Status + signature := records[len(records)-1].Signature.String() + + // Deposit and PostProcess calls are terminal calls. They cannot be combined + // with another confirmation or create another post-process transaction. + if call.Type == store.CallTypeDeposit || call.Type == store.CallTypePostProcess { + if len(calls) != 1 || len(storage) != 0 { 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) - if err != nil { - panic(err) + // For situation 7 + if flag == FlagConfirmCallFail { + return node.failSystemCall(ctx, req, call, nil, nil) } - return node.failSystemCall(ctx, req, call) - default: - logger.Printf("invalid confirm flag: %d", flag) + // For situation 6 + return node.confirmBurnRelatedSystemCall(ctx, req, call, transactions[0], signature) + } + + // Failed Main/Prepare calls may carry one cleanup transaction, which is + // validated by failSystemCall before a new signing session is created. + // For situation 2, 3, 5 + if flag == FlagConfirmCallFail { + return node.failSystemCall(ctx, req, call, storage, calls) + } + + // For situation 1, 4 + return node.confirmMainOrPrepareSystemCalls(ctx, req, calls, storage) +} + +func (node *Node) confirmMainOrPrepareSystemCalls(ctx context.Context, req *store.Request, calls []*store.SystemCall, storage []byte) ([]*mtg.Transaction, string) { + call := calls[len(calls)-1] + + var session *store.Session + var outputs []*store.UserOutput + var post *store.SystemCall + if call.Type != store.CallTypeMain { return node.failRequest(ctx, req, "") } + os, _, err := node.GetSystemCallReferenceOutputs(ctx, call.UserIdFromPublicPath(), call.RequestHash, common.RequestStatePending) + if err != nil { + panic(err) + } + outputs = os + + post, err = node.getPostProcessCall(ctx, req, FlagConfirmCallSuccess, call, storage) + logger.Printf("node.getPostProcessCall(%v %v) => %v %v", req, call, post, err) + if err != nil { + return node.failRequest(ctx, req, "") + } + if post != nil { + session = &store.Session{ + Id: post.RequestId, + RequestId: post.RequestId, + MixinHash: req.MixinHash.String(), + MixinIndex: req.Output.OutputIndex, + Index: 0, + Operation: OperationTypeSignInput, + Public: post.Public, + Extra: post.MessageHex(), + CreatedAt: req.CreatedAt, + } + } + err = node.store.ConfirmSystemCallsWithRequest(ctx, req, calls, post, session, outputs) + if err != nil { + panic(err) + } + return nil, "" } func (node *Node) processObserverRequestSign(ctx context.Context, req *store.Request) ([]*mtg.Transaction, string) { @@ -738,6 +780,11 @@ func (node *Node) processObserverCreateDepositCall(ctx context.Context, req *sto logger.Printf("node.getSubSystemCallFromExtra(%v) => %v %v", req, call, err) return node.failRequest(ctx, req, "") } + err = node.VerifySubSystemCallEnvelope(tx, userAddress) + logger.Printf("node.VerifySubSystemCallEnvelope(%s) => %v", call.RequestId, err) + if err != nil { + return node.failRequest(ctx, req, "") + } err = node.VerifySubSystemCall(ctx, tx, solana.MustPublicKeyFromBase58(node.conf.SolanaDepositEntry), userAddress) logger.Printf("node.VerifySubSystemCall(%s %s) => %v", node.conf.SolanaDepositEntry, userAddress, err) if err != nil { @@ -948,33 +995,35 @@ func (node *Node) refundAndFailRequest(ctx context.Context, req *store.Request, return txs, compaction } -func (node *Node) failSystemCall(ctx context.Context, req *store.Request, call *store.SystemCall) ([]*mtg.Transaction, string) { +// Handle the following situations for a failed system call: +// +// - [fail:Main]: fail a Main call that has no pending Prepare; optional +// storage contains its cleanup call. +// - [fail:Prepare]: fail Prepare and its Main call; storage contains the +// Solana-asset refund call exactly when such a refund exists. +// - [success:Prepare, fail:Main]: confirm Prepare and fail Main; optional +// storage contains Main's cleanup call. +// - [fail:Deposit/PostProcess]: fail one terminal call; +// storage must be empty. +func (node *Node) failSystemCall(ctx context.Context, req *store.Request, call *store.SystemCall, storage []byte, calls []*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, "") - } - - flag, extra := extra[0], extra[1:] - - storage := extra[16:] - if flag == FlagConfirmCallSuccess { - storage = nil + // validateConfirmCall mutates call to the target state before any database + // write happens. Re-read the persisted row here when rebuilding cleanup + // expectations, so a first failure still sees Pending references and a + // compaction replay sees the already-failed references. + referenceCall, err := node.store.ReadSystemCallByRequestId(ctx, call.RequestId, 0) + logger.Printf("store.ReadSystemCallByRequestId(%s) => %v %v", call.RequestId, referenceCall, err) + if err != nil || referenceCall == nil { + panic(fmt.Errorf("store.ReadSystemCallByRequestId(%s) => %v %v", call.RequestId, referenceCall, err)) } var outputs []*store.UserOutput var mix *bot.MixAddress switch call.Type { case store.CallTypeMain, store.CallTypePrepare: - main := call + main := referenceCall if call.Type == store.CallTypePrepare { c, err := node.store.ReadSystemCallByRequestId(ctx, call.Superior, 0) logger.Printf("store.ReadSystemCallByRequestId(%s) => %v %v", call.Superior, c, err) @@ -983,6 +1032,14 @@ func (node *Node) failSystemCall(ctx context.Context, req *store.Request, call * } main = c + // A failed Prepare needs a Solana cleanup whenever its associated withdrawals + // produced refundable Solana transfers. The observer may omit storage only + // when the deterministic refund builder produces no transaction. + if len(storage) == 0 && len(node.buildRefundWithdrawalTransfers(ctx, referenceCall, main)) > 0 { + logger.Printf("missing refund storage for failed Prepare: %s", call.RequestId) + return node.failRequest(ctx, req, "") + } + user, err := node.store.ReadUser(ctx, main.UserIdFromPublicPath()) if err != nil { panic(err) @@ -993,7 +1050,7 @@ func (node *Node) failSystemCall(ctx context.Context, req *store.Request, call * } } - os, _, err := node.GetSystemCallReferenceOutputs(ctx, main.UserIdFromPublicPath(), main.RequestHash, systemCallReferenceOutputState(call)) + os, _, err := node.GetSystemCallReferenceOutputs(ctx, main.UserIdFromPublicPath(), main.RequestHash, systemCallReferenceOutputStateValue(referenceCall.State)) if err != nil { panic(err) } @@ -1001,7 +1058,7 @@ func (node *Node) failSystemCall(ctx context.Context, req *store.Request, call * } var session *store.Session - post, err := node.getPostProcessCall(ctx, req, FlagConfirmCallFail, call, storage) + post, err := node.getPostProcessCall(ctx, req, FlagConfirmCallFail, referenceCall, storage) logger.Printf("node.getPostProcessCall(%v %v) => %v %v", req, call, post, err) if err != nil { return node.failRequest(ctx, req, "") @@ -1038,22 +1095,43 @@ func (node *Node) failSystemCall(ctx context.Context, req *store.Request, call * } } - err = node.store.FailSystemCallWithRequest(ctx, req, call, post, session, outputs, txs, compaction) + var confirmed []*store.SystemCall + if len(calls) > 1 { + confirmed = calls[:len(calls)-1] + } + err = node.store.FailSystemCallWithRequest(ctx, req, call, confirmed, post, session, outputs, txs, compaction) if err != nil { panic(err) } return txs, compaction } -func (node *Node) checkConfirmCallSignature(ctx context.Context, signature string) (*store.SystemCall, *rpc.GetTransactionResult, error) { +// validateConfirmCall verifies the observer's status against the stored call and +// the exact Solana transaction. Once the proof is accepted, it updates the +// returned call to the state that should be persisted. +func (node *Node) validateConfirmCall(ctx context.Context, req *store.Request, status byte, callId, signature string) (*store.SystemCall, *rpc.GetTransactionResult, error) { + call, err := node.store.ReadSystemCallByRequestId(ctx, callId, 0) + if err != nil || call == nil { + panic(fmt.Errorf("store.ReadSystemCallByRequestId(%s) => %v %v", callId, call, err)) + } + if !validConfirmCallState(req, status, call) { + return nil, nil, fmt.Errorf("invalid confirm call state: %s %d", callId, call.State) + } + if !validFailedConfirmCallHash(call, signature) { + return nil, nil, fmt.Errorf("invalid failed confirm call hash: %s %s", callId, signature) + } + transaction, err := node.RPCGetTransaction(ctx, signature) - if err != nil || transaction == nil { - panic(fmt.Errorf("checkConfirmCallSignature(%s) => %v", signature, err)) + if err != nil || transaction == nil || transaction.Meta == nil { + panic(fmt.Errorf("RPCGetTransaction(%s) => %v %v", signature, transaction, err)) } tx, err := transaction.Transaction.GetTransaction() if err != nil { panic(err) } + if len(tx.Signatures) == 0 || tx.Signatures[0].String() != signature { + panic(fmt.Errorf("confirm call transaction signature mismatch: %s", signature)) + } msg, err := tx.Message.MarshalBinary() if err != nil { panic(err) @@ -1078,18 +1156,28 @@ func (node *Node) checkConfirmCallSignature(ctx context.Context, signature strin hash = test } } + if hash != call.MessageHash { + panic(fmt.Errorf("confirm call message mismatch: %s %s %s", callId, call.MessageHash, hash)) + } - call, err := node.store.ReadSystemCallByMessage(ctx, hash) - if err != nil { - panic(fmt.Errorf("store.ReadSystemCallByMessage(%x) => %v", msg, err)) + failed := transaction.Meta.Err != nil + if common.CheckTestEnvironment(ctx) && isTestFailedSystemConfirmCall(signature) { + failed = true } - if call == nil || call.State != common.RequestStatePending { - return nil, nil, fmt.Errorf("checkConfirmCallSignature(%s) => invalid call %v", signature, call) + if status == FlagConfirmCallSuccess && failed { + return nil, nil, fmt.Errorf("expected successful solana tx: %s", signature) } - if transaction.Meta.Err != nil { - return call, nil, fmt.Errorf("failed solana tx: %s %s", signature, formatTransactionError(transaction.Meta.Err)) + if status == FlagConfirmCallFail && !failed { + return nil, nil, fmt.Errorf("expected failed solana tx: %s", signature) + } + switch status { + case FlagConfirmCallSuccess: + call.State = common.RequestStateDone + case FlagConfirmCallFail: + call.State = common.RequestStateFailed + default: + panic(status) } - call.State = common.RequestStateDone call.Hash = sql.NullString{Valid: true, String: signature} return call, transaction, nil } @@ -1201,3 +1289,17 @@ func checkUser(ctx context.Context, req *store.Request, mix *bot.MixAddress) boo return mix.Threshold == byte(req.Output.SendersThreshold) && bot.HashMembers(mix.Members()) == bot.HashMembers(senders) } + +func validConfirmCallState(req *store.Request, status byte, call *store.SystemCall) bool { + if call.State == common.RequestStatePending { + return true + } + // A fresh confirmation can only consume a pending call. Failed calls are + // accepted only while replaying a compacted action; processAction admits that + // path after it has found the same action result with a non-empty compaction. + return status == FlagConfirmCallFail && call.State == common.RequestStateFailed && req != nil && req.Restored +} + +func validFailedConfirmCallHash(call *store.SystemCall, signature string) bool { + return call.State != common.RequestStateFailed || !call.Hash.Valid || call.Hash.String == signature +} diff --git a/solana/observer.go b/solana/observer.go index 26e9ebd..9936a7f 100644 --- a/solana/observer.go +++ b/solana/observer.go @@ -768,13 +768,17 @@ func (node *Node) handleSignedCallSequence(ctx context.Context, wg *sync.WaitGro call := calls[0] tx, meta, err := node.handleSignedCall(ctx, call) if err != nil { - err = node.processFailedCall(ctx, call, err) + err = node.processFailedCall(ctx, call, tx.Signatures[0], nil, err) if err != nil { panic(err) } return } - err = node.processSuccessedCall(ctx, call, tx, meta, []solana.Signature{tx.Signatures[0]}) + err = node.processSuccessedCall(ctx, call, tx, meta, []confirmCallRecord{{ + Status: FlagConfirmCallSuccess, + CallId: call.RequestId, + Signature: tx.Signatures[0], + }}) if err != nil { panic(err) } @@ -784,7 +788,7 @@ func (node *Node) handleSignedCallSequence(ctx context.Context, wg *sync.WaitGro var sigs []solana.Signature preTx, _, err := node.handleSignedCall(ctx, calls[0]) if err != nil { - err = node.processFailedCall(ctx, calls[0], err) + err = node.processFailedCall(ctx, calls[0], preTx.Signatures[0], nil, err) if err != nil { panic(err) } @@ -799,7 +803,11 @@ func (node *Node) handleSignedCallSequence(ctx context.Context, wg *sync.WaitGro tx, meta, err := node.handleSignedCall(ctx, calls[1]) if err != nil { - err = node.processFailedCall(ctx, calls[1], err) + err = node.processFailedCall(ctx, calls[1], tx.Signatures[0], []confirmCallRecord{{ + Status: FlagConfirmCallSuccess, + CallId: calls[0].RequestId, + Signature: preTx.Signatures[0], + }}, err) if err != nil { panic(err) } @@ -807,7 +815,10 @@ func (node *Node) handleSignedCallSequence(ctx context.Context, wg *sync.WaitGro } sigs = append(sigs, tx.Signatures[0]) - err = node.processSuccessedCall(ctx, calls[1], tx, meta, sigs) + err = node.processSuccessedCall(ctx, calls[1], tx, meta, []confirmCallRecord{ + {Status: FlagConfirmCallSuccess, CallId: calls[0].RequestId, Signature: sigs[0]}, + {Status: FlagConfirmCallSuccess, CallId: calls[1].RequestId, Signature: sigs[1]}, + }) if err != nil { panic(err) } @@ -849,7 +860,7 @@ func (node *Node) handleSignedCall(ctx context.Context, call *store.SystemCall) rpcTx, err := node.SendTransactionUtilConfirm(ctx, tx, call) logger.Printf("node.SendTransactionUtilConfirm(%s, %x) => %v %v", call.RequestId, rb, rpcTx, err) if err != nil || rpcTx == nil { - return nil, nil, fmt.Errorf("node.SendTransactionUtilConfirm(%s) => %v %v", call.RequestId, rpcTx, err) + return tx, nil, fmt.Errorf("node.SendTransactionUtilConfirm(%s) => %v %v", call.RequestId, rpcTx, err) } txx, err := rpcTx.Transaction.GetTransaction() if err != nil { @@ -859,14 +870,10 @@ func (node *Node) handleSignedCall(ctx context.Context, call *store.SystemCall) } // deposited assets to run system call and new assets received in system call are all handled here -func (node *Node) processSuccessedCall(ctx context.Context, call *store.SystemCall, txx *solana.Transaction, meta *rpc.TransactionMeta, hashes []solana.Signature) error { +func (node *Node) processSuccessedCall(ctx context.Context, call *store.SystemCall, txx *solana.Transaction, meta *rpc.TransactionMeta, records []confirmCallRecord) error { logger.Printf("node.processSuccessedCall(%s)", call.RequestId) id := common.UniqueId(call.RequestId, "confirm-success") - extra := []byte{FlagConfirmCallSuccess} - extra = append(extra, byte(len(hashes))) - for _, hash := range hashes { - extra = append(extra, hash[:]...) - } + extra := encodeConfirmCallRecords(records) if call.Type == store.CallTypeMain && !call.SkipPostProcess { cid := common.UniqueId(id, "post-process") @@ -898,13 +905,17 @@ func (node *Node) processSuccessedCall(ctx context.Context, call *store.SystemCa } } -func (node *Node) processFailedCall(ctx context.Context, call *store.SystemCall, callError error) error { +func (node *Node) processFailedCall(ctx context.Context, call *store.SystemCall, signature solana.Signature, successful []confirmCallRecord, callError error) error { logger.Printf("node.processFailedCall(%s)", call.RequestId) id := common.UniqueId(call.RequestId, "confirm-fail") cid := common.UniqueId(id, "post-process") nonce := node.ReadSpareNonceAccountWithCall(ctx, cid) - extra := []byte{FlagConfirmCallFail} - extra = append(extra, uuid.Must(uuid.FromString(call.RequestId)).Bytes()...) + records := append(successful, confirmCallRecord{ + Status: FlagConfirmCallFail, + CallId: call.RequestId, + Signature: signature, + }) + extra := encodeConfirmCallRecords(records) var tx *solana.Transaction switch call.Type { diff --git a/solana/request.go b/solana/request.go index 0b3a4f2..d21e8c8 100644 --- a/solana/request.go +++ b/solana/request.go @@ -12,6 +12,7 @@ import ( "github.com/MixinNetwork/mixin/logger" "github.com/MixinNetwork/safe/common" "github.com/MixinNetwork/safe/mtg" + "github.com/gagliardetto/solana-go" "github.com/gofrs/uuid/v5" "github.com/shopspring/decimal" ) @@ -44,6 +45,63 @@ const ( OperationTypeSignOutput = 22 ) +const confirmCallRecordSize = 1 + uuid.Size + solana.SignatureLength + +type confirmCallRecord struct { + Status byte + CallId string + Signature solana.Signature +} + +func encodeConfirmCallRecords(records []confirmCallRecord) []byte { + if len(records) == 0 || len(records) > 2 { + panic(fmt.Errorf("invalid confirm call record count: %d", len(records))) + } + extra := []byte{byte(len(records))} + for _, record := range records { + if record.Status != FlagConfirmCallFail && record.Status != FlagConfirmCallSuccess { + panic(fmt.Errorf("invalid confirm call status: %d", record.Status)) + } + extra = append(extra, record.Status) + extra = append(extra, uuid.Must(uuid.FromString(record.CallId)).Bytes()...) + extra = append(extra, record.Signature[:]...) + } + return extra +} + +func decodeConfirmCallRecords(extra []byte) ([]confirmCallRecord, []byte, error) { + if len(extra) < 1 { + return nil, nil, fmt.Errorf("missing confirm call record count") + } + n := int(extra[0]) + if n == 0 || n > 2 { + return nil, nil, fmt.Errorf("invalid confirm call record count: %d", n) + } + extra = extra[1:] + if len(extra) < n*confirmCallRecordSize { + return nil, nil, fmt.Errorf("invalid confirm call record payload length: %d", len(extra)) + } + records := make([]confirmCallRecord, 0, n) + for range n { + status := extra[0] + if status != FlagConfirmCallFail && status != FlagConfirmCallSuccess { + return nil, nil, fmt.Errorf("invalid confirm call status: %d", status) + } + callId, err := uuid.FromBytes(extra[1 : 1+uuid.Size]) + if err != nil { + return nil, nil, fmt.Errorf("invalid confirm call id: %w", err) + } + signature := solana.SignatureFromBytes(extra[1+uuid.Size : confirmCallRecordSize]) + records = append(records, confirmCallRecord{ + Status: status, + CallId: callId.String(), + Signature: signature, + }) + extra = extra[confirmCallRecordSize:] + } + return records, extra, nil +} + func decodeRequest(out *mtg.Action, extra []byte, role uint8) (*store.Request, error) { h, err := crypto.HashFromString(out.TransactionHash) if err != nil { diff --git a/solana/solana.go b/solana/solana.go index 0533fd5..b526948 100644 --- a/solana/solana.go +++ b/solana/solana.go @@ -499,7 +499,7 @@ func (node *Node) CreatePrepareTransaction(ctx context.Context, call *store.Syst } func (node *Node) CreatePostProcessTransaction(ctx context.Context, call *store.SystemCall, nonce *store.NonceAccount, tx *solana.Transaction, meta *rpc.TransactionMeta) *solana.Transaction { - os, _, err := node.GetSystemCallReferenceOutputs(ctx, call.UserIdFromPublicPath(), call.RequestHash, common.RequestStatePending) + os, _, err := node.GetSystemCallReferenceOutputs(ctx, call.UserIdFromPublicPath(), call.RequestHash, systemCallReferenceOutputStateValue(call.State)) if err != nil { panic(fmt.Errorf("node.GetSystemCallReferenceTxs(%s) => %v", call.RequestId, err)) } @@ -611,6 +611,19 @@ func (node *Node) CreatePostProcessTransaction(ctx context.Context, call *store. } func (node *Node) CreateRefundWithdrawalTransaction(ctx context.Context, prepare, call *store.SystemCall, nonce *store.NonceAccount) *solana.Transaction { + transfers := node.buildRefundWithdrawalTransfers(ctx, prepare, call) + if len(transfers) == 0 { + return nil + } + + tx, err := node.solana.TransferOrMintTokens(ctx, node.SolanaPayer(), node.getMTGAddress(ctx), nonce.Account(), transfers, prepare.RequestId) + if err != nil { + panic(err) + } + return tx +} + +func (node *Node) buildRefundWithdrawalTransfers(ctx context.Context, prepare, call *store.SystemCall) []*solanaApp.TokenTransfer { withdrawals := call.GetWithdrawalIds() // the failure of prepare call means that only Solana assets are withdrawn // the mint of external assets is failed so no need to burn @@ -618,7 +631,7 @@ func (node *Node) CreateRefundWithdrawalTransaction(ctx context.Context, prepare return nil } - os, _, err := node.GetSystemCallReferenceOutputs(ctx, call.UserIdFromPublicPath(), call.RequestHash, common.RequestStatePending) + os, _, err := node.GetSystemCallReferenceOutputs(ctx, call.UserIdFromPublicPath(), call.RequestHash, systemCallReferenceOutputStateValue(prepare.State)) if err != nil { panic(fmt.Errorf("node.GetSystemCallReferenceTxs(%s) => %v", call.RequestId, err)) } @@ -666,11 +679,7 @@ func (node *Node) CreateRefundWithdrawalTransaction(ctx context.Context, prepare } node.sortSolanaTransfers(transfers) - tx, err := node.solana.TransferOrMintTokens(ctx, node.SolanaPayer(), node.getMTGAddress(ctx), nonce.Account(), transfers, prepare.RequestId) - if err != nil { - panic(err) - } - return tx + return transfers } type BalanceChange struct { @@ -930,6 +939,46 @@ func (node *Node) VerifySubSystemCall(ctx context.Context, tx *solana.Transactio return nil } +func (node *Node) VerifySubSystemCallEnvelope(tx *solana.Transaction, authority solana.PublicKey) error { + payer := node.SolanaPayer() + if len(tx.Message.AccountKeys) == 0 || tx.Message.AccountKeys[0] != payer { + return fmt.Errorf("invalid subsystem fee payer") + } + expectedSigners := solana.PublicKeySlice{payer} + if authority != payer { + expectedSigners = append(expectedSigners, authority) + } + if !slices.Equal(tx.Message.Signers(), expectedSigners) { + return fmt.Errorf("invalid subsystem signers: %v", tx.Message.Signers()) + } + if len(tx.Message.Instructions) == 0 { + return fmt.Errorf("subsystem transaction has no nonce advance") + } + ix := tx.Message.Instructions[0] + program, err := tx.Message.Program(ix.ProgramIDIndex) + if err != nil { + return err + } + if program != system.ProgramID { + return fmt.Errorf("invalid nonce advance program: %s", program) + } + accounts, err := ix.ResolveInstructionAccounts(&tx.Message) + if err != nil { + return err + } + advance, err := solanaApp.DecodeNonceAdvance(accounts, ix.Data) + if err != nil { + return err + } + if advance.GetNonceAuthorityAccount().PublicKey != payer { + return fmt.Errorf("invalid nonce authority: %s", advance.GetNonceAuthorityAccount().PublicKey) + } + if advance.GetSysVarRecentBlockHashesPubkeyAccount().PublicKey != solana.SysVarRecentBlockHashesPubkey { + return fmt.Errorf("invalid nonce recent blockhashes sysvar") + } + return nil +} + func (node *Node) parseSolanaBlockBalanceChanges(ctx context.Context, transfers []*solanaApp.Transfer) (map[string]*big.Int, error) { mtgAddress := node.getMTGAddress(ctx).String() diff --git a/solana/system_call.go b/solana/system_call.go index f157875..8e00b64 100644 --- a/solana/system_call.go +++ b/solana/system_call.go @@ -1,6 +1,7 @@ package solana import ( + "bytes" "context" "database/sql" "encoding/base64" @@ -31,10 +32,10 @@ type ReferencedTxAsset struct { Fee bool } -func systemCallReferenceOutputState(call *store.SystemCall) byte { +func systemCallReferenceOutputStateValue(state int64) byte { // Failed system calls may be replayed after compaction; the first attempt has // already moved their references from pending to done. - if call.State == common.RequestStateFailed { + if state == common.RequestStateFailed { return common.RequestStateDone } return common.RequestStatePending @@ -332,13 +333,22 @@ func (node *Node) getPostProcessCall(ctx context.Context, req *store.Request, fl return nil, fmt.Errorf("store.ReadUser(%s) => nil", main.UserIdFromPublicPath()) } mtgDeposit := solana.MustPublicKeyFromBase58(node.conf.SolanaDepositEntry) + authority := node.getUserSolanaPublicKeyFromCall(ctx, post) + if call.Type == store.CallTypePrepare { + authority = node.getMTGAddress(ctx) + } + err = node.VerifySubSystemCallEnvelope(tx, authority) + logger.Printf("node.VerifySubSystemCallEnvelope(%s) => %v", post.RequestId, err) + if err != nil { + return nil, err + } err = node.VerifySubSystemCall(ctx, tx, mtgDeposit, solana.MustPublicKeyFromBase58(user.ChainAddress)) logger.Printf("node.VerifySubSystemCall(%s) => %v", user.ChainAddress, err) if err != nil { return nil, err } - os, _, err := node.GetSystemCallReferenceOutputs(ctx, main.UserIdFromPublicPath(), main.RequestHash, systemCallReferenceOutputState(main)) + os, _, err := node.GetSystemCallReferenceOutputs(ctx, main.UserIdFromPublicPath(), main.RequestHash, systemCallReferenceOutputStateValue(main.State)) if err != nil { panic(fmt.Errorf("node.GetSystemCallReferenceTxs(%s) => %v", main.RequestId, err)) } @@ -352,11 +362,94 @@ func (node *Node) getPostProcessCall(ctx context.Context, req *store.Request, fl return nil, err } case FlagConfirmCallFail: - // TODO compare with user outputs + err = node.verifyFailedPostProcessCall(ctx, call, main, post, tx) + logger.Printf("node.verifyFailedPostProcessCall(%s) => %v", call.RequestId, err) + if err != nil { + return nil, err + } } return post, nil } +func (node *Node) verifyFailedPostProcessCall(ctx context.Context, call, main, post *store.SystemCall, actual *solana.Transaction) error { + nonce := &store.NonceAccount{ + Address: post.NonceAccount, + Hash: actual.Message.RecentBlockhash.String(), + } + var expected *solana.Transaction + switch call.Type { + case store.CallTypeMain: + expected = node.CreatePostProcessTransaction(ctx, main, nonce, nil, nil) + case store.CallTypePrepare: + expected = node.CreateRefundWithdrawalTransaction(ctx, call, main, nonce) + default: + return fmt.Errorf("invalid failed post-process superior type: %s", call.Type) + } + if expected == nil { + return fmt.Errorf("unexpected failed post-process transaction") + } + return compareCleanupTransactions(actual, expected) +} + +func compareCleanupTransactions(actual, expected *solana.Transaction) error { + if len(actual.Message.AccountKeys) == 0 || len(expected.Message.AccountKeys) == 0 { + return fmt.Errorf("cleanup transaction has no fee payer") + } + if actual.Message.AccountKeys[0] != expected.Message.AccountKeys[0] { + return fmt.Errorf("invalid cleanup fee payer: %s", actual.Message.AccountKeys[0]) + } + if !slices.Equal(actual.Message.Signers(), expected.Message.Signers()) { + return fmt.Errorf("invalid cleanup signers: %v", actual.Message.Signers()) + } + if len(actual.Message.Instructions) != len(expected.Message.Instructions) { + return fmt.Errorf("invalid cleanup instruction count: %d %d", len(actual.Message.Instructions), len(expected.Message.Instructions)) + } + + for i, actualIx := range actual.Message.Instructions { + expectedIx := expected.Message.Instructions[i] + actualProgram, err := actual.Message.Program(actualIx.ProgramIDIndex) + if err != nil { + return fmt.Errorf("resolve cleanup program %d: %w", i, err) + } + expectedProgram, err := expected.Message.Program(expectedIx.ProgramIDIndex) + if err != nil { + return fmt.Errorf("resolve expected cleanup program %d: %w", i, err) + } + if actualProgram != expectedProgram { + return fmt.Errorf("invalid cleanup program %d: %s", i, actualProgram) + } + + actualAccounts, err := actualIx.ResolveInstructionAccounts(&actual.Message) + if err != nil { + return fmt.Errorf("resolve cleanup accounts %d: %w", i, err) + } + expectedAccounts, err := expectedIx.ResolveInstructionAccounts(&expected.Message) + if err != nil { + return fmt.Errorf("resolve expected cleanup accounts %d: %w", i, err) + } + if len(actualAccounts) != len(expectedAccounts) { + return fmt.Errorf("invalid cleanup account count %d: %d %d", i, len(actualAccounts), len(expectedAccounts)) + } + for j := range actualAccounts { + a, e := actualAccounts[j], expectedAccounts[j] + if a.PublicKey != e.PublicKey || a.IsSigner != e.IsSigner || a.IsWritable != e.IsWritable { + return fmt.Errorf("invalid cleanup account %d:%d: %s", i, j, a.PublicKey) + } + } + + if i == 1 && actualProgram == solana.ComputeBudget { + if len(actualIx.Data) != 9 || len(expectedIx.Data) != 9 || actualIx.Data[0] != expectedIx.Data[0] { + return fmt.Errorf("invalid compute budget instruction") + } + continue + } + if !bytes.Equal(actualIx.Data, expectedIx.Data) { + return fmt.Errorf("invalid cleanup instruction data: %d", i) + } + } + return nil +} + func (node *Node) getSubSystemCallFromExtra(ctx context.Context, req *store.Request, data []byte) (*store.SystemCall, *solana.Transaction, error) { if len(data) < 16 { return nil, nil, nil diff --git a/solana/test.go b/solana/test.go index 17afe0c..10a02d4 100644 --- a/solana/test.go +++ b/solana/test.go @@ -187,6 +187,9 @@ func (n *testNetwork) msgChannel(id party.ID) chan []byte { } func getTestSystemConfirmCallMessage(signature string) string { + if signature == "sKxPceKjZb4PiywqwMP3Wyf9YoPqdgZZ5MLNxkpRv7qgTXVxK8UQRGkjMT47qJht5muuUPpqynkrM5C6BcYSELg" { + return "5633425be1fd091410daaae53cfc9ced98b27dd58f599cb6c7f41d189d6e8250" + } if signature == "2tPHv7kbUeHRWHgVKKddQqXnjDhuX84kTyCvRy1BmCM4m4Fkq4vJmNAz8A7fXqckrSNRTAKuPmAPWnzr5T7eCChb" { return "5633425be1fd091410daaae53cfc9ced98b27dd58f599cb6c7f41d189d6e8250" } @@ -199,6 +202,10 @@ func getTestSystemConfirmCallMessage(signature string) string { return "" } +func isTestFailedSystemConfirmCall(signature string) bool { + return signature == "sKxPceKjZb4PiywqwMP3Wyf9YoPqdgZZ5MLNxkpRv7qgTXVxK8UQRGkjMT47qJht5muuUPpqynkrM5C6BcYSELg" +} + var ( testFROSTKeys1 = map[party.ID]string{ "member-id-0": "fb17b60698d36d45bc624c8e210b4c845233c99a7ae312a27e883a8aa8444b9b;0001000b6d656d6265722d69642d3000020020fe4584dcd16c51736b64e329ef2fd51b4f1d98ee833cdc96ace16398fd243f080020fb17b60698d36d45bc624c8e210b4c845233c99a7ae312a27e883a8aa8444b9b000000b9a46b6d656d6265722d69642d305820cd5b764c011927f356938f5ebdd5f825c6f07e72f07a67ab7da1b8ec291de8d56b6d656d6265722d69642d315820d059874222f3d7a00a98da49fe388141717541f7d6ba7b0baf01af63c03510796b6d656d6265722d69642d325820e8b3ba906961e5e2ab66405d7105c2b2c19695a34ae77e229dabc2ef59ec71386b6d656d6265722d69642d33582090115b147e3977a8d44f58d40cdece998bd4b204b02ad91da9756cfff9969298", diff --git a/store/call.go b/store/call.go index 84d4336..fb2e7cc 100644 --- a/store/call.go +++ b/store/call.go @@ -326,7 +326,13 @@ func (s *SQLite3Store) ConfirmBurnRelatedSystemCallWithRequest(ctx context.Conte return tx.Commit() } -func (s *SQLite3Store) FailSystemCallWithRequest(ctx context.Context, req *Request, call, sub *SystemCall, session *Session, os []*UserOutput, txs []*mtg.Transaction, compaction string) error { +func (s *SQLite3Store) FailSystemCallWithRequest(ctx context.Context, req *Request, call *SystemCall, confirmed []*SystemCall, sub *SystemCall, session *Session, os []*UserOutput, txs []*mtg.Transaction, compaction string) error { + // The store layer only accepts the already-validated transition value. It + // should not infer failure or invent the Solana signature on its own. + if call.State != common.RequestStateFailed || !call.Hash.Valid { + return fmt.Errorf("invalid failed system call transition: %v", call) + } + s.mutex.Lock() defer s.mutex.Unlock() @@ -336,21 +342,38 @@ func (s *SQLite3Store) FailSystemCallWithRequest(ctx context.Context, req *Reque } defer common.Rollback(tx) - query := "UPDATE system_calls SET state=?, updated_at=? WHERE id=? AND state=?" - _, err = tx.ExecContext(ctx, query, common.RequestStateFailed, req.CreatedAt, call.RequestId, common.RequestStatePending) + // First attempt: move the failed call from Pending to Failed and persist the + // failed Solana transaction signature. During compaction replay the call may + // already be Failed, so this update is allowed to affect zero rows. + query := "UPDATE system_calls SET state=?, hash=?, updated_at=? WHERE id=? AND state=?" + _, err = tx.ExecContext(ctx, query, call.State, call.Hash, req.CreatedAt, call.RequestId, common.RequestStatePending) if err != nil { return fmt.Errorf("SQLite3Store UPDATE system_calls %v", err) } + // Compatibility path for rows that were failed before hash persistence was + // added. Existing hashes are never overwritten; validateConfirmCall has + // already required a replayed failed call with a hash to match this signature. + query = "UPDATE system_calls SET hash=?, updated_at=? WHERE id=? AND state=? AND hash IS NULL" + _, err = tx.ExecContext(ctx, query, call.Hash, req.CreatedAt, call.RequestId, common.RequestStateFailed) + if err != nil { + return fmt.Errorf("SQLite3Store UPDATE failed system_calls hash %v", err) + } - // if a main system call failed, its prepare call must success - if call.Type == CallTypeMain { - query = "UPDATE system_calls SET state=?, updated_at=? WHERE superior_id=? AND call_type=? AND state=?" - _, err = tx.ExecContext(ctx, query, common.RequestStateDone, req.CreatedAt, call.RequestId, CallTypePrepare, common.RequestStatePending) + // Persist successful records that precede the failed record in the same + // ConfirmCall extra. This keeps [success:Prepare, fail:Main] explicit instead + // of deriving Prepare success from the Main failure in the store layer. + for _, confirmedCall := range confirmed { + query = "UPDATE system_calls SET state=?, hash=?, updated_at=? WHERE id=? AND state=?" + err = s.execOne(ctx, tx, query, confirmedCall.State, confirmedCall.Hash, req.CreatedAt, confirmedCall.RequestId, common.RequestStatePending) if err != nil { - return fmt.Errorf("SQLite3Store UPDATE system_calls %v", err) + return fmt.Errorf("SQLite3Store UPDATE confirmed system_calls %v", err) } } - // if a prepare system call failed, fail its main call + + // If Prepare failed, Main was not executed but must still leave the workflow + // terminal. Its refund traces point to any Mixin refund transactions emitted + // for external assets; no Solana hash is written because there is no Main + // Solana transaction in this path. if call.Type == CallTypePrepare { var ids []string for _, tx := range txs { @@ -363,6 +386,9 @@ func (s *SQLite3Store) FailSystemCallWithRequest(ctx context.Context, req *Reque } } + // A failed Main/Prepare can require a cleanup PostProcess call and a matching + // SignInput session. Both writes are existence-checked so replaying the same + // compacted request does not duplicate signing work. if sub != nil { existed, err := s.checkExistence(ctx, tx, "SELECT id FROM system_calls WHERE id=?", sub.RequestId) if err != nil { @@ -395,6 +421,9 @@ func (s *SQLite3Store) FailSystemCallWithRequest(ctx context.Context, req *Reque } } + // A non-empty compaction means the MTG emitted refund transactions that must + // be replayed deterministically later, so the request remains Failed. Without + // compaction, all required local state and sessions have been persisted. if compaction == "" { err = s.finishRequest(ctx, tx, req, txs, compaction) } else { @@ -404,6 +433,8 @@ func (s *SQLite3Store) FailSystemCallWithRequest(ctx context.Context, req *Reque return err } + // Only terminal successful handling notifies emitted Mixin transactions here. + // Compacted failures are replayed from action_results instead. if compaction == "" && len(txs) > 0 { err = s.writeNotifications(ctx, tx, txs) if err != nil { From 22cea16b7341d659d12ab5bbbcfd821990942e2f Mon Sep 17 00:00:00 2001 From: hundredark Date: Tue, 25 Aug 2026 23:20:05 +0800 Subject: [PATCH 16/18] fix order of call reference assets --- solana/request.go | 3 ++- solana/system_call.go | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/solana/request.go b/solana/request.go index d21e8c8..70805e3 100644 --- a/solana/request.go +++ b/solana/request.go @@ -205,7 +205,8 @@ func (node *Node) buildRefundTxs(ctx context.Context, req *store.Request, id str trace := common.UniqueId(id, memo) t := node.buildTransaction(ctx, req.Output, node.conf.AppId, assetId, receivers, threshold, as.Amount.String(), []byte(memo), trace) if t == nil { - // TODO then all other assets ignored? + // Reference assets are sorted by AssetId during aggregation, so the + // lexicographically smallest insufficient asset is selected for compaction. return nil, assetId } txs = append(txs, t) diff --git a/solana/system_call.go b/solana/system_call.go index 8e00b64..acfc9fa 100644 --- a/solana/system_call.go +++ b/solana/system_call.go @@ -198,6 +198,11 @@ func aggregateSystemCallReferenceAssets(os []*store.UserOutput) []*ReferencedTxA } assets = append(assets, a) } + // Callers persist the ordered transactions and select the first asset with + // insufficient balance for compaction, so map iteration order is unsafe here. + slices.SortFunc(assets, func(a, b *ReferencedTxAsset) int { + return strings.Compare(a.AssetId, b.AssetId) + }) return assets } From aa81e75a6a2621042771d4a6619cba2aed4a1c58 Mon Sep 17 00:00:00 2001 From: hundredark Date: Tue, 25 Aug 2026 23:57:13 +0800 Subject: [PATCH 17/18] slight improve --- solana/computer_test.go | 23 ++++++++++++++--------- solana/mvm.go | 11 ++++------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/solana/computer_test.go b/solana/computer_test.go index 583cad1..60ec9f2 100644 --- a/solana/computer_test.go +++ b/solana/computer_test.go @@ -88,7 +88,7 @@ func TestCompaction(t *testing.T) { oid1, err := uuid.NewV4() require.Nil(err) extra = user.IdBytes() - out1 := testBuildUserRequest(node, oid1.String(), h1.String(), "0.90432841", mtg.StorageAssetId, OperationTypeUserDeposit, extra, nil, nil) + out1 := testBuildUserRequest(node, user.MixAddress, oid1.String(), h1.String(), "0.90432841", mtg.StorageAssetId, OperationTypeUserDeposit, extra, nil, nil) for _, node := range nodes { err = node.store.WriteProperty(ctx, h1.String(), "77770005a99c2e0e2b1da4d648755ef19bd95139acbbe6564cfb06dec7cd34931ca72cdc0001b98ecfc9b5b8c01e1e94a13c05866eb0bc33c7706ed306c9da60a016945eeddd00000000000000000002000000040563e54900071ede9c0d0680d843eb373883ee7383e6f47b7c5720d8e8176139ba0497893aeb6cc3ba624218f1a144ee98de1426a701b45a6dd7211c52b7e87924d751c58cb95f6e7f7a7d1e8017cda22caec31032aeaf239193093e22bcaf9ca742bf42e60ffb9692f26790ca3e0c11ff06937b2127c26791103d0400e125f698a78f6a8b6ab53fdf5e98ea926e41467a78f3b0a3bf68d1a071033091120280dc29c4d46f37151ec76daeb9cb7b49de47caee7196089a45858e68f4e565637b880877597352b9ea3473d8731e19d54459ca3a5f4793c66898510d15ef88a884b6b69fb6a568d073cff9ad3055d6311a97f81f0123e5e8f42284e2928e1099e1f036489aa5290003fffe050000000000033f50830001a3ec1d124a090ed27c235eaabab677e56dde5ba333397a49c6866285d6a8819c5933073ce8b01423b28160348a022059b77a0ee4f4c737fe0f94f055e7e0a6b30003fffe01000000000000002243735f696548465054507975556e444f4e4f507245514d414151414141414141425100010001000065b555876b9158e109739a09bdd1d69026d42f66290fa7c4e8cf95551d7e12a07d0ae2b04f7dceac3bd6893c0cfe9ec49ce8ebf512ba07be1cd859433785ca03") require.Nil(err) @@ -106,7 +106,7 @@ func TestCompaction(t *testing.T) { extra = user.IdBytes() extra = append(extra, uuid.Must(uuid.FromString(cid)).Bytes()...) extra = append(extra, FlagWithPostProcess) - out = testBuildUserRequest(node, cid, hash, "0.0001", mtg.StorageAssetId, OperationTypeSystemCall, extra, refs, nil) + out = testBuildUserRequest(node, user.MixAddress, cid, hash, "0.0001", mtg.StorageAssetId, OperationTypeSystemCall, extra, refs, nil) for _, node := range nodes { testStep(ctx, require, node, out) call, err := node.store.ReadSystemCallByRequestId(ctx, cid, common.RequestStateInitial) @@ -573,14 +573,14 @@ func testUserRequestSystemCall(ctx context.Context, require *require.Assertions, oid1, err := uuid.NewV4() require.Nil(err) extra := user.IdBytes() - out1 := testBuildUserRequest(node, oid1.String(), h1.String(), "0.01", common.SafeLitecoinChainId, OperationTypeUserDeposit, extra, nil, nil) + out1 := testBuildUserRequest(node, user.MixAddress, oid1.String(), h1.String(), "0.01", common.SafeLitecoinChainId, OperationTypeUserDeposit, extra, nil, nil) sequence += 10 h2, _ := crypto.HashFromString("01c43005fd06e0b8f06a0af04faf7530331603e352a11032afd0fd9dbd84e8ee") _, err = testWriteOutputForNodes(ctx, mds, conf.AppId, common.SafeSolanaChainId, h2.String(), "", sequence, decimal.RequireFromString("0.005")) require.Nil(err) oid2, err := uuid.NewV4() require.Nil(err) - out2 := testBuildUserRequest(node, oid2.String(), h2.String(), "0.005", common.SafeSolanaChainId, OperationTypeUserDeposit, extra, nil, nil) + out2 := testBuildUserRequest(node, user.MixAddress, oid2.String(), h2.String(), "0.005", common.SafeSolanaChainId, OperationTypeUserDeposit, extra, nil, nil) for _, node := range nodes { err = node.store.WriteProperty(ctx, h1.String(), "7777000546dbd75ed416c82652554a2fd257df3adb5d8c68726db6631bf1300e7aa36f4100013db24d1350f18126b0f93309913d237fcb870f63fb42cafb3a7d0202aca77bd200000000000000000001000000030f4240000103551f38d1ae2002e06892803b57c838012123911681dc567564e63042c3377690b6636bc74fa394d9122c6af4415d4d151c9671eb82d43c096ea01635bc177f0003fffe01000000000000007854554638593251794e5745334d6a5174593249354d7930304d324d784c546c6d4e6a6774595746695a6d4e6a4d7a4d344f446334664656515245465552563950556b5246556e786d4e446730593255794f53307a596d597a4c5451354d5755744f44677a5a6930334e6d4935596a68694e6a526a4d32453d00010001000052bf7fb6ce4e61527b1cec54d8b705b66c24876d7f53672f9f398c30c20e57136fe4853a40ae7b02a81f038055a09a1e3b0034c62a06960934c38db41701c60b") require.Nil(err) @@ -615,7 +615,7 @@ func testUserRequestSystemCall(ctx context.Context, require *require.Assertions, extra = append(extra, uuid.Must(uuid.FromString(id)).Bytes()...) extra = append(extra, FlagWithPostProcess) extra = append(extra, uuid.Must(uuid.FromString(fee.Id)).Bytes()...) - out := testBuildUserRequest(node, id, hash, "0.0001", mtg.StorageAssetId, OperationTypeSystemCall, extra, refs, &xinFee) + out := testBuildUserRequest(node, user.MixAddress, id, hash, "0.0001", mtg.StorageAssetId, OperationTypeSystemCall, extra, refs, &xinFee) for _, node := range nodes { testStep(ctx, require, node, out) call, err := node.store.ReadSystemCallByRequestId(ctx, id, common.RequestStateInitial) @@ -696,7 +696,7 @@ func testUserRequestAddUsers(ctx context.Context, require *require.Assertions, n for _, node := range nodes { uid := common.UniqueId(id, "user1") mix := bot.NewUUIDMixAddress([]string{uid}, 1) - out := testBuildUserRequest(node, id, "", "0.0001", mtg.StorageAssetId, OperationTypeAddUser, []byte(mix.String()), nil, nil) + out := testBuildUserRequest(node, mix.String(), id, "", "0.0001", mtg.StorageAssetId, OperationTypeAddUser, []byte(mix.String()), nil, nil) testStep(ctx, require, node, out) user1, err := node.store.ReadUserByMixAddress(ctx, mix.String()) require.Nil(err) @@ -714,7 +714,7 @@ func testUserRequestAddUsers(ctx context.Context, require *require.Assertions, n id2 := common.UniqueId(id, "second") uid = common.UniqueId(id, "user2") mix = bot.NewUUIDMixAddress([]string{uid}, 1) - out = testBuildUserRequest(node, id2, "", "0.0001", mtg.StorageAssetId, OperationTypeAddUser, []byte(mix.String()), nil, nil) + out = testBuildUserRequest(node, mix.String(), id2, "", "0.0001", mtg.StorageAssetId, OperationTypeAddUser, []byte(mix.String()), nil, nil) testStep(ctx, require, node, out) user2, err := node.store.ReadUserByMixAddress(ctx, mix.String()) require.Nil(err) @@ -973,11 +973,15 @@ func testObserverRequestSignSystemCall(ctx context.Context, require *require.Ass } } -func testBuildUserRequest(node *Node, id, hash, amt, asset string, action byte, extra []byte, references []crypto.Hash, fee *decimal.Decimal) *mtg.Action { +func testBuildUserRequest(node *Node, mixAddress, id, hash, amt, asset string, action byte, extra []byte, references []crypto.Hash, fee *decimal.Decimal) *mtg.Action { sequence += 10 if hash == "" { hash = crypto.Sha256Hash([]byte(id)).String() } + mix, err := bot.NewMixAddressFromString(mixAddress) + if err != nil { + panic(err) + } memo := []byte{action} memo = append(memo, extra...) @@ -996,7 +1000,8 @@ func testBuildUserRequest(node *Node, id, hash, amt, asset string, action byte, OutputId: id, TransactionHash: hash, AppId: node.conf.AppId, - Senders: []string{string(node.id)}, + SendersThreshold: int64(mix.Threshold), + Senders: mix.Members(), AssetId: asset, Extra: memoStr, Amount: amount, diff --git a/solana/mvm.go b/solana/mvm.go index 7bd50d0..ddc9cb6 100644 --- a/solana/mvm.go +++ b/solana/mvm.go @@ -56,7 +56,7 @@ func (node *Node) processAddUser(ctx context.Context, req *store.Request) ([]*mt if err != nil { return node.failRequest(ctx, req, "") } - if !checkUser(ctx, req, mmix) { + if !checkUser(req, mmix) { return node.failRequest(ctx, req, "") } @@ -113,7 +113,7 @@ func (node *Node) processUserDeposit(ctx context.Context, req *store.Request) ([ if err != nil { panic(err) } - if !checkUser(ctx, req, mix) { + if !checkUser(req, mix) { return node.failRequest(ctx, req, "") } @@ -207,7 +207,7 @@ func (node *Node) processSystemCall(ctx context.Context, req *store.Request) ([] if err != nil { panic(err) } - if !checkUser(ctx, req, mix) { + if !checkUser(req, mix) { return node.failRequest(ctx, req, "") } @@ -1281,10 +1281,7 @@ 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 - } +func checkUser(req *store.Request, mix *bot.MixAddress) bool { senders := append([]string(nil), req.Output.Senders...) return mix.Threshold == byte(req.Output.SendersThreshold) && bot.HashMembers(mix.Members()) == bot.HashMembers(senders) From b31f49bb7946e8d2ed8fafb682ef0ee361563b06 Mon Sep 17 00:00:00 2001 From: hundredark Date: Wed, 26 Aug 2026 00:22:55 +0800 Subject: [PATCH 18/18] slight fix --- solana/mvm.go | 4 ++++ solana/request.go | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/solana/mvm.go b/solana/mvm.go index ddc9cb6..3482888 100644 --- a/solana/mvm.go +++ b/solana/mvm.go @@ -570,6 +570,10 @@ func (node *Node) processConfirmCall(ctx context.Context, req *store.Request) ([ logger.Printf("decodeConfirmCallRecords(%s) => %v", req.Id, err) return node.failRequest(ctx, req, "") } + err = validateConfirmCallStorage(storage) + if err != nil { + panic(fmt.Errorf("validateConfirmCallStorage(%s) => %w", req.Id, err)) + } calls := make([]*store.SystemCall, 0, len(records)) transactions := make([]*rpc.GetTransactionResult, 0, len(records)) diff --git a/solana/request.go b/solana/request.go index 70805e3..d785eb3 100644 --- a/solana/request.go +++ b/solana/request.go @@ -102,6 +102,24 @@ func decodeConfirmCallRecords(extra []byte) ([]confirmCallRecord, []byte, error) return records, extra, nil } +func validateConfirmCallStorage(storage []byte) error { + if len(storage) == 0 { + return nil + } + if len(storage) < uuid.Size { + return fmt.Errorf("invalid confirm call storage length: %d", len(storage)) + } + _, err := uuid.FromBytes(storage[:uuid.Size]) + if err != nil { + return fmt.Errorf("invalid post call id: %v", err) + } + _, err = solana.TransactionFromBytes(storage[uuid.Size:]) + if err != nil { + return fmt.Errorf("invalid post call transaction: %v", err) + } + return nil +} + func decodeRequest(out *mtg.Action, extra []byte, role uint8) (*store.Request, error) { h, err := crypto.HashFromString(out.TransactionHash) if err != nil {