diff --git a/solana/computer_test.go b/solana/computer_test.go index 7de6c74..279ebeb 100644 --- a/solana/computer_test.go +++ b/solana/computer_test.go @@ -666,8 +666,23 @@ 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) + 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) + 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..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,16 +330,47 @@ 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. + 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 { + 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.refundAndFailRequest(ctx, req, mix.Members(), int(mix.Threshold), call, os) + } 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, "") } @@ -423,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) 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