diff --git a/apps/solana/common.go b/apps/solana/common.go index b422994..c7bb64d 100644 --- a/apps/solana/common.go +++ b/apps/solana/common.go @@ -3,6 +3,8 @@ package solana import ( "context" "crypto/ed25519" + "encoding/base64" + "errors" "fmt" "math/big" "time" @@ -20,11 +22,33 @@ import ( "github.com/gagliardetto/solana-go/programs/token" ) +var ErrTransactionTooLarge = errors.New("solana transaction too large") + +type transactionTooLargeError struct { + encodedSize int +} + +func (e *transactionTooLargeError) Error() string { + return fmt.Sprintf( + "base64 encoded solana_transaction::versioned::VersionedTransaction too large: %d bytes (max: encoded/raw %d/%d)", + e.encodedSize, + MaxTransactionEncodedSize, + MaxTransactionRawSize, + ) +} + +func (e *transactionTooLargeError) Unwrap() error { + return ErrTransactionTooLarge +} + const ( NonceAccountSize uint64 = 80 MintSize uint64 = 82 NormalAccountSize uint64 = 165 + MaxTransactionRawSize = 1232 + MaxTransactionEncodedSize = 1644 + maxNameLength = 32 maxSymbolLength = 10 @@ -36,6 +60,28 @@ const ( AssetDecimal = 8 ) +// ValidateTransactionSize checks the wire size of a fully signed transaction. +// Transactions created for system calls are commonly marshaled before their +// signatures are produced, so counting tx.Signatures directly can undercount +// the final wire size by 64 bytes per missing signature. +func ValidateTransactionSize(tx *solana.Transaction) error { + if tx == nil { + return fmt.Errorf("nil solana transaction") + } + + fullySigned := *tx + fullySigned.Signatures = make([]solana.Signature, int(tx.Message.Header.NumRequiredSignatures)) + raw, err := fullySigned.MarshalBinary() + if err != nil { + return fmt.Errorf("marshal solana transaction: %w", err) + } + if len(raw) <= MaxTransactionRawSize { + return nil + } + + return &transactionTooLargeError{encodedSize: base64.StdEncoding.EncodedLen(len(raw))} +} + type Metadata struct { Name string `json:"name"` Symbol string `json:"symbol"` diff --git a/apps/solana/rpc.go b/apps/solana/rpc.go index 6039e38..c25c9cc 100644 --- a/apps/solana/rpc.go +++ b/apps/solana/rpc.go @@ -295,6 +295,9 @@ func (c *Client) GetMint(ctx context.Context, mint solana.PublicKey) (*token.Min } func (c *Client) SendTransaction(ctx context.Context, tx *solana.Transaction) (string, error) { + if err := ValidateTransactionSize(tx); err != nil { + return "", err + } sig, err := c.rpcClient.SendTransactionWithOpts(ctx, tx, rpc.TransactionOpts{ SkipPreflight: true, PreflightCommitment: rpc.CommitmentProcessed, diff --git a/apps/solana/transaction.go b/apps/solana/transaction.go index f7cbc12..2aed34f 100644 --- a/apps/solana/transaction.go +++ b/apps/solana/transaction.go @@ -66,6 +66,9 @@ func (c *Client) CreateNonceAccount(ctx context.Context, key, nonce string, rent if err != nil { panic(err) } + if err := ValidateTransactionSize(tx); err != nil { + return nil, err + } return tx, nil } @@ -102,6 +105,9 @@ func (c *Client) InitializeAccount(ctx context.Context, key, user string) (*sola if err != nil { panic(err) } + if err := ValidateTransactionSize(tx); err != nil { + return nil, err + } return tx, nil } @@ -189,6 +195,9 @@ func (c *Client) CreateMints(ctx context.Context, payer, mtg solana.PublicKey, a panic(err) } } + if err := ValidateTransactionSize(tx); err != nil { + return nil, err + } return tx, nil } @@ -232,6 +241,9 @@ func (c *Client) ExtendLookupTables(ctx context.Context, key, table string, as [ if err != nil { panic(err) } + if err := ValidateTransactionSize(tx); err != nil { + return nil, "", err + } return tx, table, nil } @@ -283,6 +295,9 @@ func (c *Client) TransferOrMintTokens(ctx context.Context, payer, mtg solana.Pub if err != nil { panic(err) } + if err := ValidateTransactionSize(tx); err != nil { + return nil, err + } return tx, nil } @@ -312,7 +327,14 @@ func (c *Client) TransferOrBurnTokens(ctx context.Context, payer, user solana.Pu ) } - return builder.Build() + tx, err := builder.Build() + if err != nil { + return nil, err + } + if err := ValidateTransactionSize(tx); err != nil { + return nil, err + } + return tx, nil } func (c *Client) AddTransferSolanaAssetInstruction(ctx context.Context, builder *solana.TransactionBuilder, transfer *TokenTransfer, payer, source solana.PublicKey) (*solana.TransactionBuilder, error) { diff --git a/solana/observer.go b/solana/observer.go index 9936a7f..5b1aec5 100644 --- a/solana/observer.go +++ b/solana/observer.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/binary" + "errors" "fmt" "math" "strings" @@ -620,36 +621,30 @@ func (node *Node) handleUnconfirmedCalls(ctx context.Context) 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, failureReason) - if err != nil { - return err - } - } else { - err := node.OccupyNonceAccountByCall(ctx, nonce, call.RequestId) + if failureReason == "" { + err = node.OccupyNonceAccountByCall(ctx, nonce, call.RequestId) if err != nil { return err } - - cid := common.UniqueId(id, "storage") fee, err := node.getSystemCallFeeFromXIN(ctx, call) if err != nil { return err } - nonce = node.ReadSpareNonceAccountWithCall(ctx, cid) - tx, err := node.CreatePrepareTransaction(ctx, call, nonce, fee) - if err != nil { + cid := common.UniqueId(id, "storage") + prepareNonce := node.ReadSpareNonceAccountWithCall(ctx, cid) + prepareTx, err := node.CreatePrepareTransaction(ctx, call, prepareNonce, fee) + if errors.Is(err, solanaApp.ErrTransactionTooLarge) { + failureReason = err.Error() + } else if err != nil { return err } - if tx != nil { - err := node.OccupyNonceAccountByCall(ctx, nonce, cid) + + if prepareTx != nil { + err := node.OccupyNonceAccountByCall(ctx, prepareNonce, cid) if err != nil { return err } - tb, err := tx.MarshalBinary() + tb, err := prepareTx.MarshalBinary() if err != nil { panic(err) } @@ -657,6 +652,16 @@ func (node *Node) handleUnconfirmedCalls(ctx context.Context) 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, failureReason) + if err != nil { + return err + } + } + err = node.sendObserverTransactionToGroup(ctx, &common.Operation{ Id: id, Type: OperationTypeConfirmNonce, @@ -728,6 +733,18 @@ func (node *Node) handleSignedCallSequence(ctx context.Context, wg *sync.WaitGro var ids []string for _, c := range calls { ids = append(ids, c.RequestId) + tx, err := solana.TransactionFromBase64(c.Raw) + if err != nil { + panic(fmt.Errorf("solana.TransactionFromBase64(%s) => %v", c.RequestId, err)) + } + err = solanaApp.ValidateTransactionSize(tx) + if errors.Is(err, solanaApp.ErrTransactionTooLarge) { + logger.Printf("node.handleSignedCallSequence(%s) => skip oversized call %s: %v", key, c.RequestId, err) + return + } + if err != nil { + panic(fmt.Errorf("solana.ValidateTransactionSize(%s) => %v", c.RequestId, err)) + } } logger.Printf("node.handleSignedCallSequence(%s) => %s", key, strings.Join(ids, ",")) if len(calls) > 2 { diff --git a/solana/solana.go b/solana/solana.go index 1b1df7c..55c55c8 100644 --- a/solana/solana.go +++ b/solana/solana.go @@ -314,13 +314,13 @@ func (node *Node) solanaProcessDepositTransaction(ctx context.Context, depositHa } nonce := node.ReadSpareNonceAccountWithCall(ctx, cid) - err = node.store.OccupyNonceAccountByCall(ctx, nonce.Address, cid) + tx, err := node.solana.TransferOrBurnTokens(ctx, node.SolanaPayer(), solana.MustPublicKeyFromBase58(user), nonce.Account(), ts) if err != nil { return err } - tx, err := node.solana.TransferOrBurnTokens(ctx, node.SolanaPayer(), solana.MustPublicKeyFromBase58(user), nonce.Account(), ts) + err = node.store.OccupyNonceAccountByCall(ctx, nonce.Address, cid) if err != nil { - panic(err) + return err } data, err := tx.MarshalBinary() if err != nil { diff --git a/solana/system_call.go b/solana/system_call.go index acfc9fa..df9d330 100644 --- a/solana/system_call.go +++ b/solana/system_call.go @@ -463,14 +463,20 @@ 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 resolve address lookups or parse nonce advance instruction; -// without fields of superior, type, public, skip_postprocess +// Returns validation errors for oversized transactions, unresolved address +// lookups, or malformed nonce-advance instructions. The returned call omits +// superior, type, public, and skip_postprocess fields. 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) logger.Printf("solana.TransactionFromBytes(%x) => %v %v", raw, tx, err) if err != nil { return nil, nil, err } + err = solanaApp.ValidateTransactionSize(tx) + if err != nil { + logger.Printf("solana.ValidateTransactionSize(%s %s) => %v", req.Id, id, err) + return nil, nil, err + } err = node.processTransactionWithAddressLookups(ctx, tx) if err != nil { if errors.Is(err, errInvalidAddressLookup) { diff --git a/store/migrate.go b/store/migrate.go index fd99094..b5f7433 100644 --- a/store/migrate.go +++ b/store/migrate.go @@ -3,10 +3,19 @@ package store import ( "context" "database/sql" + "errors" "fmt" "time" + solanaApp "github.com/MixinNetwork/computer/apps/solana" + "github.com/MixinNetwork/mixin/logger" "github.com/MixinNetwork/safe/common" + "github.com/gagliardetto/solana-go" +) + +const ( + oversizedDepositMigrationKey = "SCHEMA:VERSION:OVERSIZED_DEPOSIT_7E823E4C" + oversizedDepositSystemCallID = "7e823e4c-b389-320a-b241-ff96c30d730b" ) func (s *SQLite3Store) Migrate(ctx context.Context) error { @@ -19,24 +28,49 @@ func (s *SQLite3Store) Migrate(ctx context.Context) error { } defer common.Rollback(tx) - key, val := "SCHEMA:VERSION:FAILED_BURN", "" - row := tx.QueryRowContext(ctx, "SELECT value FROM properties WHERE key=?", key) - err = row.Scan(&val) - if err == nil || err != sql.ErrNoRows { + err = s.migrateOversizedDepositSystemCall(ctx, tx) + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *SQLite3Store) migrateOversizedDepositSystemCall(ctx context.Context, tx *sql.Tx) error { + applied, err := s.checkExistence(ctx, tx, "SELECT value FROM properties WHERE key=?", oversizedDepositMigrationKey) + if err != nil || applied { return err } - now := time.Now().UTC() - query := "UPDATE system_calls SET state=? WHERE id=? AND state=?" - _, err = tx.ExecContext(ctx, query, common.RequestStatePending, "035d4b18-451d-336c-abf1-ee9909f4e931", common.RequestStateFailed) + call, err := s.ReadSystemCallByRequestId(ctx, oversizedDepositSystemCallID, common.RequestStatePending) if err != nil { - return fmt.Errorf("SQLite3Store UPDATE system_calls %v", err) + return fmt.Errorf("store.ReadSystemCallByRequestId(%s) => %v", oversizedDepositSystemCallID, err) + } + if call == nil { + return s.writeProperty(ctx, tx, oversizedDepositMigrationKey, "system call not found") + } + if call.Type != CallTypeDeposit { + return fmt.Errorf("invalid system call type for oversized deposit migration: %s", call.Type) } - _, err = tx.ExecContext(ctx, "INSERT INTO properties (key, value, created_at, updated_at) VALUES (?, ?, ?, ?)", key, query, now, now) + solanaTx, err := solana.TransactionFromBase64(call.Raw) if err != nil { - return err + return fmt.Errorf("solana.TransactionFromBase64(%s) => %v", oversizedDepositSystemCallID, err) + } + sizeErr := solanaApp.ValidateTransactionSize(solanaTx) + if sizeErr == nil { + return s.writeProperty(ctx, tx, oversizedDepositMigrationKey, "transaction within size limit") + } + if !errors.Is(sizeErr, solanaApp.ErrTransactionTooLarge) { + return fmt.Errorf("solana.ValidateTransactionSize(%s) => %v", oversizedDepositSystemCallID, sizeErr) } + logger.Printf("store.migrateOversizedDepositSystemCall(%s) => %v", oversizedDepositSystemCallID, sizeErr) - return tx.Commit() + query := "UPDATE system_calls SET state=?, updated_at=? WHERE id=? AND call_type=? AND state=?" + err = s.execOne(ctx, tx, query, common.RequestStateFailed, time.Now().UTC(), oversizedDepositSystemCallID, CallTypeDeposit, common.RequestStatePending) + if err != nil { + return fmt.Errorf("SQLite3Store UPDATE oversized deposit system_calls %v", err) + } + + return s.writeProperty(ctx, tx, oversizedDepositMigrationKey, sizeErr.Error()) }