From 83b059e6cf90255c328ee29a71d3b144fe16c6cb Mon Sep 17 00:00:00 2001 From: hundredark Date: Thu, 12 Nov 2026 14:44:45 +0800 Subject: [PATCH 1/2] should check nonce hash in processConfirmNonce --- solana/computer_test.go | 17 +++++++++++++++++ solana/mvm.go | 20 +++++++++++++++++++- solana/observer.go | 2 ++ solana/system_call.go | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/solana/computer_test.go b/solana/computer_test.go index 7de6c74..51d4b56 100644 --- a/solana/computer_test.go +++ b/solana/computer_test.go @@ -666,8 +666,25 @@ func testUserRequestSystemCall(ctx context.Context, require *require.Assertions, id = uuid.Must(uuid.NewV4()).String() extra = []byte{ConfirmFlagNonceAvailable} extra = append(extra, uuid.Must(uuid.FromString(c.RequestId)).Bytes()...) + mainNonce, err := node.store.ReadNonceAccount(ctx, c.NonceAccount) + require.Nil(err) + nonceHash := solana.MustHashFromBase58(mainNonce.Hash) + extra = append(extra, nonceHash[:]...) extra = attachSystemCall(extra, cid, raw) + wrongHash := append([]byte(nil), extra...) + wrongHash[1+uuid.Size] ^= 0xff + for _, invalid := range [][]byte{extra[:1+uuid.Size], extra[:1+uuid.Size+31], wrongHash} { + out := testBuildObserverRequest(node, uuid.Must(uuid.NewV4()).String(), OperationTypeConfirmNonce, invalid) + for _, node := range nodes { + testStep(ctx, require, node, out) + call, err := node.store.ReadSystemCallByRequestId(ctx, c.RequestId, common.RequestStateInitial) + require.Nil(err) + require.NotNil(call) + require.False(call.WithdrawalTraces.Valid) + } + } + out = testBuildObserverRequest(node, id, OperationTypeConfirmNonce, extra) for _, node := range nodes { testStep(ctx, require, node, out) diff --git a/solana/mvm.go b/solana/mvm.go index aae88ec..0319da8 100644 --- a/solana/mvm.go +++ b/solana/mvm.go @@ -333,9 +333,27 @@ func (node *Node) processConfirmNonce(ctx context.Context, req *store.Request) ( } switch flag { case ConfirmFlagNonceAvailable: + // Available: call UUID, nonce hash, then the optional prepare call. + if len(extra) < uuid.Size+32 { + return node.failRequest(ctx, req, "") + } + nonceHash := solana.HashFromBytes(extra[uuid.Size : uuid.Size+32]) + mainTx, err := solana.TransactionFromBase64(call.Raw) + if err != nil { + panic(err) + } + err = node.processTransactionWithAddressLookups(ctx, mainTx) + if err != nil { + panic(err) + } + err = verifySystemCallNonce(mainTx, call.NonceAccount, nonceHash, node.SolanaPayer()) + logger.Printf("verifySystemCallNonce(%s) => %v", call.RequestId, err) + if err != nil { + return node.failRequest(ctx, req, "") + } as := node.GetSystemCallRelatedAsset(ctx, os) var sessions []*store.Session - prepare, tx, err := node.getSubSystemCallFromExtra(ctx, req, extra[16:]) + prepare, tx, err := node.getSubSystemCallFromExtra(ctx, req, extra[uuid.Size+32:]) if err != nil { return node.failRequest(ctx, req, "") } diff --git a/solana/observer.go b/solana/observer.go index 38fc7bc..ba58ecf 100644 --- a/solana/observer.go +++ b/solana/observer.go @@ -644,6 +644,8 @@ func (node *Node) handleUnconfirmedCalls(ctx context.Context) error { } if failureReason == "" { + nonceHash := solana.MustHashFromBase58(nonce.Hash) + extra = append(extra, nonceHash[:]...) err = node.OccupyNonceAccountByCall(ctx, nonce, call.RequestId) if err != nil { return err diff --git a/solana/system_call.go b/solana/system_call.go index e8d0933..6f533f7 100644 --- a/solana/system_call.go +++ b/solana/system_call.go @@ -19,6 +19,7 @@ import ( "github.com/MixinNetwork/safe/common" "github.com/gagliardetto/solana-go" computebudget "github.com/gagliardetto/solana-go/programs/compute-budget" + "github.com/gagliardetto/solana-go/programs/system" "github.com/gofrs/uuid/v5" "github.com/shopspring/decimal" ) @@ -554,6 +555,44 @@ func (node *Node) buildSystemCallFromBytes(ctx context.Context, req *store.Reque return call, tx, nil } +func verifySystemCallNonce(tx *solana.Transaction, nonceAccount string, nonceHash solana.Hash, authority solana.PublicKey) error { + if tx.Message.RecentBlockhash != nonceHash { + return fmt.Errorf("invalid system call nonce hash") + } + if len(tx.Message.Instructions) == 0 { + return fmt.Errorf("system call 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 + } + if len(accounts) != 3 { + return fmt.Errorf("invalid nonce advance accounts: %d", len(accounts)) + } + advance, err := solanaApp.DecodeNonceAdvance(accounts, ix.Data) + if err != nil { + return err + } + if advance.GetNonceAccount().PublicKey.String() != nonceAccount || !advance.GetNonceAccount().IsWritable { + return fmt.Errorf("invalid nonce account") + } + if advance.GetNonceAuthorityAccount().PublicKey != authority || !advance.GetNonceAuthorityAccount().IsSigner { + return fmt.Errorf("invalid nonce authority") + } + if advance.GetSysVarRecentBlockHashesPubkeyAccount().PublicKey != solana.SysVarRecentBlockHashesPubkey { + return fmt.Errorf("invalid nonce recent blockhashes sysvar") + } + return nil +} + func (node *Node) checkUserSystemCall(ctx context.Context, tx *solana.Transaction) error { if common.CheckTestEnvironment(ctx) { return nil From 4907ee9e0017c74ef53163ea6ffcdb192b243593 Mon Sep 17 00:00:00 2001 From: hundredark Date: Thu, 12 Nov 2026 16:25:22 +0800 Subject: [PATCH 2/2] slight fixes --- solana/computer_test.go | 4 +--- solana/mvm.go | 23 ++++++++++++++++++----- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/solana/computer_test.go b/solana/computer_test.go index 51d4b56..279ebeb 100644 --- a/solana/computer_test.go +++ b/solana/computer_test.go @@ -672,9 +672,7 @@ func testUserRequestSystemCall(ctx context.Context, require *require.Assertions, extra = append(extra, nonceHash[:]...) extra = attachSystemCall(extra, cid, raw) - wrongHash := append([]byte(nil), extra...) - wrongHash[1+uuid.Size] ^= 0xff - for _, invalid := range [][]byte{extra[:1+uuid.Size], extra[:1+uuid.Size+31], wrongHash} { + for _, invalid := range [][]byte{extra[:1+uuid.Size], extra[:1+uuid.Size+31]} { out := testBuildObserverRequest(node, uuid.Must(uuid.NewV4()).String(), OperationTypeConfirmNonce, invalid) for _, node := range nodes { testStep(ctx, require, node, out) diff --git a/solana/mvm.go b/solana/mvm.go index 0319da8..6c907e7 100644 --- a/solana/mvm.go +++ b/solana/mvm.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/hex" + "errors" "fmt" "math/big" "strings" @@ -317,6 +318,9 @@ func (node *Node) processConfirmNonce(ctx context.Context, req *store.Request) ( switch call.State { case common.RequestStateInitial: case common.RequestStateFailed: + if !req.Restored { + return node.failRequest(ctx, req, "") + } outputState = common.RequestStateDone default: return node.failRequest(ctx, req, "") @@ -326,11 +330,21 @@ func (node *Node) processConfirmNonce(ctx context.Context, req *store.Request) ( if err != nil || user == nil { panic(fmt.Errorf("store.ReadUser(%s) => %v %v", call.UserIdFromPublicPath(), user, err)) } + mix, err := bot.NewMixAddressFromString(user.MixAddress) + if err != nil { + panic(err) + } os, _, err := node.GetSystemCallReferenceOutputs(ctx, call.UserIdFromPublicPath(), call.RequestHash, byte(outputState)) logger.Printf("node.GetSystemCallReferenceTxs(%s) => %v %v", req.MixinHash.String(), os, err) if err != nil { panic(err) } + // Insufficient refund funds trigger compaction after the call is marked Failed. + // On replay, only rebuild the refund; do not revalidate or execute the main call. + if call.State == common.RequestStateFailed { + return node.refundAndFailRequest(ctx, req, mix.Members(), int(mix.Threshold), call, os) + } + switch flag { case ConfirmFlagNonceAvailable: // Available: call UUID, nonce hash, then the optional prepare call. @@ -344,12 +358,15 @@ func (node *Node) processConfirmNonce(ctx context.Context, req *store.Request) ( } err = node.processTransactionWithAddressLookups(ctx, mainTx) if err != nil { + if errors.Is(err, errInvalidAddressLookup) { + return node.refundAndFailRequest(ctx, req, mix.Members(), int(mix.Threshold), call, os) + } panic(err) } err = verifySystemCallNonce(mainTx, call.NonceAccount, nonceHash, node.SolanaPayer()) logger.Printf("verifySystemCallNonce(%s) => %v", call.RequestId, err) if err != nil { - return node.failRequest(ctx, req, "") + return node.refundAndFailRequest(ctx, req, mix.Members(), int(mix.Threshold), call, os) } as := node.GetSystemCallRelatedAsset(ctx, os) var sessions []*store.Session @@ -441,10 +458,6 @@ func (node *Node) processConfirmNonce(ctx context.Context, req *store.Request) ( } return txs, "" case ConfirmFlagNonceExpired: - mix, err := bot.NewMixAddressFromString(user.MixAddress) - if err != nil { - panic(err) - } return node.refundAndFailRequest(ctx, req, mix.Members(), int(mix.Threshold), call, os) default: logger.Printf("invalid nonce confirm flag: %d", flag)