From 9709ed2f6b760d9eee3baa9fab64385bfda1928f Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Thu, 29 Jan 2026 00:51:27 -0800 Subject: [PATCH] feat(wasm-solana): add transaction building and versioned transaction support This commit adds comprehensive transaction building capabilities to wasm-solana, enabling the construction of both legacy and versioned (MessageV0) Solana transactions without requiring @solana/web3.js dependency. ## Transaction Building ### Core Builder (Rust) - `src/builder/build.rs` - Main transaction building logic from TransactionIntent - `src/builder/types.rs` - Type definitions (TransactionIntent, Instruction, Nonce, etc.) - `src/builder/versioned.rs` - MessageV0/versioned transaction building from raw data ### Supported Instructions - Transfer (SOL and SPL tokens) - CreateAssociatedTokenAccount - StakingActivate, StakingDeactivate, StakingWithdraw - StakingAuthorize, StakingAuthorizeRaw - StakingPartialDeactivate (split + deactivate) - StakingSplit - AdvanceNonceAccount (durable nonce support) - Memo - SetComputeUnitLimit, SetComputeUnitPrice - TokenTransfer (SPL Token Program transfers) - CustomInstruction (raw instruction support) ### TypeScript API (`js/builder.ts`) - `buildTransaction()` - Build transactions from high-level intents - Type definitions for all instruction kinds and nonce sources - Support for Address Lookup Tables in versioned transactions ## Versioned Transaction Support ### Parsing (`js/versioned.ts`, `src/versioned.rs`) - `VersionedTransaction` class for parsing both legacy and MessageV0 transactions - `isVersionedTransaction()` utility to detect transaction format - Extract Address Lookup Tables, static account keys, and instructions - Support for adding signatures to parsed transactions ### Building from Raw Data - `buildFromRawVersionedData()` - Build MessageV0 from pre-compiled instruction data - Preserves account indexes, ALT references, and message header from source - Enables rebuilding transactions with different blockhash/nonce ## WASM Exports ### Constants (`src/wasm/constants.rs`) - Program IDs: systemProgramId, tokenProgramId, token2022ProgramId, etc. - Sysvar addresses: sysvarClockAddress, sysvarRecentBlockhashes, etc. - Well-known addresses: memoV1ProgramId, associatedTokenProgramId, etc. ### Builder Functions (`src/wasm/builder.rs`) - `buildTransaction()` - WASM entry point for transaction building - `buildFromRawVersionedData()` - WASM entry point for versioned building ### Transaction Methods - `serializeMessage()` - Returns message bytes for web3.js API compatibility - Property-based API (feePayer, recentBlockhash, signatures) - `CustomInstruction` type for passing raw instructions to builder ## Additional Changes ### CreateATA Fix - Fixed `programId` return value for CreateAssociatedTokenAccount instructions - Now correctly returns Token Program ID instead of ATA Program ID ### Stake Split Support - Added `StakeSplitIntent` for stake account splitting - Proper handling of split instruction in both building and parsing ### Test Coverage - `test/builder.ts` - Comprehensive builder tests (693 lines) - `test/versioned.ts` - Versioned transaction tests - `test/transaction.ts` - Extended transaction tests with building ## Architecture The builder follows an intent-based architecture: 1. Caller creates a `TransactionIntent` with fee payer, nonce, and instructions 2. Builder converts intents to native Solana instructions 3. Transaction is serialized as legacy or versioned based on ALT presence 4. Returns raw transaction bytes for signing This design eliminates @solana/web3.js dependency while maintaining full compatibility with the Solana transaction format. --- packages/wasm-solana/Cargo.lock | 1038 ++++++++++- packages/wasm-solana/Cargo.toml | 21 +- packages/wasm-solana/js/builder.ts | 592 ++++++ packages/wasm-solana/js/index.ts | 67 + packages/wasm-solana/js/parser.ts | 8 + packages/wasm-solana/js/transaction.ts | 80 +- packages/wasm-solana/js/versioned.ts | 251 +++ packages/wasm-solana/src/builder/build.rs | 1588 +++++++++++++++++ packages/wasm-solana/src/builder/mod.rs | 23 + packages/wasm-solana/src/builder/types.rs | 456 +++++ packages/wasm-solana/src/builder/versioned.rs | 258 +++ .../wasm-solana/src/instructions/decode.rs | 17 +- .../wasm-solana/src/instructions/types.rs | 17 + packages/wasm-solana/src/lib.rs | 10 +- packages/wasm-solana/src/parser.rs | 61 +- packages/wasm-solana/src/transaction.rs | 112 +- packages/wasm-solana/src/versioned.rs | 341 ++++ packages/wasm-solana/src/wasm/builder.rs | 109 ++ packages/wasm-solana/src/wasm/constants.rs | 148 ++ packages/wasm-solana/src/wasm/mod.rs | 8 +- packages/wasm-solana/src/wasm/transaction.rs | 252 ++- packages/wasm-solana/test/builder.ts | 693 +++++++ packages/wasm-solana/test/parser.ts | 14 - packages/wasm-solana/test/transaction.ts | 231 ++- packages/wasm-solana/test/versioned.ts | 95 + 25 files changed, 6346 insertions(+), 144 deletions(-) create mode 100644 packages/wasm-solana/js/builder.ts create mode 100644 packages/wasm-solana/js/versioned.ts create mode 100644 packages/wasm-solana/src/builder/build.rs create mode 100644 packages/wasm-solana/src/builder/mod.rs create mode 100644 packages/wasm-solana/src/builder/types.rs create mode 100644 packages/wasm-solana/src/builder/versioned.rs create mode 100644 packages/wasm-solana/src/versioned.rs create mode 100644 packages/wasm-solana/src/wasm/builder.rs create mode 100644 packages/wasm-solana/src/wasm/constants.rs create mode 100644 packages/wasm-solana/test/builder.ts create mode 100644 packages/wasm-solana/test/versioned.ts diff --git a/packages/wasm-solana/Cargo.lock b/packages/wasm-solana/Cargo.lock index 6805a7bf..10c9b81d 100644 --- a/packages/wasm-solana/Cargo.lock +++ b/packages/wasm-solana/Cargo.lock @@ -68,6 +68,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + [[package]] name = "async-trait" version = "0.1.89" @@ -85,6 +91,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.12.3" @@ -97,6 +109,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bincode" version = "1.3.3" @@ -322,6 +340,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -343,6 +367,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -405,6 +441,16 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "derivation-path" version = "0.2.0" @@ -427,17 +473,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid", "crypto-common", "subtle", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki", +] + [[package]] name = "ed25519" version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7" dependencies = [ - "signature", + "signature 1.6.4", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature 2.2.0", ] [[package]] @@ -447,19 +518,65 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c762bae6dcaf24c4c84667b8579785430908723d5c889f469d76a41d59cc7a9d" dependencies = [ "curve25519-dalek 3.2.0", - "ed25519", + "ed25519 1.5.3", "rand 0.7.3", "serde", "sha2 0.9.9", "zeroize", ] +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "ed25519-dalek-bip32" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b49a684b133c4980d7ee783936af771516011c8cd15f429dbda77245e282f03" +dependencies = [ + "derivation-path", + "ed25519-dalek 2.2.0", + "hmac", + "sha2 0.10.9", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -472,6 +589,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "835a3dc7d1ec9e75e2b5fb4ba75396837112d2060b03f7d43bc1897c7f7211da" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -546,6 +673,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -572,6 +700,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "hashbrown" version = "0.13.2" @@ -657,6 +796,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2 0.10.9", + "signature 2.2.0", +] + [[package]] name = "keccak" version = "0.1.5" @@ -909,6 +1062,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "polyval" version = "0.6.2" @@ -1055,6 +1218,16 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -1085,6 +1258,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.27" @@ -1210,6 +1397,22 @@ version = "1.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "smallvec" version = "1.15.1" @@ -1229,6 +1432,24 @@ dependencies = [ "solana-sdk-ids 2.2.1", ] +[[package]] +name = "solana-account" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60e0ac2a81ae17e1b3570deb50242ab4cfde50b848b898f57288b6271cc7b71f" +dependencies = [ + "bincode", + "serde", + "serde_bytes", + "serde_derive", + "solana-account-info 3.1.0", + "solana-clock 3.0.0", + "solana-instruction-error", + "solana-pubkey 4.0.0", + "solana-sdk-ids 3.1.0", + "solana-sysvar 3.1.1", +] + [[package]] name = "solana-account-info" version = "2.3.0" @@ -1248,6 +1469,8 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc3397241392f5756925029acaa8515dc70fcbe3d8059d4885d7d6533baf64fd" dependencies = [ + "bincode", + "serde_core", "solana-address 2.0.0", "solana-program-error 3.0.0", "solana-program-memory 3.1.0", @@ -1268,8 +1491,13 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e37320fd2945c5d654b2c6210624a52d66c3f1f73b653ed211ab91a703b35bdd" dependencies = [ + "borsh 1.6.0", + "bytemuck", + "bytemuck_derive", + "curve25519-dalek 4.1.3", "five8 1.0.0", "five8_const 1.0.0", + "rand 0.8.5", "serde", "serde_derive", "solana-atomic-u64 3.0.0", @@ -1296,6 +1524,18 @@ dependencies = [ "solana-slot-hashes 2.2.1", ] +[[package]] +name = "solana-address-lookup-table-interface" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e8df0b083c10ce32490410f3795016b1b5d9b4d094658c0a5e496753645b7cd" +dependencies = [ + "solana-clock 3.0.0", + "solana-pubkey 4.0.0", + "solana-sdk-ids 3.1.0", + "solana-slot-hashes 3.0.0", +] + [[package]] name = "solana-atomic-u64" version = "2.2.1" @@ -1325,6 +1565,17 @@ dependencies = [ "solana-define-syscall 2.3.0", ] +[[package]] +name = "solana-big-mod-exp" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30c80fb6d791b3925d5ec4bf23a7c169ef5090c013059ec3ed7d0b2c04efa085" +dependencies = [ + "num-bigint", + "num-traits", + "solana-define-syscall 3.0.0", +] + [[package]] name = "solana-bincode" version = "2.2.1" @@ -1348,6 +1599,17 @@ dependencies = [ "solana-sanitize 2.2.1", ] +[[package]] +name = "solana-blake3-hasher" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7116e1d942a2432ca3f514625104757ab8a56233787e95144c93950029e31176" +dependencies = [ + "blake3", + "solana-define-syscall 4.0.1", + "solana-hash 4.0.1", +] + [[package]] name = "solana-borsh" version = "2.2.1" @@ -1386,13 +1648,13 @@ dependencies = [ [[package]] name = "solana-compute-budget-interface" -version = "2.2.2" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8432d2c4c22d0499aa06d62e4f7e333f81777b3d7c96050ae9e5cb71a8c3aee4" +checksum = "8292c436b269ad23cecc8b24f7da3ab07ca111661e25e00ce0e1d22771951ab9" dependencies = [ "borsh 1.6.0", - "solana-instruction 2.3.3", - "solana-sdk-ids 2.2.1", + "solana-instruction 3.1.0", + "solana-sdk-ids 3.1.0", ] [[package]] @@ -1475,6 +1737,27 @@ dependencies = [ "uriparse", ] +[[package]] +name = "solana-derivation-path" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff71743072690fdbdfcdc37700ae1cb77485aaad49019473a81aee099b1e0b8c" +dependencies = [ + "derivation-path", + "qstring", + "uriparse", +] + +[[package]] +name = "solana-epoch-info" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e093c84f6ece620a6b10cd036574b0cd51944231ab32d81f80f76d54aba833e6" +dependencies = [ + "serde", + "serde_derive", +] + [[package]] name = "solana-epoch-rewards" version = "2.2.1" @@ -1503,6 +1786,17 @@ dependencies = [ "solana-sysvar-id 3.1.0", ] +[[package]] +name = "solana-epoch-rewards-hasher" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ee8beac9bff4db9225e57d532d169b0be5e447f1e6601a2f50f27a01bf5518f" +dependencies = [ + "siphasher", + "solana-address 2.0.0", + "solana-hash 4.0.1", +] + [[package]] name = "solana-epoch-schedule" version = "2.2.1" @@ -1529,6 +1823,16 @@ dependencies = [ "solana-sysvar-id 3.1.0", ] +[[package]] +name = "solana-epoch-stake" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc6693d0ea833b880514b9b88d95afb80b42762dca98b0712465d1fcbbcb89e" +dependencies = [ + "solana-define-syscall 3.0.0", + "solana-pubkey 3.0.0", +] + [[package]] name = "solana-example-mocks" version = "2.2.1" @@ -1537,19 +1841,40 @@ checksum = "84461d56cbb8bb8d539347151e0525b53910102e4bced875d49d5139708e39d3" dependencies = [ "serde", "serde_derive", - "solana-address-lookup-table-interface", + "solana-address-lookup-table-interface 2.2.2", "solana-clock 2.2.2", "solana-hash 2.3.0", "solana-instruction 2.3.3", - "solana-keccak-hasher", + "solana-keccak-hasher 2.2.1", "solana-message 2.4.0", - "solana-nonce", + "solana-nonce 2.2.1", "solana-pubkey 2.4.0", "solana-sdk-ids 2.2.1", "solana-system-interface 1.0.0", "thiserror 2.0.18", ] +[[package]] +name = "solana-example-mocks" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978855d164845c1b0235d4b4d101cadc55373fffaf0b5b6cfa2194d25b2ed658" +dependencies = [ + "serde", + "serde_derive", + "solana-address-lookup-table-interface 3.0.1", + "solana-clock 3.0.0", + "solana-hash 3.1.0", + "solana-instruction 3.1.0", + "solana-keccak-hasher 3.1.0", + "solana-message 3.0.1", + "solana-nonce 3.0.0", + "solana-pubkey 3.0.0", + "solana-sdk-ids 3.1.0", + "solana-system-interface 2.0.0", + "thiserror 2.0.18", +] + [[package]] name = "solana-feature-gate-interface" version = "2.2.2" @@ -1559,7 +1884,7 @@ dependencies = [ "bincode", "serde", "serde_derive", - "solana-account", + "solana-account 2.2.1", "solana-account-info 2.3.0", "solana-instruction 2.3.3", "solana-program-error 2.2.2", @@ -1591,6 +1916,22 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "solana-fee-structure" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2abdb1223eea8ec64136f39cb1ffcf257e00f915c957c35c0dd9e3f4e700b0" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "solana-hard-forks" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0abacc4b66ce471f135f48f22facf75cbbb0f8a252fbe2c1e0aa59d5b203f519" + [[package]] name = "solana-hash" version = "2.3.0" @@ -1624,6 +1965,7 @@ version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a5d48a6ee7b91fc7b998944ab026ed7b3e2fc8ee3bc58452644a86c2648152f" dependencies = [ + "borsh 1.6.0", "bytemuck", "bytemuck_derive", "five8 1.0.0", @@ -1634,8 +1976,18 @@ dependencies = [ ] [[package]] -name = "solana-instruction" -version = "2.3.3" +name = "solana-inflation" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e92f37a14e7c660628752833250dd3dcd8e95309876aee751d7f8769a27947c6" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "solana-instruction" +version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bab5682934bd1f65f8d2c16f21cb532526fcc1a09f796e2cacdb091eee5774ad" dependencies = [ @@ -1692,10 +2044,28 @@ dependencies = [ "solana-pubkey 2.4.0", "solana-sanitize 2.2.1", "solana-sdk-ids 2.2.1", - "solana-serialize-utils", + "solana-serialize-utils 2.2.1", "solana-sysvar-id 2.2.1", ] +[[package]] +name = "solana-instructions-sysvar" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddf67876c541aa1e21ee1acae35c95c6fbc61119814bfef70579317a5e26955" +dependencies = [ + "bitflags", + "solana-account-info 3.1.0", + "solana-instruction 3.1.0", + "solana-instruction-error", + "solana-program-error 3.0.0", + "solana-pubkey 3.0.0", + "solana-sanitize 3.0.1", + "solana-sdk-ids 3.1.0", + "solana-serialize-utils 3.1.0", + "solana-sysvar-id 3.1.0", +] + [[package]] name = "solana-keccak-hasher" version = "2.2.1" @@ -1708,22 +2078,51 @@ dependencies = [ "solana-sanitize 2.2.1", ] +[[package]] +name = "solana-keccak-hasher" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed1c0d16d6fdeba12291a1f068cdf0d479d9bff1141bf44afd7aa9d485f65ef8" +dependencies = [ + "sha3", + "solana-define-syscall 4.0.1", + "solana-hash 4.0.1", +] + [[package]] name = "solana-keypair" version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd3f04aa1a05c535e93e121a95f66e7dcccf57e007282e8255535d24bf1e98bb" dependencies = [ - "ed25519-dalek", + "ed25519-dalek 1.0.1", "five8 0.2.1", "rand 0.7.3", "solana-pubkey 2.4.0", - "solana-seed-phrase", + "solana-seed-phrase 2.2.1", "solana-signature 2.3.0", "solana-signer 2.2.1", "wasm-bindgen", ] +[[package]] +name = "solana-keypair" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8be597c9e231b0cab2928ce3bc3e4ee77d9c0ad92977b9d901f3879f25a7a" +dependencies = [ + "ed25519-dalek 2.2.0", + "ed25519-dalek-bip32", + "five8 1.0.0", + "rand 0.8.5", + "solana-address 2.0.0", + "solana-derivation-path 3.0.0", + "solana-seed-derivable 3.0.0", + "solana-seed-phrase 3.0.0", + "solana-signature 3.1.0", + "solana-signer 3.0.0", +] + [[package]] name = "solana-last-restart-slot" version = "2.2.1" @@ -1824,6 +2223,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85666605c9fd727f865ed381665db0a8fc29f984a030ecc1e40f43bfb2541623" dependencies = [ "bincode", + "blake3", "lazy_static", "serde", "serde_derive", @@ -1860,6 +2260,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61515b880c36974053dd499c0510066783f0cc6ac17def0c7ef2a244874cf4a9" +[[package]] +name = "solana-native-token" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8dd4c280dca9d046139eb5b7a5ac9ad10403fbd64964c7d7571214950d758f" + [[package]] name = "solana-nonce" version = "2.2.1" @@ -1874,6 +2280,54 @@ dependencies = [ "solana-sha256-hasher 2.3.0", ] +[[package]] +name = "solana-nonce" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abbdc6c8caf1c08db9f36a50967539d0f72b9f1d4aea04fec5430f532e5afadc" +dependencies = [ + "solana-fee-calculator 3.0.0", + "solana-hash 3.1.0", + "solana-pubkey 3.0.0", + "solana-sha256-hasher 3.1.0", +] + +[[package]] +name = "solana-offchain-message" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e2a1141a673f72a05cf406b99e4b2b8a457792b7c01afa07b3f00d4e2de393" +dependencies = [ + "num_enum", + "solana-hash 3.1.0", + "solana-packet", + "solana-pubkey 3.0.0", + "solana-sanitize 3.0.1", + "solana-sha256-hasher 3.1.0", + "solana-signature 3.1.0", + "solana-signer 3.0.0", +] + +[[package]] +name = "solana-packet" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edf2f25743c95229ac0fdc32f8f5893ef738dbf332c669e9861d33ddb0f469d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "solana-presigner" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f704eaf825be3180832445b9e4983b875340696e8e7239bf2d535b0f86c14a2" +dependencies = [ + "solana-pubkey 3.0.0", + "solana-signature 3.1.0", + "solana-signer 3.0.0", +] + [[package]] name = "solana-program" version = "2.3.0" @@ -1900,11 +2354,11 @@ dependencies = [ "serde_bytes", "serde_derive", "solana-account-info 2.3.0", - "solana-address-lookup-table-interface", + "solana-address-lookup-table-interface 2.2.2", "solana-atomic-u64 2.2.1", - "solana-big-mod-exp", + "solana-big-mod-exp 2.2.1", "solana-bincode", - "solana-blake3-hasher", + "solana-blake3-hasher 2.2.1", "solana-borsh", "solana-clock 2.2.2", "solana-cpi 2.2.1", @@ -1912,34 +2366,34 @@ dependencies = [ "solana-define-syscall 2.3.0", "solana-epoch-rewards 2.2.1", "solana-epoch-schedule 2.2.1", - "solana-example-mocks", + "solana-example-mocks 2.2.1", "solana-feature-gate-interface", "solana-fee-calculator 2.2.1", "solana-hash 2.3.0", "solana-instruction 2.3.3", - "solana-instructions-sysvar", - "solana-keccak-hasher", + "solana-instructions-sysvar 2.2.2", + "solana-keccak-hasher 2.2.1", "solana-last-restart-slot 2.2.1", "solana-loader-v2-interface", "solana-loader-v3-interface", "solana-loader-v4-interface", "solana-message 2.4.0", "solana-msg 2.2.1", - "solana-native-token", - "solana-nonce", + "solana-native-token 2.3.0", + "solana-nonce 2.2.1", "solana-program-entrypoint 2.3.0", "solana-program-error 2.2.2", "solana-program-memory 2.3.1", - "solana-program-option", - "solana-program-pack", + "solana-program-option 2.2.1", + "solana-program-pack 2.2.1", "solana-pubkey 2.4.0", "solana-rent 2.2.1", "solana-sanitize 2.2.1", "solana-sdk-ids 2.2.1", "solana-sdk-macro 2.2.1", - "solana-secp256k1-recover", - "solana-serde-varint", - "solana-serialize-utils", + "solana-secp256k1-recover 2.2.1", + "solana-serde-varint 2.2.2", + "solana-serialize-utils 2.2.1", "solana-sha256-hasher 2.3.0", "solana-short-vec 2.2.1", "solana-slot-hashes 2.2.1", @@ -1954,6 +2408,52 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "solana-program" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91b12305dd81045d705f427acd0435a2e46444b65367d7179d7bdcfc3bc5f5eb" +dependencies = [ + "memoffset", + "solana-account-info 3.1.0", + "solana-big-mod-exp 3.0.0", + "solana-blake3-hasher 3.1.0", + "solana-clock 3.0.0", + "solana-cpi 3.1.0", + "solana-define-syscall 3.0.0", + "solana-epoch-rewards 3.0.0", + "solana-epoch-schedule 3.0.0", + "solana-epoch-stake", + "solana-example-mocks 3.0.0", + "solana-fee-calculator 3.0.0", + "solana-hash 3.1.0", + "solana-instruction 3.1.0", + "solana-instruction-error", + "solana-instructions-sysvar 3.0.0", + "solana-keccak-hasher 3.1.0", + "solana-last-restart-slot 3.0.0", + "solana-msg 3.0.0", + "solana-native-token 3.0.0", + "solana-program-entrypoint 3.1.1", + "solana-program-error 3.0.0", + "solana-program-memory 3.1.0", + "solana-program-option 3.0.0", + "solana-program-pack 3.0.0", + "solana-pubkey 3.0.0", + "solana-rent 3.1.0", + "solana-sdk-ids 3.1.0", + "solana-secp256k1-recover 3.1.0", + "solana-serde-varint 3.0.0", + "solana-serialize-utils 3.1.0", + "solana-sha256-hasher 3.1.0", + "solana-short-vec 3.1.0", + "solana-slot-hashes 3.0.0", + "solana-slot-history 3.0.0", + "solana-stable-layout 3.0.0", + "solana-sysvar 3.1.1", + "solana-sysvar-id 3.1.0", +] + [[package]] name = "solana-program-entrypoint" version = "2.3.0" @@ -1999,6 +2499,10 @@ name = "solana-program-error" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1af32c995a7b692a915bb7414d5f8e838450cf7c70414e763d8abcae7b51f28" +dependencies = [ + "serde", + "serde_derive", +] [[package]] name = "solana-program-memory" @@ -2024,6 +2528,12 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc677a2e9bc616eda6dbdab834d463372b92848b2bfe4a1ed4e4b4adba3397d0" +[[package]] +name = "solana-program-option" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e7b4ddb464f274deb4a497712664c3b612e3f5f82471d4e47710fc4ab1c3095" + [[package]] name = "solana-program-pack" version = "2.2.1" @@ -2033,6 +2543,15 @@ dependencies = [ "solana-program-error 2.2.2", ] +[[package]] +name = "solana-program-pack" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c169359de21f6034a63ebf96d6b380980307df17a8d371344ff04a883ec4e9d0" +dependencies = [ + "solana-program-error 3.0.0", +] + [[package]] name = "solana-pubkey" version = "2.4.0" @@ -2065,6 +2584,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8909d399deb0851aa524420beeb5646b115fd253ef446e35fe4504c904da3941" dependencies = [ + "rand 0.8.5", "solana-address 1.1.0", ] @@ -2115,6 +2635,44 @@ version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcf09694a0fc14e5ffb18f9b7b7c0f15ecb6eac5b5610bf76a1853459d19daf9" +[[package]] +name = "solana-sdk" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f03df7969f5e723ad31b6c9eadccc209037ac4caa34d8dc259316b05c11e82b" +dependencies = [ + "bincode", + "bs58", + "serde", + "solana-account 3.3.0", + "solana-epoch-info", + "solana-epoch-rewards-hasher", + "solana-fee-structure", + "solana-inflation", + "solana-keypair 3.1.0", + "solana-message 3.0.1", + "solana-offchain-message", + "solana-presigner", + "solana-program 3.0.0", + "solana-program-memory 3.1.0", + "solana-pubkey 3.0.0", + "solana-sanitize 3.0.1", + "solana-sdk-ids 3.1.0", + "solana-sdk-macro 3.0.0", + "solana-seed-derivable 3.0.0", + "solana-seed-phrase 3.0.0", + "solana-serde", + "solana-serde-varint 3.0.0", + "solana-short-vec 3.1.0", + "solana-shred-version", + "solana-signature 3.1.0", + "solana-signer 3.0.0", + "solana-time-utils", + "solana-transaction", + "solana-transaction-error 3.0.0", + "thiserror 2.0.18", +] + [[package]] name = "solana-sdk-ids" version = "2.2.1" @@ -2168,6 +2726,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "solana-secp256k1-recover" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9de18cfdab99eeb940fbedd8c981fa130c0d76252da75d05446f22fae8b51932" +dependencies = [ + "k256", + "solana-define-syscall 4.0.1", + "thiserror 2.0.18", +] + [[package]] name = "solana-security-txt" version = "1.1.2" @@ -2183,7 +2752,16 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beb82b5adb266c6ea90e5cf3967235644848eac476c5a1f2f9283a143b7c97f" dependencies = [ - "solana-derivation-path", + "solana-derivation-path 2.2.1", +] + +[[package]] +name = "solana-seed-derivable" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7bdb72758e3bec33ed0e2658a920f1f35dfb9ed576b951d20d63cb61ecd95c" +dependencies = [ + "solana-derivation-path 3.0.0", ] [[package]] @@ -2197,6 +2775,26 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "solana-seed-phrase" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc905b200a95f2ea9146e43f2a7181e3aeb55de6bc12afb36462d00a3c7310de" +dependencies = [ + "hmac", + "pbkdf2", + "sha2 0.10.9", +] + +[[package]] +name = "solana-serde" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709a93cab694c70f40b279d497639788fc2ccbcf9b4aa32273d4b361322c02dd" +dependencies = [ + "serde", +] + [[package]] name = "solana-serde-varint" version = "2.2.2" @@ -2206,6 +2804,15 @@ dependencies = [ "serde", ] +[[package]] +name = "solana-serde-varint" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e5174c57d5ff3c1995f274d17156964664566e2cde18a07bba1586d35a70d3b" +dependencies = [ + "serde", +] + [[package]] name = "solana-serialize-utils" version = "2.2.1" @@ -2217,6 +2824,17 @@ dependencies = [ "solana-sanitize 2.2.1", ] +[[package]] +name = "solana-serialize-utils" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e41dd8feea239516c623a02f0a81c2367f4b604d7965237fed0751aeec33ed" +dependencies = [ + "solana-instruction-error", + "solana-pubkey 3.0.0", + "solana-sanitize 3.0.1", +] + [[package]] name = "solana-sha256-hasher" version = "2.3.0" @@ -2257,13 +2875,24 @@ dependencies = [ "serde_core", ] +[[package]] +name = "solana-shred-version" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94953e22ca28fe4541a3447d6baeaf519cc4ddc063253bfa673b721f34c136bb" +dependencies = [ + "solana-hard-forks", + "solana-hash 3.1.0", + "solana-sha256-hasher 3.1.0", +] + [[package]] name = "solana-signature" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64c8ec8e657aecfc187522fc67495142c12f35e55ddeca8698edbb738b8dbd8c" dependencies = [ - "ed25519-dalek", + "ed25519-dalek 1.0.1", "five8 0.2.1", "solana-sanitize 2.2.1", ] @@ -2274,7 +2903,9 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4bb8057cc0e9f7b5e89883d49de6f407df655bb6f3a71d0b7baf9986a2218fd9" dependencies = [ + "ed25519-dalek 2.2.0", "five8 0.2.1", + "rand 0.8.5", "serde", "serde-big-array", "serde_derive", @@ -2445,6 +3076,21 @@ dependencies = [ "solana-pubkey 3.0.0", ] +[[package]] +name = "solana-system-interface" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14591d6508042ebefb110305d3ba761615927146a26917ade45dc332d8e1ecde" +dependencies = [ + "num-traits", + "serde", + "serde_derive", + "solana-address 2.0.0", + "solana-instruction 3.1.0", + "solana-msg 3.0.0", + "solana-program-error 3.0.0", +] + [[package]] name = "solana-sysvar" version = "2.3.0" @@ -2466,7 +3112,7 @@ dependencies = [ "solana-fee-calculator 2.2.1", "solana-hash 2.3.0", "solana-instruction 2.3.3", - "solana-instructions-sysvar", + "solana-instructions-sysvar 2.2.2", "solana-last-restart-slot 2.2.1", "solana-program-entrypoint 2.3.0", "solana-program-error 2.2.2", @@ -2490,6 +3136,8 @@ checksum = "6690d3dd88f15c21edff68eb391ef8800df7a1f5cec84ee3e8d1abf05affdf74" dependencies = [ "base64 0.22.1", "bincode", + "bytemuck", + "bytemuck_derive", "lazy_static", "serde", "serde_derive", @@ -2534,6 +3182,12 @@ dependencies = [ "solana-sdk-ids 3.1.0", ] +[[package]] +name = "solana-time-utils" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced92c60aa76ec4780a9d93f3bd64dfa916e1b998eacc6f1c110f3f444f02c9" + [[package]] name = "solana-transaction" version = "3.0.2" @@ -2596,8 +3250,8 @@ dependencies = [ "solana-pubkey 2.4.0", "solana-rent 2.2.1", "solana-sdk-ids 2.2.1", - "solana-serde-varint", - "solana-serialize-utils", + "solana-serde-varint 2.2.2", + "solana-serialize-utils 2.2.1", "solana-short-vec 2.2.1", "solana-system-interface 1.0.0", ] @@ -2624,12 +3278,12 @@ dependencies = [ "serde_derive", "serde_json", "sha3", - "solana-derivation-path", + "solana-derivation-path 2.2.1", "solana-instruction 2.3.3", "solana-pubkey 2.4.0", "solana-sdk-ids 2.2.1", - "solana-seed-derivable", - "solana-seed-phrase", + "solana-seed-derivable 2.2.1", + "solana-seed-phrase 2.2.1", "solana-signature 2.3.0", "solana-signer 2.2.1", "subtle", @@ -2638,6 +3292,78 @@ dependencies = [ "zeroize", ] +[[package]] +name = "solana-zk-token-sdk" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5055e5df94abd5badf4f947681c893375bdb6f8f543c05d2a7ab9647a6a9d205" +dependencies = [ + "aes-gcm-siv", + "base64 0.22.1", + "bincode", + "bytemuck", + "bytemuck_derive", + "curve25519-dalek 4.1.3", + "itertools", + "merlin", + "num-derive", + "num-traits", + "rand 0.8.5", + "serde", + "serde_derive", + "serde_json", + "sha3", + "solana-curve25519", + "solana-derivation-path 2.2.1", + "solana-instruction 2.3.3", + "solana-pubkey 2.4.0", + "solana-sdk-ids 2.2.1", + "solana-seed-derivable 2.2.1", + "solana-seed-phrase 2.2.1", + "solana-signature 2.3.0", + "solana-signer 2.2.1", + "subtle", + "thiserror 2.0.18", + "zeroize", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "spl-associated-token-account" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68034596cf4804880d265f834af1ff2f821ad5293e41fa0f8f59086c181fc38e" +dependencies = [ + "assert_matches", + "borsh 1.6.0", + "num-derive", + "num-traits", + "solana-program 2.3.0", + "spl-token 6.0.0", + "spl-token-2022 4.0.1", + "thiserror 1.0.69", +] + +[[package]] +name = "spl-discriminator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a38ea8b6dedb7065887f12d62ed62c1743aa70749e8558f963609793f6fb12bc" +dependencies = [ + "bytemuck", + "solana-program 2.3.0", + "spl-discriminator-derive", +] + [[package]] name = "spl-discriminator" version = "0.4.1" @@ -2694,10 +3420,19 @@ dependencies = [ "solana-system-interface 1.0.0", "solana-sysvar 2.3.0", "solana-zk-sdk", - "spl-pod", + "spl-pod 0.5.1", "spl-token-confidential-transfer-proof-extraction", ] +[[package]] +name = "spl-memo" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0dba2f2bb6419523405d21c301a32c9f9568354d4742552e7972af801f4bdb3" +dependencies = [ + "solana-program 2.3.0", +] + [[package]] name = "spl-memo" version = "6.0.0" @@ -2712,6 +3447,20 @@ dependencies = [ "solana-pubkey 2.4.0", ] +[[package]] +name = "spl-pod" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c704c88fc457fa649ba3aabe195c79d885c3f26709efaddc453c8de352c90b87" +dependencies = [ + "borsh 1.6.0", + "bytemuck", + "bytemuck_derive", + "solana-program 2.3.0", + "solana-zk-token-sdk", + "spl-program-error 0.5.0", +] + [[package]] name = "spl-pod" version = "0.5.1" @@ -2726,12 +3475,25 @@ dependencies = [ "solana-decode-error", "solana-msg 2.2.1", "solana-program-error 2.2.2", - "solana-program-option", + "solana-program-option 2.2.1", "solana-pubkey 2.4.0", "solana-zk-sdk", "thiserror 2.0.18", ] +[[package]] +name = "spl-program-error" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b28bed65356558133751cc32b48a7a5ddfc59ac4e941314630bbed1ac10532" +dependencies = [ + "num-derive", + "num-traits", + "solana-program 2.3.0", + "spl-program-error-derive 0.4.1", + "thiserror 1.0.69", +] + [[package]] name = "spl-program-error" version = "0.7.0" @@ -2743,10 +3505,22 @@ dependencies = [ "solana-decode-error", "solana-msg 2.2.1", "solana-program-error 2.2.2", - "spl-program-error-derive", + "spl-program-error-derive 0.5.0", "thiserror 2.0.18", ] +[[package]] +name = "spl-program-error-derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d375dd76c517836353e093c2dbb490938ff72821ab568b545fd30ab3256b3e" +dependencies = [ + "proc-macro2", + "quote", + "sha2 0.10.9", + "syn 2.0.114", +] + [[package]] name = "spl-program-error-derive" version = "0.5.0" @@ -2774,15 +3548,29 @@ dependencies = [ "num_enum", "serde", "serde_derive", - "solana-program", + "solana-program 2.3.0", "solana-security-txt", "solana-stake-interface 1.2.1", "solana-system-interface 1.0.0", - "spl-pod", - "spl-token-2022", + "spl-pod 0.5.1", + "spl-token-2022 9.0.0", "thiserror 2.0.18", ] +[[package]] +name = "spl-tlv-account-resolution" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37a75a5f0fcc58126693ed78a17042e9dc53f07e357d6be91789f7d62aff61a4" +dependencies = [ + "bytemuck", + "solana-program 2.3.0", + "spl-discriminator 0.3.0", + "spl-pod 0.3.1", + "spl-program-error 0.5.0", + "spl-type-length-value 0.5.0", +] + [[package]] name = "spl-tlv-account-resolution" version = "0.10.0" @@ -2798,13 +3586,28 @@ dependencies = [ "solana-msg 2.2.1", "solana-program-error 2.2.2", "solana-pubkey 2.4.0", - "spl-discriminator", - "spl-pod", - "spl-program-error", - "spl-type-length-value", + "spl-discriminator 0.4.1", + "spl-pod 0.5.1", + "spl-program-error 0.7.0", + "spl-type-length-value 0.8.0", "thiserror 2.0.18", ] +[[package]] +name = "spl-token" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a0f06ac7f23dc0984931b1fe309468f14ea58e32660439c1cef19456f5d0e3" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive", + "num-traits", + "num_enum", + "solana-program 2.3.0", + "thiserror 1.0.69", +] + [[package]] name = "spl-token" version = "8.0.0" @@ -2824,8 +3627,8 @@ dependencies = [ "solana-program-entrypoint 2.3.0", "solana-program-error 2.2.2", "solana-program-memory 2.3.1", - "solana-program-option", - "solana-program-pack", + "solana-program-option 2.2.1", + "solana-program-pack 2.2.1", "solana-pubkey 2.4.0", "solana-rent 2.2.1", "solana-sdk-ids 2.2.1", @@ -2833,6 +3636,30 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "spl-token-2022" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33afcf7a47274725990c783a6e86330883cae26655ad6e2d910f765e530d2cef" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive", + "num-traits", + "num_enum", + "solana-program 2.3.0", + "solana-security-txt", + "solana-zk-token-sdk", + "spl-memo 5.0.0", + "spl-pod 0.3.1", + "spl-token 6.0.0", + "spl-token-group-interface 0.3.0", + "spl-token-metadata-interface 0.4.0", + "spl-transfer-hook-interface 0.7.0", + "spl-type-length-value 0.5.0", + "thiserror 1.0.69", +] + [[package]] name = "spl-token-2022" version = "9.0.0" @@ -2850,12 +3677,12 @@ dependencies = [ "solana-decode-error", "solana-instruction 2.3.3", "solana-msg 2.2.1", - "solana-native-token", + "solana-native-token 2.3.0", "solana-program-entrypoint 2.3.0", "solana-program-error 2.2.2", "solana-program-memory 2.3.1", - "solana-program-option", - "solana-program-pack", + "solana-program-option 2.2.1", + "solana-program-pack 2.2.1", "solana-pubkey 2.4.0", "solana-rent 2.2.1", "solana-sdk-ids 2.2.1", @@ -2864,16 +3691,16 @@ dependencies = [ "solana-sysvar 2.3.0", "solana-zk-sdk", "spl-elgamal-registry", - "spl-memo", - "spl-pod", - "spl-token", + "spl-memo 6.0.0", + "spl-pod 0.5.1", + "spl-token 8.0.0", "spl-token-confidential-transfer-ciphertext-arithmetic", "spl-token-confidential-transfer-proof-extraction", "spl-token-confidential-transfer-proof-generation", - "spl-token-group-interface", - "spl-token-metadata-interface", - "spl-transfer-hook-interface", - "spl-type-length-value", + "spl-token-group-interface 0.6.0", + "spl-token-metadata-interface 0.7.0", + "spl-transfer-hook-interface 0.10.0", + "spl-type-length-value 0.8.0", "thiserror 2.0.18", ] @@ -2899,13 +3726,13 @@ dependencies = [ "solana-account-info 2.3.0", "solana-curve25519", "solana-instruction 2.3.3", - "solana-instructions-sysvar", + "solana-instructions-sysvar 2.2.2", "solana-msg 2.2.1", "solana-program-error 2.2.2", "solana-pubkey 2.4.0", "solana-sdk-ids 2.2.1", "solana-zk-sdk", - "spl-pod", + "spl-pod 0.5.1", "thiserror 2.0.18", ] @@ -2920,6 +3747,19 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "spl-token-group-interface" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8752b85a5ecc1d9f3a43bce3dd9a6a053673aacf5deb513d1cbb88d3534ffd" +dependencies = [ + "bytemuck", + "solana-program 2.3.0", + "spl-discriminator 0.3.0", + "spl-pod 0.3.1", + "spl-program-error 0.5.0", +] + [[package]] name = "spl-token-group-interface" version = "0.6.0" @@ -2934,11 +3774,25 @@ dependencies = [ "solana-msg 2.2.1", "solana-program-error 2.2.2", "solana-pubkey 2.4.0", - "spl-discriminator", - "spl-pod", + "spl-discriminator 0.4.1", + "spl-pod 0.5.1", "thiserror 2.0.18", ] +[[package]] +name = "spl-token-metadata-interface" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6c2318ddff97e006ed9b1291ebec0750a78547f870f62a69c56fe3b46a5d8fc" +dependencies = [ + "borsh 1.6.0", + "solana-program 2.3.0", + "spl-discriminator 0.3.0", + "spl-pod 0.3.1", + "spl-program-error 0.5.0", + "spl-type-length-value 0.5.0", +] + [[package]] name = "spl-token-metadata-interface" version = "0.7.0" @@ -2954,12 +3808,28 @@ dependencies = [ "solana-msg 2.2.1", "solana-program-error 2.2.2", "solana-pubkey 2.4.0", - "spl-discriminator", - "spl-pod", - "spl-type-length-value", + "spl-discriminator 0.4.1", + "spl-pod 0.5.1", + "spl-type-length-value 0.8.0", "thiserror 2.0.18", ] +[[package]] +name = "spl-transfer-hook-interface" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a110f33d941275d9f868b96daaa993f1e73b6806cc8836e43075b4d3ad8338a7" +dependencies = [ + "arrayref", + "bytemuck", + "solana-program 2.3.0", + "spl-discriminator 0.3.0", + "spl-pod 0.3.1", + "spl-program-error 0.5.0", + "spl-tlv-account-resolution 0.7.0", + "spl-type-length-value 0.5.0", +] + [[package]] name = "spl-transfer-hook-interface" version = "0.10.0" @@ -2977,14 +3847,27 @@ dependencies = [ "solana-msg 2.2.1", "solana-program-error 2.2.2", "solana-pubkey 2.4.0", - "spl-discriminator", - "spl-pod", - "spl-program-error", - "spl-tlv-account-resolution", - "spl-type-length-value", + "spl-discriminator 0.4.1", + "spl-pod 0.5.1", + "spl-program-error 0.7.0", + "spl-tlv-account-resolution 0.10.0", + "spl-type-length-value 0.8.0", "thiserror 2.0.18", ] +[[package]] +name = "spl-type-length-value" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdcd73ec187bc409464c60759232e309f83b52a18a9c5610bf281c9c6432918c" +dependencies = [ + "bytemuck", + "solana-program 2.3.0", + "spl-discriminator 0.3.0", + "spl-pod 0.3.1", + "spl-program-error 0.5.0", +] + [[package]] name = "spl-type-length-value" version = "0.8.0" @@ -2998,8 +3881,8 @@ dependencies = [ "solana-decode-error", "solana-msg 2.2.1", "solana-program-error 2.2.2", - "spl-discriminator", - "spl-pod", + "spl-discriminator 0.4.1", + "spl-pod 0.5.1", "thiserror 2.0.18", ] @@ -3287,14 +4170,21 @@ dependencies = [ "serde", "serde-wasm-bindgen", "serde_json", + "solana-address 1.1.0", "solana-compute-budget-interface", - "solana-keypair", + "solana-keypair 2.2.3", + "solana-message 3.0.1", "solana-pubkey 2.4.0", + "solana-sdk", + "solana-signature 3.1.0", "solana-signer 2.2.1", "solana-stake-interface 2.0.2", - "solana-system-interface 2.0.0", + "solana-system-interface 3.0.0", "solana-transaction", + "spl-associated-token-account", + "spl-memo 5.0.0", "spl-stake-pool", + "spl-token 6.0.0", "wasm-bindgen", "wasm-bindgen-test", ] diff --git a/packages/wasm-solana/Cargo.toml b/packages/wasm-solana/Cargo.toml index f9421723..c1a0b4ea 100644 --- a/packages/wasm-solana/Cargo.toml +++ b/packages/wasm-solana/Cargo.toml @@ -17,21 +17,30 @@ wasm-bindgen = "0.2" js-sys = "0.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -# Solana SDK crates +# Solana 3.x crates (for transaction building) +solana-sdk = { version = "3.0", default-features = false, features = ["full"] } +solana-transaction = { version = "3.0", features = ["serde", "bincode"] } +solana-message = { version = "3.0", features = ["serde", "bincode"] } +solana-system-interface = { version = "3.0", features = ["bincode"] } +solana-compute-budget-interface = { version = "3.0", features = ["borsh"] } +# Solana 2.x crates (no 3.x available yet for these) +solana-stake-interface = { version = "2.0", features = ["bincode"] } solana-pubkey = { version = "2.0", features = ["curve25519"] } solana-keypair = "2.0" solana-signer = "2.0" -solana-transaction = { version = "3.0", features = ["serde", "bincode"] } -# Instruction decoder interfaces (official Solana crates) -solana-system-interface = { version = "2.0", features = ["bincode"] } -solana-stake-interface = { version = "2.0", features = ["bincode"] } -solana-compute-budget-interface = { version = "2.0", features = ["borsh"] } +solana-signature = "3.0" +solana-address = "1.0" # Serialization bincode = "1.3" borsh = "1.5" base64 = "0.22" +hex = "0.4" serde-wasm-bindgen = "0.6" +# SPL crates for token/ATA operations spl-stake-pool = { version = "2.0.3", features = ["no-entrypoint"] } +spl-token = { version = "6.0", features = ["no-entrypoint"] } +spl-associated-token-account = { version = "4.0", features = ["no-entrypoint"] } +spl-memo = { version = "5.0", features = ["no-entrypoint"] } [dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/packages/wasm-solana/js/builder.ts b/packages/wasm-solana/js/builder.ts new file mode 100644 index 00000000..adc61d79 --- /dev/null +++ b/packages/wasm-solana/js/builder.ts @@ -0,0 +1,592 @@ +/** + * Transaction building from high-level intents. + * + * Provides types and functions for building Solana transactions from a + * declarative intent structure, without requiring the full @solana/web3.js dependency. + */ + +import { BuilderNamespace } from "./wasm/wasm_solana.js"; + +// ============================================================================= +// Nonce Types +// ============================================================================= + +/** Use a recent blockhash for the transaction */ +export interface BlockhashNonceSource { + type: "blockhash"; + /** The recent blockhash value (base58) */ + value: string; +} + +/** Use a durable nonce account for the transaction */ +export interface DurableNonceSource { + type: "durable"; + /** The nonce account address (base58) */ + address: string; + /** The nonce authority address (base58) */ + authority: string; + /** The nonce value stored in the account (base58) - this becomes the blockhash */ + value: string; +} + +/** Nonce source for the transaction */ +export type NonceSource = BlockhashNonceSource | DurableNonceSource; + +// ============================================================================= +// Address Lookup Table Types (Versioned Transactions) +// ============================================================================= + +/** + * Address Lookup Table data for versioned transactions. + * + * ALTs allow transactions to reference more accounts than the legacy format + * by storing account addresses in on-chain lookup tables. + */ +export interface AddressLookupTable { + /** The lookup table account address (base58) */ + accountKey: string; + /** Indices of writable accounts in the lookup table */ + writableIndexes: number[]; + /** Indices of readonly accounts in the lookup table */ + readonlyIndexes: number[]; +} + +// ============================================================================= +// Instruction Types +// ============================================================================= + +/** SOL transfer instruction */ +export interface TransferInstruction { + type: "transfer"; + /** Source account (base58) */ + from: string; + /** Destination account (base58) */ + to: string; + /** Amount in lamports */ + lamports: bigint; +} + +/** Create new account instruction */ +export interface CreateAccountInstruction { + type: "createAccount"; + /** Funding account (base58) */ + from: string; + /** New account address (base58) */ + newAccount: string; + /** Lamports to transfer */ + lamports: bigint; + /** Space to allocate in bytes */ + space: number; + /** Owner program (base58) */ + owner: string; +} + +/** Advance durable nonce instruction */ +export interface NonceAdvanceInstruction { + type: "nonceAdvance"; + /** Nonce account address (base58) */ + nonce: string; + /** Nonce authority (base58) */ + authority: string; +} + +/** Initialize nonce account instruction */ +export interface NonceInitializeInstruction { + type: "nonceInitialize"; + /** Nonce account address (base58) */ + nonce: string; + /** Nonce authority (base58) */ + authority: string; +} + +/** Allocate space instruction */ +export interface AllocateInstruction { + type: "allocate"; + /** Account to allocate (base58) */ + account: string; + /** Space to allocate in bytes */ + space: number; +} + +/** Assign account to program instruction */ +export interface AssignInstruction { + type: "assign"; + /** Account to assign (base58) */ + account: string; + /** New owner program (base58) */ + owner: string; +} + +/** Memo instruction */ +export interface MemoInstruction { + type: "memo"; + /** The memo message */ + message: string; +} + +/** Compute budget instruction */ +export interface ComputeBudgetInstruction { + type: "computeBudget"; + /** Compute unit limit (optional) */ + unitLimit?: number; + /** Compute unit price in micro-lamports (optional) */ + unitPrice?: number; +} + +// ============================================================================= +// Stake Program Instructions +// ============================================================================= + +/** Initialize a stake account instruction */ +export interface StakeInitializeInstruction { + type: "stakeInitialize"; + /** Stake account address (base58) */ + stake: string; + /** Authorized staker pubkey (base58) */ + staker: string; + /** Authorized withdrawer pubkey (base58) */ + withdrawer: string; +} + +/** Delegate stake to a validator instruction */ +export interface StakeDelegateInstruction { + type: "stakeDelegate"; + /** Stake account address (base58) */ + stake: string; + /** Vote account (validator) to delegate to (base58) */ + vote: string; + /** Stake authority (base58) */ + authority: string; +} + +/** Deactivate a stake account instruction */ +export interface StakeDeactivateInstruction { + type: "stakeDeactivate"; + /** Stake account address (base58) */ + stake: string; + /** Stake authority (base58) */ + authority: string; +} + +/** Withdraw from a stake account instruction */ +export interface StakeWithdrawInstruction { + type: "stakeWithdraw"; + /** Stake account address (base58) */ + stake: string; + /** Recipient address (base58) */ + recipient: string; + /** Amount in lamports to withdraw */ + lamports: bigint; + /** Withdraw authority (base58) */ + authority: string; +} + +/** Change stake account authorization instruction */ +export interface StakeAuthorizeInstruction { + type: "stakeAuthorize"; + /** Stake account address (base58) */ + stake: string; + /** New authority pubkey (base58) */ + newAuthority: string; + /** Authorization type: "staker" or "withdrawer" */ + authorizeType: "staker" | "withdrawer"; + /** Current authority (base58) */ + authority: string; +} + +/** Split stake account instruction (for partial deactivation) */ +export interface StakeSplitInstruction { + type: "stakeSplit"; + /** Source stake account address (base58) */ + stake: string; + /** Destination stake account (must be uninitialized/created first) (base58) */ + splitStake: string; + /** Stake authority (base58) */ + authority: string; + /** Amount in lamports to split */ + lamports: bigint; +} + +// ============================================================================= +// SPL Token Instructions +// ============================================================================= + +/** Transfer tokens instruction (uses TransferChecked) */ +export interface TokenTransferInstruction { + type: "tokenTransfer"; + /** Source token account (base58) */ + source: string; + /** Destination token account (base58) */ + destination: string; + /** Token mint address (base58) */ + mint: string; + /** Amount of tokens (in smallest units) */ + amount: bigint; + /** Number of decimals for the token */ + decimals: number; + /** Owner/authority of the source account (base58) */ + authority: string; + /** Token program ID (optional, defaults to SPL Token) */ + programId?: string; +} + +/** Create an Associated Token Account instruction */ +export interface CreateAssociatedTokenAccountInstruction { + type: "createAssociatedTokenAccount"; + /** Payer for account creation (base58) */ + payer: string; + /** Owner of the new ATA (base58) */ + owner: string; + /** Token mint address (base58) */ + mint: string; + /** Token program ID (optional, defaults to SPL Token) */ + tokenProgramId?: string; +} + +/** Close an Associated Token Account instruction */ +export interface CloseAssociatedTokenAccountInstruction { + type: "closeAssociatedTokenAccount"; + /** Token account to close (base58) */ + account: string; + /** Destination for remaining lamports (base58) */ + destination: string; + /** Authority of the account (base58) */ + authority: string; + /** Token program ID (optional, defaults to SPL Token) */ + programId?: string; +} + +/** Mint tokens to an account instruction */ +export interface MintToInstruction { + type: "mintTo"; + /** Token mint address (base58) */ + mint: string; + /** Destination token account (base58) */ + destination: string; + /** Mint authority (base58) */ + authority: string; + /** Amount of tokens to mint (in smallest units) */ + amount: bigint; + /** Token program ID (optional, defaults to SPL Token) */ + programId?: string; +} + +/** Burn tokens from an account instruction */ +export interface BurnInstruction { + type: "burn"; + /** Token mint address (base58) */ + mint: string; + /** Source token account to burn from (base58) */ + account: string; + /** Token account authority (base58) */ + authority: string; + /** Amount of tokens to burn (in smallest units) */ + amount: bigint; + /** Token program ID (optional, defaults to SPL Token) */ + programId?: string; +} + +/** Approve a delegate to transfer tokens instruction */ +export interface ApproveInstruction { + type: "approve"; + /** Token account to approve delegation for (base58) */ + account: string; + /** Delegate address (who can transfer) (base58) */ + delegate: string; + /** Token account owner (base58) */ + owner: string; + /** Amount of tokens to approve (in smallest units) */ + amount: bigint; + /** Token program ID (optional, defaults to SPL Token) */ + programId?: string; +} + +// ============================================================================= +// Jito Stake Pool Instructions +// ============================================================================= + +/** Deposit SOL into a stake pool (Jito liquid staking) */ +export interface StakePoolDepositSolInstruction { + type: "stakePoolDepositSol"; + /** Stake pool address (base58) */ + stakePool: string; + /** Withdraw authority PDA (base58) */ + withdrawAuthority: string; + /** Reserve stake account (base58) */ + reserveStake: string; + /** Funding account (SOL source, signer) (base58) */ + fundingAccount: string; + /** Destination for pool tokens (base58) */ + destinationPoolAccount: string; + /** Manager fee account (base58) */ + managerFeeAccount: string; + /** Referral pool account (base58) */ + referralPoolAccount: string; + /** Pool mint address (base58) */ + poolMint: string; + /** Amount in lamports to deposit */ + lamports: bigint; +} + +/** Withdraw stake from a stake pool (Jito liquid staking) */ +export interface StakePoolWithdrawStakeInstruction { + type: "stakePoolWithdrawStake"; + /** Stake pool address (base58) */ + stakePool: string; + /** Validator list account (base58) */ + validatorList: string; + /** Withdraw authority PDA (base58) */ + withdrawAuthority: string; + /** Validator stake account to split from (base58) */ + validatorStake: string; + /** Destination stake account (uninitialized) (base58) */ + destinationStake: string; + /** Authority for the destination stake account (base58) */ + destinationStakeAuthority: string; + /** Source pool token account authority (signer) (base58) */ + sourceTransferAuthority: string; + /** Source pool token account (base58) */ + sourcePoolAccount: string; + /** Manager fee account (base58) */ + managerFeeAccount: string; + /** Pool mint address (base58) */ + poolMint: string; + /** Amount of pool tokens to burn */ + poolTokens: bigint; +} + +// ============================================================================= +// Custom Instruction +// ============================================================================= + +/** Account metadata for custom instructions */ +export interface CustomAccountMeta { + /** Account public key (base58) */ + pubkey: string; + /** Whether the account is a signer */ + isSigner: boolean; + /** Whether the account is writable */ + isWritable: boolean; +} + +/** + * Custom instruction for invoking any program. + * Enables passthrough of arbitrary instructions for extensibility. + */ +export interface CustomInstruction { + type: "custom"; + /** The program ID to invoke (base58) */ + programId: string; + /** Account metas for the instruction */ + accounts: CustomAccountMeta[]; + /** Instruction data (base64 or hex encoded) */ + data: string; + /** Encoding of the data field: "base64" (default) or "hex" */ + encoding?: "base64" | "hex"; +} + +/** Union of all instruction types */ +export type Instruction = + | TransferInstruction + | CreateAccountInstruction + | NonceAdvanceInstruction + | NonceInitializeInstruction + | AllocateInstruction + | AssignInstruction + | MemoInstruction + | ComputeBudgetInstruction + | StakeInitializeInstruction + | StakeDelegateInstruction + | StakeDeactivateInstruction + | StakeWithdrawInstruction + | StakeAuthorizeInstruction + | StakeSplitInstruction + | TokenTransferInstruction + | CreateAssociatedTokenAccountInstruction + | CloseAssociatedTokenAccountInstruction + | MintToInstruction + | BurnInstruction + | ApproveInstruction + | StakePoolDepositSolInstruction + | StakePoolWithdrawStakeInstruction + | CustomInstruction; + +// ============================================================================= +// TransactionIntent +// ============================================================================= + +/** + * A declarative intent to build a Solana transaction. + * + * @example + * ```typescript + * const intent: TransactionIntent = { + * feePayer: 'DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB', + * nonce: { + * type: 'blockhash', + * value: 'GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4' + * }, + * instructions: [ + * { type: 'transfer', from: '...', to: '...', lamports: '1000000' } + * ] + * }; + * ``` + */ +export interface TransactionIntent { + /** The fee payer's public key (base58) */ + feePayer: string; + /** The nonce source (blockhash or durable nonce) */ + nonce: NonceSource; + /** List of instructions to include */ + instructions: Instruction[]; + + // ===== Versioned Transaction Fields (MessageV0) ===== + // If addressLookupTables is provided, a versioned transaction is built. + + /** + * Address Lookup Tables for versioned transactions. + * If provided, builds a MessageV0 transaction instead of legacy. + */ + addressLookupTables?: AddressLookupTable[]; + + /** + * Static account keys (for versioned transaction round-trip). + * These are the accounts stored directly in the message. + */ + staticAccountKeys?: string[]; +} + +// ============================================================================= +// buildTransaction function +// ============================================================================= + +/** + * Build a Solana transaction from a high-level intent. + * + * This function takes a declarative TransactionIntent and produces serialized + * transaction bytes that can be signed and submitted to the network. + * + * The returned transaction is unsigned - signatures should be added before + * broadcasting. + * + * @param intent - The transaction intent describing what to build + * @returns Serialized unsigned transaction bytes (Uint8Array) + * @throws Error if the intent cannot be built (e.g., invalid addresses) + * + * @example + * ```typescript + * import { buildTransaction } from '@bitgo/wasm-solana'; + * + * // Build a simple SOL transfer + * const txBytes = buildTransaction({ + * feePayer: sender, + * nonce: { type: 'blockhash', value: blockhash }, + * instructions: [ + * { type: 'transfer', from: sender, to: recipient, lamports: '1000000' } + * ] + * }); + * + * // The returned bytes can be signed and broadcast + * ``` + * + * @example + * ```typescript + * // Build with durable nonce and priority fee + * const txBytes = buildTransaction({ + * feePayer: sender, + * nonce: { type: 'durable', address: nonceAccount, authority: sender, value: nonceValue }, + * instructions: [ + * { type: 'computeBudget', unitLimit: 200000, unitPrice: 5000 }, + * { type: 'transfer', from: sender, to: recipient, lamports: '1000000' }, + * { type: 'memo', message: 'BitGo transfer' } + * ] + * }); + * ``` + */ +export function buildTransaction(intent: TransactionIntent): Uint8Array { + return BuilderNamespace.build_transaction(intent); +} + +// ============================================================================= +// Raw Versioned Transaction Data Types (for fromVersionedTransactionData path) +// ============================================================================= + +/** + * A pre-compiled versioned instruction (uses indexes, not pubkeys). + * This is the format used in MessageV0 transactions. + */ +export interface VersionedInstruction { + /** Index into the account keys array for the program ID */ + programIdIndex: number; + /** Indexes into the account keys array for instruction accounts */ + accountKeyIndexes: number[]; + /** Instruction data (base58 encoded) */ + data: string; +} + +/** + * Message header for versioned transactions. + * Describes the structure of the account keys array. + */ +export interface MessageHeader { + /** Number of required signatures */ + numRequiredSignatures: number; + /** Number of readonly signed accounts */ + numReadonlySignedAccounts: number; + /** Number of readonly unsigned accounts */ + numReadonlyUnsignedAccounts: number; +} + +/** + * Raw versioned transaction data for direct serialization. + * This is used when we have pre-formed MessageV0 data that just needs to be serialized. + * No instruction compilation is needed - just serialize the raw structure. + */ +export interface RawVersionedTransactionData { + /** Static account keys (base58 encoded public keys) */ + staticAccountKeys: string[]; + /** Address lookup tables */ + addressLookupTables: AddressLookupTable[]; + /** Pre-compiled instructions with index-based account references */ + versionedInstructions: VersionedInstruction[]; + /** Message header */ + messageHeader: MessageHeader; + /** Recent blockhash (base58) */ + recentBlockhash: string; +} + +/** + * Build a versioned transaction directly from raw MessageV0 data. + * + * This function is used for the `fromVersionedTransactionData()` path where we already + * have pre-compiled versioned data (indexes + ALT refs). No instruction compilation + * is needed - we just serialize the raw structure to bytes. + * + * @param data - Raw versioned transaction data + * @returns Serialized unsigned versioned transaction bytes (Uint8Array) + * @throws Error if the data is invalid + * + * @example + * ```typescript + * import { buildFromVersionedData } from '@bitgo/wasm-solana'; + * + * const txBytes = buildFromVersionedData({ + * staticAccountKeys: ['pubkey1', 'pubkey2', ...], + * addressLookupTables: [ + * { accountKey: 'altPubkey', writableIndexes: [0, 1], readonlyIndexes: [2] } + * ], + * versionedInstructions: [ + * { programIdIndex: 0, accountKeyIndexes: [1, 2], data: 'base58EncodedData' } + * ], + * messageHeader: { + * numRequiredSignatures: 1, + * numReadonlySignedAccounts: 0, + * numReadonlyUnsignedAccounts: 3 + * }, + * recentBlockhash: 'blockhash' + * }); + * ``` + */ +export function buildFromVersionedData(data: RawVersionedTransactionData): Uint8Array { + return BuilderNamespace.build_from_versioned_data(data); +} diff --git a/packages/wasm-solana/js/index.ts b/packages/wasm-solana/js/index.ts index 5b2e8e21..835f88ff 100644 --- a/packages/wasm-solana/js/index.ts +++ b/packages/wasm-solana/js/index.ts @@ -8,14 +8,39 @@ export * as keypair from "./keypair.js"; export * as pubkey from "./pubkey.js"; export * as transaction from "./transaction.js"; export * as parser from "./parser.js"; +export * as builder from "./builder.js"; // Top-level class exports for convenience export { Keypair } from "./keypair.js"; export { Pubkey } from "./pubkey.js"; export { Transaction } from "./transaction.js"; +// Versioned transaction support +export { VersionedTransaction, isVersionedTransaction } from "./versioned.js"; +export type { AddressLookupTableData } from "./versioned.js"; + // Top-level function exports export { parseTransaction } from "./parser.js"; +export { buildTransaction, buildFromVersionedData } from "./builder.js"; + +// Program ID constants (from WASM) +export { + system_program_id as systemProgramId, + stake_program_id as stakeProgramId, + compute_budget_program_id as computeBudgetProgramId, + memo_program_id as memoProgramId, + token_program_id as tokenProgramId, + token_2022_program_id as token2022ProgramId, + ata_program_id as ataProgramId, + stake_pool_program_id as stakePoolProgramId, + stake_account_space as stakeAccountSpace, + nonce_account_space as nonceAccountSpace, + // Sysvar addresses + sysvar_recent_blockhashes as sysvarRecentBlockhashes, + // PDA derivation functions (eliminates @solana/web3.js dependency) + get_associated_token_address as getAssociatedTokenAddress, + find_withdraw_authority_program_address as findWithdrawAuthorityProgramAddress, +} from "./wasm/wasm_solana.js"; // Type exports export type { AccountMeta, Instruction } from "./transaction.js"; @@ -44,3 +69,45 @@ export type { StakePoolWithdrawStakeParams, UnknownInstructionParams, } from "./parser.js"; + +// Builder type exports (prefixed to avoid conflict with parser/transaction types) +export type { + TransactionIntent, + NonceSource, + BlockhashNonceSource, + DurableNonceSource, + AddressLookupTable as BuilderAddressLookupTable, + Instruction as BuilderInstruction, + TransferInstruction, + CreateAccountInstruction, + NonceAdvanceInstruction, + NonceInitializeInstruction, + AllocateInstruction, + AssignInstruction, + MemoInstruction, + ComputeBudgetInstruction, + // Stake Program + StakeInitializeInstruction, + StakeDelegateInstruction, + StakeDeactivateInstruction, + StakeWithdrawInstruction, + StakeAuthorizeInstruction, + StakeSplitInstruction, + // SPL Token + TokenTransferInstruction, + CreateAssociatedTokenAccountInstruction, + CloseAssociatedTokenAccountInstruction, + MintToInstruction, + BurnInstruction, + ApproveInstruction, + // Jito Stake Pool + StakePoolDepositSolInstruction, + StakePoolWithdrawStakeInstruction, + // Custom Instruction + CustomInstruction as BuilderCustomInstruction, + CustomAccountMeta, + // Raw Versioned Transaction Data (for fromVersionedTransactionData path) + RawVersionedTransactionData, + VersionedInstruction as BuilderVersionedInstruction, + MessageHeader, +} from "./builder.js"; diff --git a/packages/wasm-solana/js/parser.ts b/packages/wasm-solana/js/parser.ts index 4f9a4e46..f7e018b9 100644 --- a/packages/wasm-solana/js/parser.ts +++ b/packages/wasm-solana/js/parser.ts @@ -105,6 +105,14 @@ export interface StakingAuthorizeParams { custodianAddress?: string; } +/** Stake initialize parameters (intermediate type) */ +export interface StakeInitializeParams { + type: "StakeInitialize"; + stakingAddress: string; + staker: string; + withdrawer: string; +} + /** Set compute unit limit parameters */ export interface SetComputeUnitLimitParams { type: "SetComputeUnitLimit"; diff --git a/packages/wasm-solana/js/transaction.ts b/packages/wasm-solana/js/transaction.ts index a29baf88..2c8be113 100644 --- a/packages/wasm-solana/js/transaction.ts +++ b/packages/wasm-solana/js/transaction.ts @@ -88,6 +88,16 @@ export class Transaction { return this._wasm.signable_payload(); } + /** + * Serialize the message portion of the transaction. + * Alias for signablePayload() - provides compatibility with @solana/web3.js API. + * Returns a Buffer for compatibility with code expecting .toString('base64'). + * @returns The serialized message bytes as a Buffer + */ + serializeMessage(): Buffer { + return Buffer.from(this.signablePayload()); + } + /** * Serialize the transaction to bytes * @returns The serialized transaction bytes @@ -106,22 +116,82 @@ export class Transaction { } /** - * Get all signatures as byte arrays + * Get all signatures as byte arrays. + * Provides compatibility with @solana/web3.js Transaction.signatures API. * @returns Array of signature byte arrays */ - signatures(): Uint8Array[] { + get signatures(): Uint8Array[] { return Array.from(this._wasm.signatures()) as Uint8Array[]; } /** - * Get all instructions in the transaction - * @returns Array of instructions with programId, accounts, and data + * Get all signatures as byte arrays (method form). + * Alias for the `signatures` property getter. + * @returns Array of signature byte arrays */ - instructions(): Instruction[] { + getSignatures(): Uint8Array[] { + return this.signatures; + } + + /** + * Get all instructions in the transaction. + * Returns an array with programId, accounts, and data for each instruction. + * + * Note: This is a getter property to provide compatibility with code + * expecting @solana/web3.js Transaction.instructions API. If you need + * to call this as a method, use `getInstructions()` instead. + */ + get instructions(): Instruction[] { const rawInstructions = this._wasm.instructions(); return Array.from(rawInstructions) as Instruction[]; } + /** + * Get all instructions in the transaction (method form). + * Alias for the `instructions` property getter. + * @returns Array of instructions with programId, accounts, and data + */ + getInstructions(): Instruction[] { + return this.instructions; + } + + /** + * Add a signature for a given public key. + * + * The pubkey must be one of the required signers in the transaction. + * The signature must be exactly 64 bytes (Ed25519 signature). + * + * @param pubkey - The public key as a base58 string + * @param signature - The 64-byte signature as Uint8Array + * @throws Error if pubkey is not a signer or signature is invalid + * + * @example + * ```typescript + * // Add a pre-computed signature (e.g., from TSS) + * tx.addSignature(signerPubkey, signatureBytes); + * + * // Serialize and broadcast + * const signedTxBytes = tx.toBytes(); + * ``` + */ + addSignature(pubkey: string, signature: Uint8Array): void { + this._wasm.add_signature(pubkey, signature); + } + + /** + * Get the signer index for a public key. + * + * Returns the index in the signatures array where this pubkey's + * signature should be placed, or null if the pubkey is not a signer. + * + * @param pubkey - The public key as a base58 string + * @returns The signer index, or null if not a signer + */ + signerIndex(pubkey: string): number | null { + const idx = this._wasm.signer_index(pubkey); + return idx ?? null; + } + /** * Get the underlying WASM instance (internal use only) * @internal diff --git a/packages/wasm-solana/js/versioned.ts b/packages/wasm-solana/js/versioned.ts new file mode 100644 index 00000000..8c91b2a3 --- /dev/null +++ b/packages/wasm-solana/js/versioned.ts @@ -0,0 +1,251 @@ +/** + * Versioned Transaction Support + * + * Handles both legacy and versioned (MessageV0) Solana transactions. + * Versioned transactions use Address Lookup Tables (ALTs) to compress + * transaction size by referencing accounts via lookup table indices. + */ + +import { + WasmVersionedTransaction, + is_versioned_transaction, + BuilderNamespace, +} from "./wasm/wasm_solana.js"; +import type { RawVersionedTransactionData } from "./builder.js"; + +/** + * Address Lookup Table data extracted from versioned transactions. + */ +export interface AddressLookupTableData { + /** The lookup table account address (base58) */ + accountKey: string; + /** Indices of writable accounts in the lookup table */ + writableIndexes: Uint8Array; + /** Indices of readonly accounts in the lookup table */ + readonlyIndexes: Uint8Array; +} + +/** + * Account metadata for instructions. + */ +export interface VersionedAccountMeta { + /** Account index within the transaction */ + index: number; + /** Account pubkey (only present for static accounts) */ + pubkey?: string; + /** Whether this account is from an Address Lookup Table */ + isLookupTable: boolean; + /** Whether this account is a signer */ + isSigner: boolean; +} + +/** + * Instruction from a versioned transaction. + */ +export interface VersionedInstruction { + /** Program ID */ + programId: string; + /** Accounts used by the instruction */ + accounts: VersionedAccountMeta[]; + /** Instruction data */ + data: Uint8Array; +} + +/** + * Detect if transaction bytes represent a versioned transaction (MessageV0). + * + * @param bytes - Raw transaction bytes + * @returns true if versioned, false if legacy + */ +export function isVersionedTransaction(bytes: Uint8Array): boolean { + return is_versioned_transaction(bytes); +} + +/** + * Versioned Transaction class. + * + * Handles both legacy and versioned (MessageV0) transactions. + * Provides access to Address Lookup Table data for versioned transactions. + */ +export class VersionedTransaction { + private inner: WasmVersionedTransaction; + + private constructor(inner: WasmVersionedTransaction) { + this.inner = inner; + } + + /** + * Deserialize a transaction from raw bytes. + * Automatically handles both legacy and versioned formats. + */ + static fromBytes(bytes: Uint8Array): VersionedTransaction { + return new VersionedTransaction(WasmVersionedTransaction.from_bytes(bytes)); + } + + /** + * Deserialize a transaction from base64 string. + */ + static fromBase64(base64: string): VersionedTransaction { + const bytes = Uint8Array.from(Buffer.from(base64, "base64")); + return VersionedTransaction.fromBytes(bytes); + } + + /** + * Create a versioned transaction from raw MessageV0 data. + * + * This is used for the `fromVersionedTransactionData()` path where we have + * pre-compiled versioned data (indexes + ALT refs). No instruction compilation + * is needed - this just constructs the transaction from the raw structure. + * + * @param data - Raw versioned transaction data + * @returns A VersionedTransaction instance + * + * @example + * ```typescript + * const tx = VersionedTransaction.fromVersionedData({ + * staticAccountKeys: ['pubkey1', 'pubkey2', ...], + * addressLookupTables: [ + * { accountKey: 'altPubkey', writableIndexes: [0, 1], readonlyIndexes: [2] } + * ], + * versionedInstructions: [ + * { programIdIndex: 0, accountKeyIndexes: [1, 2], data: 'base58EncodedData' } + * ], + * messageHeader: { + * numRequiredSignatures: 1, + * numReadonlySignedAccounts: 0, + * numReadonlyUnsignedAccounts: 3 + * }, + * recentBlockhash: 'blockhash' + * }); + * ``` + */ + static fromVersionedData(data: RawVersionedTransactionData): VersionedTransaction { + // Build the transaction bytes using WASM + const bytes = BuilderNamespace.build_from_versioned_data(data); + // Parse the bytes to create a VersionedTransaction + return VersionedTransaction.fromBytes(bytes); + } + + /** + * Check if this is a versioned transaction (MessageV0). + */ + get isVersioned(): boolean { + return this.inner.is_versioned; + } + + /** + * Get the fee payer address. + */ + get feePayer(): string | undefined { + return this.inner.fee_payer ?? undefined; + } + + /** + * Get the recent blockhash. + */ + get recentBlockhash(): string { + return this.inner.recent_blockhash; + } + + /** + * Get the number of instructions. + */ + get numInstructions(): number { + return this.inner.num_instructions; + } + + /** + * Get the number of signatures. + */ + get numSignatures(): number { + return this.inner.num_signatures; + } + + /** + * Get the signable message payload. + */ + signablePayload(): Uint8Array { + return this.inner.signable_payload(); + } + + /** + * Serialize the message portion of the transaction. + * Alias for signablePayload() - provides compatibility with @solana/web3.js API. + * Returns a Buffer for compatibility with code expecting .toString('base64'). + * @returns The serialized message bytes as a Buffer + */ + serializeMessage(): Buffer { + return Buffer.from(this.signablePayload()); + } + + /** + * Serialize the transaction to bytes. + */ + toBytes(): Uint8Array { + return this.inner.to_bytes(); + } + + /** + * Serialize the transaction to base64. + */ + toBase64(): string { + return Buffer.from(this.toBytes()).toString("base64"); + } + + /** + * Get static account keys (accounts stored directly in the message). + * For versioned transactions, additional accounts may be referenced via ALTs. + */ + staticAccountKeys(): string[] { + return Array.from(this.inner.static_account_keys()) as string[]; + } + + /** + * Get Address Lookup Table data. + * Returns empty array for legacy transactions. + */ + addressLookupTables(): AddressLookupTableData[] { + const alts = this.inner.address_lookup_tables(); + return Array.from(alts).map((alt: AddressLookupTableData) => ({ + accountKey: alt.accountKey, + writableIndexes: alt.writableIndexes, + readonlyIndexes: alt.readonlyIndexes, + })); + } + + /** + * Get all signatures as byte arrays. + * Provides compatibility with @solana/web3.js Transaction.signatures API. + */ + get signatures(): Uint8Array[] { + return Array.from(this.inner.signatures()) as Uint8Array[]; + } + + /** + * Add a signature for a given public key. + * + * @param pubkey - The public key as base58 string + * @param signature - The 64-byte Ed25519 signature + */ + addSignature(pubkey: string, signature: Uint8Array): void { + this.inner.add_signature(pubkey, signature); + } + + /** + * Get the signer index for a public key. + * Returns undefined if the pubkey is not a required signer. + */ + signerIndex(pubkey: string): number | undefined { + return this.inner.signer_index(pubkey) ?? undefined; + } + + /** + * Get all instructions. + * Note: For versioned transactions, account indices may reference + * accounts from Address Lookup Tables. + */ + instructions(): VersionedInstruction[] { + const instructions = this.inner.instructions(); + return Array.from(instructions) as VersionedInstruction[]; + } +} diff --git a/packages/wasm-solana/src/builder/build.rs b/packages/wasm-solana/src/builder/build.rs new file mode 100644 index 00000000..3b1fe680 --- /dev/null +++ b/packages/wasm-solana/src/builder/build.rs @@ -0,0 +1,1588 @@ +//! Transaction building implementation. +//! +//! Uses the Solana SDK for transaction construction and serialization. + +use crate::error::WasmSolanaError; + +use super::types::{Instruction as IntentInstruction, Nonce, TransactionIntent}; + +// Use SDK types for building (3.x ecosystem) +use solana_compute_budget_interface::ComputeBudgetInstruction; +use solana_sdk::hash::Hash; +use solana_sdk::instruction::{AccountMeta, Instruction}; +use solana_sdk::message::Message; +use solana_sdk::pubkey::Pubkey; +use solana_sdk::sysvar::clock as clock_sysvar; +use solana_sdk::transaction::Transaction; +// Use stake instruction helpers from the crate (handles sysvars internally) +use solana_stake_interface::instruction as stake_ix; +use solana_stake_interface::state::{Authorized, Lockup, StakeAuthorize}; +use solana_system_interface::instruction::{self as system_ix, SystemInstruction}; +use spl_stake_pool::instruction::StakePoolInstruction; +// SPL Token instruction encoding - use the crate for data packing to avoid manual byte construction +use spl_token::instruction::TokenInstruction; + +/// Well-known program IDs. +/// +/// Note: Solana ecosystem is split between SDK 2.x (solana_program) and SDK 3.x (solana_sdk): +/// - SDK 3.x compatible crates export IDs we can use directly (e.g., solana_stake_interface::program::ID) +/// - SPL crates (spl-token, spl-memo, spl-associated-token-account) use solana_program (2.x) types +/// which are incompatible with our solana_sdk (3.x) types at compile time. +/// +/// These program IDs are string-parsed because the SPL crates' ID constants return +/// `solana_program::pubkey::Pubkey`, not `solana_sdk::pubkey::Pubkey`. While the bytes are +/// identical, Rust's type system prevents direct usage across the SDK version boundary. +/// +/// The values here match the SPL crate declare_id! macros: +/// - spl_memo: "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" +/// - spl_associated_token_account: "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" +/// - spl_token: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" +/// - spl_stake_pool: "SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy" +mod program_ids { + use super::Pubkey; + + /// SPL Memo Program v2. + /// https://github.com/solana-program/memo/blob/main/interface/src/lib.rs#L15 + pub fn memo_program() -> Pubkey { + "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" + .parse() + .unwrap() + } + + /// Associated Token Account Program. + /// https://github.com/solana-program/associated-token-account/blob/main/interface/src/lib.rs#L10 + pub fn ata_program() -> Pubkey { + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + .parse() + .unwrap() + } + + /// Native System Program. + /// https://docs.solanalabs.com/runtime/programs#system-program + /// Used for ATA creation which requires system program in accounts. + pub fn system_program() -> Pubkey { + "11111111111111111111111111111111".parse().unwrap() + } + + /// SPL Token Program. + /// https://github.com/solana-program/token/blob/main/interface/src/lib.rs#L17 + /// Used for stake pool operations that need token program in accounts. + pub fn token_program() -> Pubkey { + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + .parse() + .unwrap() + } + + /// SPL Stake Pool Program. + /// https://github.com/solana-program/stake-pool/blob/main/program/src/lib.rs#L11 + /// Note: spl_stake_pool::id() exists but returns solana_program::pubkey::Pubkey (2.x types), + /// which is incompatible with solana_sdk::pubkey::Pubkey (3.x types). + pub fn stake_pool_program() -> Pubkey { + "SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy" + .parse() + .unwrap() + } +} + +/// Build a transaction from an intent structure. +/// +/// Returns the serialized unsigned transaction (wire format). +/// +/// # Transaction Types +/// +/// - If `intent.address_lookup_tables` is set, builds a versioned transaction (MessageV0) +/// - Otherwise, builds a legacy transaction +pub fn build_transaction(intent: TransactionIntent) -> Result, WasmSolanaError> { + // Check if this should be a versioned transaction + if super::versioned::should_build_versioned(&intent) { + return build_versioned_transaction(intent); + } + + // Legacy transaction building + build_legacy_transaction(intent) +} + +/// Build a versioned transaction (MessageV0) with Address Lookup Tables. +fn build_versioned_transaction(intent: TransactionIntent) -> Result, WasmSolanaError> { + // Build instructions first (same as legacy) + let mut instructions: Vec = Vec::new(); + + // Handle nonce + if let Nonce::Durable { + address, authority, .. + } = &intent.nonce + { + let nonce_pubkey: Pubkey = address + .parse() + .map_err(|_| WasmSolanaError::new(&format!("Invalid nonce.address: {}", address)))?; + let authority_pubkey: Pubkey = authority.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid nonce.authority: {}", authority)) + })?; + instructions.push(solana_system_interface::instruction::advance_nonce_account( + &nonce_pubkey, + &authority_pubkey, + )); + } + + // Build each instruction + for ix in intent.instructions.clone() { + instructions.push(build_instruction(ix)?); + } + + // Delegate to versioned module + super::versioned::build_versioned_transaction(&intent, instructions) +} + +/// Build a legacy transaction (original format). +fn build_legacy_transaction(intent: TransactionIntent) -> Result, WasmSolanaError> { + // Parse fee payer + let fee_payer: Pubkey = intent + .fee_payer + .parse() + .map_err(|_| WasmSolanaError::new(&format!("Invalid fee_payer: {}", intent.fee_payer)))?; + + // Build all instructions + let mut instructions: Vec = Vec::new(); + + // Handle nonce - either blockhash or durable nonce + let blockhash_str = match &intent.nonce { + Nonce::Blockhash { value } => value.clone(), + Nonce::Durable { + address, + authority, + value, + } => { + // For durable nonce, prepend the nonce advance instruction + let nonce_pubkey: Pubkey = address.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid nonce.address: {}", address)) + })?; + let authority_pubkey: Pubkey = authority.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid nonce.authority: {}", authority)) + })?; + instructions.push(system_ix::advance_nonce_account( + &nonce_pubkey, + &authority_pubkey, + )); + + // The blockhash is the nonce value stored in the nonce account + value.clone() + } + }; + + // Parse blockhash + let blockhash: Hash = blockhash_str + .parse() + .map_err(|_| WasmSolanaError::new(&format!("Invalid blockhash: {}", blockhash_str)))?; + + // Build each instruction + for ix in intent.instructions { + instructions.push(build_instruction(ix)?); + } + + // Create message using SDK (handles account ordering correctly) + let message = Message::new_with_blockhash(&instructions, Some(&fee_payer), &blockhash); + + // Create unsigned transaction + let mut tx = Transaction::new_unsigned(message); + tx.message.recent_blockhash = blockhash; + + // Serialize using bincode (standard Solana serialization) + let tx_bytes = + bincode::serialize(&tx).map_err(|e| WasmSolanaError::new(&format!("Serialize: {}", e)))?; + + Ok(tx_bytes) +} + +/// Build a single instruction from the IntentInstruction enum. +fn build_instruction(ix: IntentInstruction) -> Result { + match ix { + // ===== System Program ===== + IntentInstruction::Transfer { from, to, lamports } => { + let from_pubkey: Pubkey = from + .parse() + .map_err(|_| WasmSolanaError::new(&format!("Invalid transfer.from: {}", from)))?; + let to_pubkey: Pubkey = to + .parse() + .map_err(|_| WasmSolanaError::new(&format!("Invalid transfer.to: {}", to)))?; + Ok(system_ix::transfer(&from_pubkey, &to_pubkey, lamports)) + } + + IntentInstruction::CreateAccount { + from, + new_account, + lamports, + space, + owner, + } => { + let from_pubkey: Pubkey = from.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid createAccount.from: {}", from)) + })?; + let new_pubkey: Pubkey = new_account.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid createAccount.newAccount: {}", + new_account + )) + })?; + let owner_pubkey: Pubkey = owner.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid createAccount.owner: {}", owner)) + })?; + Ok(system_ix::create_account( + &from_pubkey, + &new_pubkey, + lamports, + space, + &owner_pubkey, + )) + } + + IntentInstruction::NonceAdvance { nonce, authority } => { + let nonce_pubkey: Pubkey = nonce.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid nonceAdvance.nonce: {}", nonce)) + })?; + let authority_pubkey: Pubkey = authority.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid nonceAdvance.authority: {}", authority)) + })?; + Ok(system_ix::advance_nonce_account( + &nonce_pubkey, + &authority_pubkey, + )) + } + + IntentInstruction::NonceInitialize { nonce, authority } => { + // Note: In SDK 3.x, nonce initialization is combined with creation. + // This creates an InitializeNonceAccount instruction manually. + let nonce_pubkey: Pubkey = nonce.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid nonceInitialize.nonce: {}", nonce)) + })?; + let authority_pubkey: Pubkey = authority.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid nonceInitialize.authority: {}", authority)) + })?; + Ok(build_nonce_initialize(&nonce_pubkey, &authority_pubkey)) + } + + IntentInstruction::Allocate { account, space } => { + let account_pubkey: Pubkey = account.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid allocate.account: {}", account)) + })?; + Ok(system_ix::allocate(&account_pubkey, space)) + } + + IntentInstruction::Assign { account, owner } => { + let account_pubkey: Pubkey = account.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid assign.account: {}", account)) + })?; + let owner_pubkey: Pubkey = owner + .parse() + .map_err(|_| WasmSolanaError::new(&format!("Invalid assign.owner: {}", owner)))?; + Ok(system_ix::assign(&account_pubkey, &owner_pubkey)) + } + + // ===== Memo Program ===== + IntentInstruction::Memo { message } => Ok(build_memo(&message)), + + // ===== Compute Budget Program ===== + IntentInstruction::ComputeBudget { + unit_limit, + unit_price, + } => { + // Return a single instruction - prefer unit_price if both specified + // Use SDK's ComputeBudgetInstruction 3.x methods (compatible with solana-sdk 3.x) + if let Some(price) = unit_price { + Ok(ComputeBudgetInstruction::set_compute_unit_price(price)) + } else if let Some(limit) = unit_limit { + Ok(ComputeBudgetInstruction::set_compute_unit_limit(limit)) + } else { + Err(WasmSolanaError::new( + "ComputeBudget instruction requires either unitLimit or unitPrice", + )) + } + } + + // ===== Stake Program ===== + IntentInstruction::StakeInitialize { + stake, + staker, + withdrawer, + } => { + let stake_pubkey: Pubkey = stake.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid stakeInitialize.stake: {}", stake)) + })?; + let staker_pubkey: Pubkey = staker.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid stakeInitialize.staker: {}", staker)) + })?; + let withdrawer_pubkey: Pubkey = withdrawer.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid stakeInitialize.withdrawer: {}", + withdrawer + )) + })?; + Ok(build_stake_initialize( + &stake_pubkey, + &Authorized { + staker: staker_pubkey, + withdrawer: withdrawer_pubkey, + }, + )) + } + + IntentInstruction::StakeDelegate { + stake, + vote, + authority, + } => { + let stake_pubkey: Pubkey = stake.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid stakeDelegate.stake: {}", stake)) + })?; + let vote_pubkey: Pubkey = vote.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid stakeDelegate.vote: {}", vote)) + })?; + let authority_pubkey: Pubkey = authority.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid stakeDelegate.authority: {}", authority)) + })?; + Ok(build_stake_delegate( + &stake_pubkey, + &vote_pubkey, + &authority_pubkey, + )) + } + + IntentInstruction::StakeDeactivate { stake, authority } => { + let stake_pubkey: Pubkey = stake.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid stakeDeactivate.stake: {}", stake)) + })?; + let authority_pubkey: Pubkey = authority.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid stakeDeactivate.authority: {}", authority)) + })?; + Ok(build_stake_deactivate(&stake_pubkey, &authority_pubkey)) + } + + IntentInstruction::StakeWithdraw { + stake, + recipient, + lamports, + authority, + } => { + let stake_pubkey: Pubkey = stake.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid stakeWithdraw.stake: {}", stake)) + })?; + let recipient_pubkey: Pubkey = recipient.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid stakeWithdraw.recipient: {}", recipient)) + })?; + let authority_pubkey: Pubkey = authority.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid stakeWithdraw.authority: {}", authority)) + })?; + Ok(build_stake_withdraw( + &stake_pubkey, + &recipient_pubkey, + lamports, + &authority_pubkey, + )) + } + + IntentInstruction::StakeAuthorize { + stake, + new_authority, + authorize_type, + authority, + } => { + let stake_pubkey: Pubkey = stake.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid stakeAuthorize.stake: {}", stake)) + })?; + let new_authority_pubkey: Pubkey = new_authority.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid stakeAuthorize.newAuthority: {}", + new_authority + )) + })?; + let authority_pubkey: Pubkey = authority.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid stakeAuthorize.authority: {}", authority)) + })?; + let stake_authorize = match authorize_type.to_lowercase().as_str() { + "staker" => StakeAuthorize::Staker, + "withdrawer" => StakeAuthorize::Withdrawer, + _ => { + return Err(WasmSolanaError::new(&format!( + "Invalid stakeAuthorize.authorizeType: {} (expected 'staker' or 'withdrawer')", + authorize_type + ))) + } + }; + Ok(build_stake_authorize( + &stake_pubkey, + &authority_pubkey, + &new_authority_pubkey, + stake_authorize, + )) + } + + IntentInstruction::StakeSplit { + stake, + split_stake, + authority, + lamports, + } => { + let stake_pubkey: Pubkey = stake.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid stakeSplit.stake: {}", stake)) + })?; + let split_stake_pubkey: Pubkey = split_stake.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid stakeSplit.splitStake: {}", split_stake)) + })?; + let authority_pubkey: Pubkey = authority.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid stakeSplit.authority: {}", authority)) + })?; + Ok(build_stake_split( + &stake_pubkey, + &split_stake_pubkey, + &authority_pubkey, + lamports, + )) + } + + // ===== SPL Token Program ===== + IntentInstruction::TokenTransfer { + source, + destination, + mint, + amount, + decimals, + authority, + program_id, + } => { + let source_pubkey: Pubkey = source.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid tokenTransfer.source: {}", source)) + })?; + let destination_pubkey: Pubkey = destination.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid tokenTransfer.destination: {}", + destination + )) + })?; + let mint_pubkey: Pubkey = mint.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid tokenTransfer.mint: {}", mint)) + })?; + let authority_pubkey: Pubkey = authority.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid tokenTransfer.authority: {}", authority)) + })?; + let token_program: Pubkey = program_id.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid tokenTransfer.programId: {}", program_id)) + })?; + Ok(build_token_transfer_checked( + &source_pubkey, + &mint_pubkey, + &destination_pubkey, + &authority_pubkey, + amount, + decimals, + &token_program, + )) + } + + IntentInstruction::CreateAssociatedTokenAccount { + payer, + owner, + mint, + token_program_id, + } => { + let payer_pubkey: Pubkey = payer.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid createAta.payer: {}", payer)) + })?; + let owner_pubkey: Pubkey = owner.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid createAta.owner: {}", owner)) + })?; + let mint_pubkey: Pubkey = mint + .parse() + .map_err(|_| WasmSolanaError::new(&format!("Invalid createAta.mint: {}", mint)))?; + let token_program: Pubkey = token_program_id.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid createAta.tokenProgramId: {}", + token_program_id + )) + })?; + Ok(build_create_ata( + &payer_pubkey, + &owner_pubkey, + &mint_pubkey, + &token_program, + )) + } + + IntentInstruction::CloseAssociatedTokenAccount { + account, + destination, + authority, + program_id, + } => { + let account_pubkey: Pubkey = account.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid closeAta.account: {}", account)) + })?; + let destination_pubkey: Pubkey = destination.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid closeAta.destination: {}", destination)) + })?; + let authority_pubkey: Pubkey = authority.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid closeAta.authority: {}", authority)) + })?; + let token_program: Pubkey = program_id.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid closeAta.programId: {}", program_id)) + })?; + Ok(build_close_account( + &account_pubkey, + &destination_pubkey, + &authority_pubkey, + &token_program, + )) + } + + IntentInstruction::MintTo { + mint, + destination, + authority, + amount, + program_id, + } => { + let mint_pubkey: Pubkey = mint + .parse() + .map_err(|_| WasmSolanaError::new(&format!("Invalid mintTo.mint: {}", mint)))?; + let destination_pubkey: Pubkey = destination.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid mintTo.destination: {}", destination)) + })?; + let authority_pubkey: Pubkey = authority.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid mintTo.authority: {}", authority)) + })?; + let token_program: Pubkey = program_id.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid mintTo.programId: {}", program_id)) + })?; + Ok(build_mint_to( + &mint_pubkey, + &destination_pubkey, + &authority_pubkey, + amount, + &token_program, + )) + } + + IntentInstruction::Burn { + mint, + account, + authority, + amount, + program_id, + } => { + let mint_pubkey: Pubkey = mint + .parse() + .map_err(|_| WasmSolanaError::new(&format!("Invalid burn.mint: {}", mint)))?; + let account_pubkey: Pubkey = account + .parse() + .map_err(|_| WasmSolanaError::new(&format!("Invalid burn.account: {}", account)))?; + let authority_pubkey: Pubkey = authority.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid burn.authority: {}", authority)) + })?; + let token_program: Pubkey = program_id.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid burn.programId: {}", program_id)) + })?; + Ok(build_burn( + &account_pubkey, + &mint_pubkey, + &authority_pubkey, + amount, + &token_program, + )) + } + + IntentInstruction::Approve { + account, + delegate, + owner, + amount, + program_id, + } => { + let account_pubkey: Pubkey = account.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid approve.account: {}", account)) + })?; + let delegate_pubkey: Pubkey = delegate.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid approve.delegate: {}", delegate)) + })?; + let owner_pubkey: Pubkey = owner + .parse() + .map_err(|_| WasmSolanaError::new(&format!("Invalid approve.owner: {}", owner)))?; + let token_program: Pubkey = program_id.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid approve.programId: {}", program_id)) + })?; + Ok(build_approve( + &account_pubkey, + &delegate_pubkey, + &owner_pubkey, + amount, + &token_program, + )) + } + + // ===== Jito Stake Pool ===== + IntentInstruction::StakePoolDepositSol { + stake_pool, + withdraw_authority, + reserve_stake, + funding_account, + destination_pool_account, + manager_fee_account, + referral_pool_account, + pool_mint, + lamports, + } => { + let stake_pool_pubkey: Pubkey = stake_pool.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid stakePoolDepositSol.stakePool: {}", + stake_pool + )) + })?; + let withdraw_authority_pubkey: Pubkey = withdraw_authority.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid stakePoolDepositSol.withdrawAuthority: {}", + withdraw_authority + )) + })?; + let reserve_stake_pubkey: Pubkey = reserve_stake.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid stakePoolDepositSol.reserveStake: {}", + reserve_stake + )) + })?; + let funding_account_pubkey: Pubkey = funding_account.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid stakePoolDepositSol.fundingAccount: {}", + funding_account + )) + })?; + let destination_pool_account_pubkey: Pubkey = + destination_pool_account.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid stakePoolDepositSol.destinationPoolAccount: {}", + destination_pool_account + )) + })?; + let manager_fee_account_pubkey: Pubkey = manager_fee_account.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid stakePoolDepositSol.managerFeeAccount: {}", + manager_fee_account + )) + })?; + let referral_pool_account_pubkey: Pubkey = + referral_pool_account.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid stakePoolDepositSol.referralPoolAccount: {}", + referral_pool_account + )) + })?; + let pool_mint_pubkey: Pubkey = pool_mint.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid stakePoolDepositSol.poolMint: {}", + pool_mint + )) + })?; + Ok(build_stake_pool_deposit_sol( + &stake_pool_pubkey, + &withdraw_authority_pubkey, + &reserve_stake_pubkey, + &funding_account_pubkey, + &destination_pool_account_pubkey, + &manager_fee_account_pubkey, + &referral_pool_account_pubkey, + &pool_mint_pubkey, + lamports, + )) + } + + IntentInstruction::StakePoolWithdrawStake { + stake_pool, + validator_list, + withdraw_authority, + validator_stake, + destination_stake, + destination_stake_authority, + source_transfer_authority, + source_pool_account, + manager_fee_account, + pool_mint, + pool_tokens, + } => { + let stake_pool_pubkey: Pubkey = stake_pool.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid stakePoolWithdrawStake.stakePool: {}", + stake_pool + )) + })?; + let validator_list_pubkey: Pubkey = validator_list.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid stakePoolWithdrawStake.validatorList: {}", + validator_list + )) + })?; + let withdraw_authority_pubkey: Pubkey = withdraw_authority.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid stakePoolWithdrawStake.withdrawAuthority: {}", + withdraw_authority + )) + })?; + let validator_stake_pubkey: Pubkey = validator_stake.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid stakePoolWithdrawStake.validatorStake: {}", + validator_stake + )) + })?; + let destination_stake_pubkey: Pubkey = destination_stake.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid stakePoolWithdrawStake.destinationStake: {}", + destination_stake + )) + })?; + let destination_stake_authority_pubkey: Pubkey = + destination_stake_authority.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid stakePoolWithdrawStake.destinationStakeAuthority: {}", + destination_stake_authority + )) + })?; + let source_transfer_authority_pubkey: Pubkey = + source_transfer_authority.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid stakePoolWithdrawStake.sourceTransferAuthority: {}", + source_transfer_authority + )) + })?; + let source_pool_account_pubkey: Pubkey = source_pool_account.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid stakePoolWithdrawStake.sourcePoolAccount: {}", + source_pool_account + )) + })?; + let manager_fee_account_pubkey: Pubkey = manager_fee_account.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid stakePoolWithdrawStake.managerFeeAccount: {}", + manager_fee_account + )) + })?; + let pool_mint_pubkey: Pubkey = pool_mint.parse().map_err(|_| { + WasmSolanaError::new(&format!( + "Invalid stakePoolWithdrawStake.poolMint: {}", + pool_mint + )) + })?; + + Ok(build_stake_pool_withdraw_stake( + &stake_pool_pubkey, + &validator_list_pubkey, + &withdraw_authority_pubkey, + &validator_stake_pubkey, + &destination_stake_pubkey, + &destination_stake_authority_pubkey, + &source_transfer_authority_pubkey, + &source_pool_account_pubkey, + &manager_fee_account_pubkey, + &pool_mint_pubkey, + pool_tokens, + )) + } + + // ===== Custom/Raw Instruction ===== + IntentInstruction::Custom { + program_id, + accounts, + data, + encoding, + } => { + let program_pubkey: Pubkey = program_id.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid custom.programId: {}", program_id)) + })?; + + // Decode the data based on encoding + let data_bytes = match encoding.as_str() { + "hex" => hex::decode(&data).map_err(|e| { + WasmSolanaError::new(&format!("Invalid hex data in custom instruction: {}", e)) + })?, + "base64" | _ => { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(&data) + .map_err(|e| { + WasmSolanaError::new(&format!( + "Invalid base64 data in custom instruction: {}", + e + )) + })? + } + }; + + // Parse account metas + let account_metas: Vec = accounts + .into_iter() + .map(|acc| { + let pubkey: Pubkey = acc.pubkey.parse().map_err(|_| { + WasmSolanaError::new(&format!("Invalid account pubkey: {}", acc.pubkey)) + })?; + Ok(if acc.is_writable { + AccountMeta::new(pubkey, acc.is_signer) + } else { + AccountMeta::new_readonly(pubkey, acc.is_signer) + }) + }) + .collect::, WasmSolanaError>>()?; + + Ok(Instruction::new_with_bytes( + program_pubkey, + &data_bytes, + account_metas, + )) + } + } +} + +// ===== Nonce Instruction Builders ===== + +/// Build an InitializeNonceAccount instruction using the SDK's SystemInstruction enum. +/// SDK 3.x `create_nonce_account` combines create + initialize; we extract just initialize. +fn build_nonce_initialize(nonce: &Pubkey, authority: &Pubkey) -> Instruction { + // System program ID + let system_program_id: Pubkey = "11111111111111111111111111111111".parse().unwrap(); + + // Sysvars (same addresses as used by SDK) + let recent_blockhashes_sysvar: Pubkey = "SysvarRecentB1ockHashes11111111111111111111" + .parse() + .unwrap(); + let rent_sysvar: Pubkey = "SysvarRent111111111111111111111111111111111" + .parse() + .unwrap(); + + // Use SDK's SystemInstruction enum with bincode serialization (same as SDK does) + Instruction::new_with_bincode( + system_program_id, + &SystemInstruction::InitializeNonceAccount(*authority), + vec![ + AccountMeta::new(*nonce, false), // nonce account: writable + AccountMeta::new_readonly(recent_blockhashes_sysvar, false), // RecentBlockhashes sysvar + AccountMeta::new_readonly(rent_sysvar, false), // Rent sysvar + ], + ) +} + +// ===== Other Instruction Builders ===== + +/// Build a memo instruction. +fn build_memo(message: &str) -> Instruction { + Instruction::new_with_bytes(program_ids::memo_program(), message.as_bytes(), vec![]) +} + +// ===== Stake Instruction Builders ===== +// These use solana_stake_interface helpers which handle sysvars internally. + +/// Build a stake initialize instruction. +/// Uses solana_stake_interface::instruction::initialize which handles rent sysvar. +fn build_stake_initialize(stake: &Pubkey, authorized: &Authorized) -> Instruction { + stake_ix::initialize(stake, authorized, &Lockup::default()) +} + +/// Build a stake delegate instruction. +/// Uses solana_stake_interface::instruction::delegate_stake which handles +/// clock, stake_history, and stake_config sysvars internally. +fn build_stake_delegate(stake: &Pubkey, vote: &Pubkey, authority: &Pubkey) -> Instruction { + stake_ix::delegate_stake(stake, authority, vote) +} + +/// Build a stake deactivate instruction. +/// Uses solana_stake_interface::instruction::deactivate_stake which handles clock sysvar. +fn build_stake_deactivate(stake: &Pubkey, authority: &Pubkey) -> Instruction { + stake_ix::deactivate_stake(stake, authority) +} + +/// Build a stake withdraw instruction. +/// Uses solana_stake_interface::instruction::withdraw which handles +/// clock and stake_history sysvars internally. +fn build_stake_withdraw( + stake: &Pubkey, + recipient: &Pubkey, + lamports: u64, + authority: &Pubkey, +) -> Instruction { + stake_ix::withdraw(stake, authority, recipient, lamports, None) +} + +/// Build a stake authorize instruction. +/// Uses solana_stake_interface::instruction::authorize which handles clock sysvar. +fn build_stake_authorize( + stake: &Pubkey, + authority: &Pubkey, + new_authority: &Pubkey, + stake_authorize: StakeAuthorize, +) -> Instruction { + stake_ix::authorize(stake, authority, new_authority, stake_authorize, None) +} + +/// Build a stake split instruction. +/// Note: We build this manually because stake_ix::split returns Vec +/// (including account creation), but our interface expects a single instruction. +/// Callers should ensure the split_stake account is already created. +fn build_stake_split( + stake: &Pubkey, + split_stake: &Pubkey, + authority: &Pubkey, + lamports: u64, +) -> Instruction { + use solana_stake_interface::instruction::StakeInstruction; + + Instruction::new_with_bincode( + solana_stake_interface::program::ID, + &StakeInstruction::Split(lamports), + vec![ + AccountMeta::new(*stake, false), // source stake account + AccountMeta::new(*split_stake, false), // destination stake account + AccountMeta::new_readonly(*authority, true), // stake authority (signer) + ], + ) +} + +// ===== SPL Token Instruction Builders ===== +// These use spl_token::instruction::TokenInstruction for data encoding to avoid manual byte construction. +// This ensures we stay in sync with any changes to the SPL Token program instruction format. + +/// Build a TransferChecked instruction for SPL Token. +/// TransferChecked is safer than Transfer as it verifies decimals. +fn build_token_transfer_checked( + source: &Pubkey, + mint: &Pubkey, + destination: &Pubkey, + authority: &Pubkey, + amount: u64, + decimals: u8, + token_program: &Pubkey, +) -> Instruction { + // Use SPL Token crate for instruction data encoding + let data = TokenInstruction::TransferChecked { amount, decimals }.pack(); + + Instruction::new_with_bytes( + *token_program, + &data, + vec![ + AccountMeta::new(*source, false), // source token account + AccountMeta::new_readonly(*mint, false), // mint + AccountMeta::new(*destination, false), // destination token account + AccountMeta::new_readonly(*authority, true), // owner/authority (signer) + ], + ) +} + +/// Build a CreateAssociatedTokenAccount instruction. +fn build_create_ata( + payer: &Pubkey, + owner: &Pubkey, + mint: &Pubkey, + token_program: &Pubkey, +) -> Instruction { + // Derive the ATA address + let ata = get_associated_token_address(owner, mint, token_program); + + // ATA program create instruction has no data (or discriminator 0) + Instruction::new_with_bytes( + program_ids::ata_program(), + &[], + vec![ + AccountMeta::new(*payer, true), // payer (signer) + AccountMeta::new(ata, false), // associated token account + AccountMeta::new_readonly(*owner, false), // wallet owner + AccountMeta::new_readonly(*mint, false), // token mint + AccountMeta::new_readonly(program_ids::system_program(), false), // system program + AccountMeta::new_readonly(*token_program, false), // token program + ], + ) +} + +/// Build a CloseAccount instruction for SPL Token. +fn build_close_account( + account: &Pubkey, + destination: &Pubkey, + authority: &Pubkey, + token_program: &Pubkey, +) -> Instruction { + // Use SPL Token crate for instruction data encoding + let data = TokenInstruction::CloseAccount.pack(); + + Instruction::new_with_bytes( + *token_program, + &data, + vec![ + AccountMeta::new(*account, false), // account to close + AccountMeta::new(*destination, false), // destination for lamports + AccountMeta::new_readonly(*authority, true), // owner/authority (signer) + ], + ) +} + +/// Derive the Associated Token Account address. +fn get_associated_token_address(owner: &Pubkey, mint: &Pubkey, token_program: &Pubkey) -> Pubkey { + // ATA is a PDA with seeds: [owner, token_program, mint] + let seeds = &[owner.as_ref(), token_program.as_ref(), mint.as_ref()]; + let (ata, _bump) = Pubkey::find_program_address(seeds, &program_ids::ata_program()); + ata +} + +/// Build a MintTo instruction for SPL Token. +fn build_mint_to( + mint: &Pubkey, + destination: &Pubkey, + authority: &Pubkey, + amount: u64, + token_program: &Pubkey, +) -> Instruction { + // Use SPL Token crate for instruction data encoding + let data = TokenInstruction::MintTo { amount }.pack(); + + Instruction::new_with_bytes( + *token_program, + &data, + vec![ + AccountMeta::new(*mint, false), // mint + AccountMeta::new(*destination, false), // destination token account + AccountMeta::new_readonly(*authority, true), // mint authority (signer) + ], + ) +} + +/// Build a Burn instruction for SPL Token. +fn build_burn( + account: &Pubkey, + mint: &Pubkey, + authority: &Pubkey, + amount: u64, + token_program: &Pubkey, +) -> Instruction { + // Use SPL Token crate for instruction data encoding + let data = TokenInstruction::Burn { amount }.pack(); + + Instruction::new_with_bytes( + *token_program, + &data, + vec![ + AccountMeta::new(*account, false), // source token account + AccountMeta::new(*mint, false), // mint + AccountMeta::new_readonly(*authority, true), // owner/authority (signer) + ], + ) +} + +/// Build an Approve instruction for SPL Token. +fn build_approve( + account: &Pubkey, + delegate: &Pubkey, + owner: &Pubkey, + amount: u64, + token_program: &Pubkey, +) -> Instruction { + // Use SPL Token crate for instruction data encoding + let data = TokenInstruction::Approve { amount }.pack(); + + Instruction::new_with_bytes( + *token_program, + &data, + vec![ + AccountMeta::new(*account, false), // token account + AccountMeta::new_readonly(*delegate, false), // delegate + AccountMeta::new_readonly(*owner, true), // owner (signer) + ], + ) +} + +// ===== Jito Stake Pool Instruction Builders ===== + +/// Build a DepositSol instruction for SPL Stake Pool (Jito). +#[allow(clippy::too_many_arguments)] +fn build_stake_pool_deposit_sol( + stake_pool: &Pubkey, + withdraw_authority: &Pubkey, + reserve_stake: &Pubkey, + funding_account: &Pubkey, + destination_pool_account: &Pubkey, + manager_fee_account: &Pubkey, + referral_pool_account: &Pubkey, + pool_mint: &Pubkey, + lamports: u64, +) -> Instruction { + use borsh::BorshSerialize; + + // DepositSol instruction data using spl-stake-pool + let instruction_data = StakePoolInstruction::DepositSol(lamports); + let mut data = Vec::new(); + instruction_data.serialize(&mut data).unwrap(); + + Instruction::new_with_bytes( + program_ids::stake_pool_program(), + &data, + vec![ + AccountMeta::new(*stake_pool, false), + AccountMeta::new_readonly(*withdraw_authority, false), + AccountMeta::new(*reserve_stake, false), + AccountMeta::new(*funding_account, true), // signer + AccountMeta::new(*destination_pool_account, false), + AccountMeta::new(*manager_fee_account, false), + AccountMeta::new(*referral_pool_account, false), + AccountMeta::new(*pool_mint, false), + AccountMeta::new_readonly(program_ids::system_program(), false), + AccountMeta::new_readonly(program_ids::token_program(), false), + ], + ) +} + +/// Build a WithdrawStake instruction for SPL Stake Pool (Jito). +/// Uses solana_stake_interface::program::ID for the stake program. +#[allow(clippy::too_many_arguments)] +fn build_stake_pool_withdraw_stake( + stake_pool: &Pubkey, + validator_list: &Pubkey, + withdraw_authority: &Pubkey, + validator_stake: &Pubkey, + destination_stake: &Pubkey, + destination_stake_authority: &Pubkey, + source_transfer_authority: &Pubkey, + source_pool_account: &Pubkey, + manager_fee_account: &Pubkey, + pool_mint: &Pubkey, + pool_tokens: u64, +) -> Instruction { + use borsh::BorshSerialize; + + // WithdrawStake instruction data using spl-stake-pool + let instruction_data = StakePoolInstruction::WithdrawStake(pool_tokens); + let mut data = Vec::new(); + instruction_data.serialize(&mut data).unwrap(); + + Instruction::new_with_bytes( + program_ids::stake_pool_program(), + &data, + vec![ + AccountMeta::new(*stake_pool, false), + AccountMeta::new(*validator_list, false), + AccountMeta::new_readonly(*withdraw_authority, false), + AccountMeta::new(*validator_stake, false), + AccountMeta::new(*destination_stake, false), + AccountMeta::new_readonly(*destination_stake_authority, false), + AccountMeta::new_readonly(*source_transfer_authority, true), // signer + AccountMeta::new(*source_pool_account, false), + AccountMeta::new(*manager_fee_account, false), + AccountMeta::new(*pool_mint, false), + AccountMeta::new_readonly(clock_sysvar::ID, false), + AccountMeta::new_readonly(program_ids::token_program(), false), + AccountMeta::new_readonly(solana_stake_interface::program::ID, false), + ], + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Use our 2.x parsing Transaction for verification (different type than SDK Transaction) + fn verify_tx_structure(tx_bytes: &[u8], expected_instructions: usize) { + use crate::transaction::TransactionExt; + let tx = crate::Transaction::from_bytes(tx_bytes).unwrap(); + assert_eq!(tx.num_instructions(), expected_instructions); + } + + #[test] + fn test_build_simple_transfer() { + let intent = TransactionIntent { + fee_payer: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + nonce: Nonce::Blockhash { + value: "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4".to_string(), + }, + instructions: vec![IntentInstruction::Transfer { + from: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + to: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH".to_string(), + lamports: 1000000, + }], + address_lookup_tables: None, + static_account_keys: None, + }; + + let result = build_transaction(intent); + assert!(result.is_ok(), "Failed to build transaction: {:?}", result); + + let tx_bytes = result.unwrap(); + assert!(!tx_bytes.is_empty()); + verify_tx_structure(&tx_bytes, 1); + } + + #[test] + fn test_build_with_memo() { + let intent = TransactionIntent { + fee_payer: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + nonce: Nonce::Blockhash { + value: "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4".to_string(), + }, + instructions: vec![ + IntentInstruction::Transfer { + from: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + to: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH".to_string(), + lamports: 1000000, + }, + IntentInstruction::Memo { + message: "BitGo transfer".to_string(), + }, + ], + address_lookup_tables: None, + static_account_keys: None, + }; + + let result = build_transaction(intent); + assert!(result.is_ok()); + + let tx_bytes = result.unwrap(); + verify_tx_structure(&tx_bytes, 2); + } + + #[test] + fn test_build_with_compute_budget() { + let intent = TransactionIntent { + fee_payer: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + nonce: Nonce::Blockhash { + value: "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4".to_string(), + }, + instructions: vec![ + IntentInstruction::ComputeBudget { + unit_limit: Some(200000), + unit_price: None, + }, + IntentInstruction::Transfer { + from: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + to: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH".to_string(), + lamports: 1000000, + }, + ], + address_lookup_tables: None, + static_account_keys: None, + }; + + let result = build_transaction(intent); + assert!(result.is_ok()); + } + + #[test] + fn test_invalid_pubkey() { + let intent = TransactionIntent { + fee_payer: "invalid".to_string(), + nonce: Nonce::Blockhash { + value: "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4".to_string(), + }, + instructions: vec![], + address_lookup_tables: None, + static_account_keys: None, + }; + + let result = build_transaction(intent); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Invalid")); + } + + #[test] + fn test_build_stake_delegate() { + let intent = TransactionIntent { + fee_payer: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + nonce: Nonce::Blockhash { + value: "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4".to_string(), + }, + instructions: vec![IntentInstruction::StakeDelegate { + stake: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH".to_string(), + vote: "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN".to_string(), + authority: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + }], + address_lookup_tables: None, + static_account_keys: None, + }; + + let result = build_transaction(intent); + assert!( + result.is_ok(), + "Failed to build stake delegate: {:?}", + result + ); + verify_tx_structure(&result.unwrap(), 1); + } + + #[test] + fn test_build_stake_deactivate() { + let intent = TransactionIntent { + fee_payer: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + nonce: Nonce::Blockhash { + value: "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4".to_string(), + }, + instructions: vec![IntentInstruction::StakeDeactivate { + stake: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH".to_string(), + authority: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + }], + address_lookup_tables: None, + static_account_keys: None, + }; + + let result = build_transaction(intent); + assert!( + result.is_ok(), + "Failed to build stake deactivate: {:?}", + result + ); + verify_tx_structure(&result.unwrap(), 1); + } + + #[test] + fn test_build_stake_withdraw() { + let intent = TransactionIntent { + fee_payer: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + nonce: Nonce::Blockhash { + value: "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4".to_string(), + }, + instructions: vec![IntentInstruction::StakeWithdraw { + stake: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH".to_string(), + recipient: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + lamports: 1000000, + authority: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + }], + address_lookup_tables: None, + static_account_keys: None, + }; + + let result = build_transaction(intent); + assert!( + result.is_ok(), + "Failed to build stake withdraw: {:?}", + result + ); + verify_tx_structure(&result.unwrap(), 1); + } + + #[test] + fn test_build_token_transfer() { + let intent = TransactionIntent { + fee_payer: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + nonce: Nonce::Blockhash { + value: "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4".to_string(), + }, + instructions: vec![IntentInstruction::TokenTransfer { + source: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH".to_string(), + destination: "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN".to_string(), + mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), // USDC mint + amount: 1000000, + decimals: 6, + authority: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + program_id: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA".to_string(), + }], + address_lookup_tables: None, + static_account_keys: None, + }; + + let result = build_transaction(intent); + assert!( + result.is_ok(), + "Failed to build token transfer: {:?}", + result + ); + verify_tx_structure(&result.unwrap(), 1); + } + + #[test] + fn test_build_create_ata() { + let intent = TransactionIntent { + fee_payer: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + nonce: Nonce::Blockhash { + value: "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4".to_string(), + }, + instructions: vec![IntentInstruction::CreateAssociatedTokenAccount { + payer: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + owner: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH".to_string(), + mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), // USDC mint + token_program_id: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA".to_string(), + }], + address_lookup_tables: None, + static_account_keys: None, + }; + + let result = build_transaction(intent); + assert!(result.is_ok(), "Failed to build create ATA: {:?}", result); + verify_tx_structure(&result.unwrap(), 1); + } + + #[test] + fn test_build_close_ata() { + let intent = TransactionIntent { + fee_payer: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + nonce: Nonce::Blockhash { + value: "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4".to_string(), + }, + instructions: vec![IntentInstruction::CloseAssociatedTokenAccount { + account: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH".to_string(), + destination: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + authority: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + program_id: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA".to_string(), + }], + address_lookup_tables: None, + static_account_keys: None, + }; + + let result = build_transaction(intent); + assert!(result.is_ok(), "Failed to build close ATA: {:?}", result); + verify_tx_structure(&result.unwrap(), 1); + } + + #[test] + fn test_build_mint_to() { + let intent = TransactionIntent { + fee_payer: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + nonce: Nonce::Blockhash { + value: "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4".to_string(), + }, + instructions: vec![IntentInstruction::MintTo { + mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), + destination: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH".to_string(), + authority: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + amount: 1000000, + program_id: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA".to_string(), + }], + address_lookup_tables: None, + static_account_keys: None, + }; + + let result = build_transaction(intent); + assert!(result.is_ok(), "Failed to build mint to: {:?}", result); + verify_tx_structure(&result.unwrap(), 1); + } + + #[test] + fn test_build_burn() { + let intent = TransactionIntent { + fee_payer: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + nonce: Nonce::Blockhash { + value: "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4".to_string(), + }, + instructions: vec![IntentInstruction::Burn { + mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(), + account: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH".to_string(), + authority: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + amount: 1000000, + program_id: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA".to_string(), + }], + address_lookup_tables: None, + static_account_keys: None, + }; + + let result = build_transaction(intent); + assert!(result.is_ok(), "Failed to build burn: {:?}", result); + verify_tx_structure(&result.unwrap(), 1); + } + + #[test] + fn test_build_approve() { + let intent = TransactionIntent { + fee_payer: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + nonce: Nonce::Blockhash { + value: "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4".to_string(), + }, + instructions: vec![IntentInstruction::Approve { + account: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH".to_string(), + delegate: "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN".to_string(), + owner: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + amount: 1000000, + program_id: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA".to_string(), + }], + address_lookup_tables: None, + static_account_keys: None, + }; + + let result = build_transaction(intent); + assert!(result.is_ok(), "Failed to build approve: {:?}", result); + verify_tx_structure(&result.unwrap(), 1); + } + + #[test] + fn test_build_stake_pool_deposit_sol() { + // Jito stake pool addresses (testnet-like) + let intent = TransactionIntent { + fee_payer: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + nonce: Nonce::Blockhash { + value: "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4".to_string(), + }, + instructions: vec![IntentInstruction::StakePoolDepositSol { + stake_pool: "Jito4APyf642JPZPx3hGc6WWJ8zPKtRbRs4P815Awbb".to_string(), + withdraw_authority: "6iQKfEyhr3bZMotVkW6beNZz5CPAkiwvgV2CTje9pVSS".to_string(), + reserve_stake: "BgKUXdS4Wy6Vdgp1jwT2dz5ZgxPG94aPL77dQscSPGmc".to_string(), + funding_account: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + destination_pool_account: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH" + .to_string(), + manager_fee_account: "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN".to_string(), + referral_pool_account: "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN".to_string(), + pool_mint: "J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn".to_string(), + lamports: 1000000000, // 1 SOL + }], + address_lookup_tables: None, + static_account_keys: None, + }; + + let result = build_transaction(intent); + assert!( + result.is_ok(), + "Failed to build stake pool deposit sol: {:?}", + result + ); + verify_tx_structure(&result.unwrap(), 1); + } + + #[test] + fn test_build_stake_pool_withdraw_stake() { + // Jito stake pool addresses (testnet-like) + let intent = TransactionIntent { + fee_payer: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + nonce: Nonce::Blockhash { + value: "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4".to_string(), + }, + instructions: vec![IntentInstruction::StakePoolWithdrawStake { + stake_pool: "Jito4APyf642JPZPx3hGc6WWJ8zPKtRbRs4P815Awbb".to_string(), + validator_list: "3R3nGZpQs2aZo5FDQvd2MUQ5R5E9g7NvHQaxpLPYA8r2".to_string(), + withdraw_authority: "6iQKfEyhr3bZMotVkW6beNZz5CPAkiwvgV2CTje9pVSS".to_string(), + validator_stake: "BgKUXdS4Wy6Vdgp1jwT2dz5ZgxPG94aPL77dQscSPGmc".to_string(), + destination_stake: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH".to_string(), + destination_stake_authority: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB" + .to_string(), + source_transfer_authority: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB" + .to_string(), + source_pool_account: "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN".to_string(), + manager_fee_account: "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN".to_string(), + pool_mint: "J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn".to_string(), + pool_tokens: 1000000000, // 1 JitoSOL + }], + address_lookup_tables: None, + static_account_keys: None, + }; + + let result = build_transaction(intent); + assert!( + result.is_ok(), + "Failed to build stake pool withdraw stake: {:?}", + result + ); + verify_tx_structure(&result.unwrap(), 1); + } + + #[test] + fn test_build_stake_split() { + let intent = TransactionIntent { + fee_payer: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + nonce: Nonce::Blockhash { + value: "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4".to_string(), + }, + instructions: vec![IntentInstruction::StakeSplit { + stake: "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH".to_string(), + split_stake: "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN".to_string(), + authority: "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB".to_string(), + lamports: 500000000, // 0.5 SOL + }], + address_lookup_tables: None, + static_account_keys: None, + }; + + let result = build_transaction(intent); + assert!(result.is_ok(), "Failed to build stake split: {:?}", result); + verify_tx_structure(&result.unwrap(), 1); + } +} diff --git a/packages/wasm-solana/src/builder/mod.rs b/packages/wasm-solana/src/builder/mod.rs new file mode 100644 index 00000000..f90c82a2 --- /dev/null +++ b/packages/wasm-solana/src/builder/mod.rs @@ -0,0 +1,23 @@ +//! Transaction building module. +//! +//! This module provides the `buildTransaction()` function which creates Solana +//! transactions from a high-level `TransactionIntent` structure. +//! +//! # Transaction Types +//! +//! - **Legacy transactions**: Standard format, all accounts inline +//! - **Versioned transactions (MessageV0)**: Supports Address Lookup Tables +//! +//! The builder automatically selects the format based on whether +//! `address_lookup_tables` is provided in the intent. + +mod build; +mod types; +mod versioned; + +pub use build::build_transaction; +pub use types::{ + AddressLookupTable, Instruction, MessageHeader, Nonce, RawVersionedTransactionData, + TransactionIntent, VersionedInstruction, +}; +pub use versioned::{build_from_raw_versioned_data, should_build_versioned}; diff --git a/packages/wasm-solana/src/builder/types.rs b/packages/wasm-solana/src/builder/types.rs new file mode 100644 index 00000000..8ff8d34d --- /dev/null +++ b/packages/wasm-solana/src/builder/types.rs @@ -0,0 +1,456 @@ +//! Types for transaction building. +//! +//! These types are designed to be serialized from JavaScript via serde. +//! Public keys use string (base58) representations. +//! Amounts use u64 which maps to JavaScript BigInt via wasm-bindgen. + +use serde::Deserialize; + +/// Nonce source for transaction - either a recent blockhash or durable nonce account. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Nonce { + /// Use a recent blockhash (standard transactions) + Blockhash { value: String }, + /// Use a durable nonce account (offline signing) + Durable { + address: String, + authority: String, + /// Nonce value stored in the account (this becomes the blockhash) + value: String, + }, +} + +/// Intent to build a transaction. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TransactionIntent { + /// The fee payer's public key (base58) + pub fee_payer: String, + /// Nonce source + pub nonce: Nonce, + /// List of instructions to include + pub instructions: Vec, + + // ===== Versioned Transaction Fields (MessageV0) ===== + // If these fields are provided, a versioned transaction is built. + /// Address Lookup Tables for versioned transactions. + /// If provided, builds a MessageV0 transaction instead of legacy. + #[serde(rename = "addressLookupTables", default)] + pub address_lookup_tables: Option>, + + /// Static account keys (for versioned transaction round-trip). + /// These are the accounts stored directly in the message. + #[serde(rename = "staticAccountKeys", default)] + pub static_account_keys: Option>, +} + +/// Address Lookup Table data for versioned transactions. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddressLookupTable { + /// The lookup table account address (base58) + #[serde(rename = "accountKey")] + pub account_key: String, + /// Indices of writable accounts in the lookup table + #[serde(rename = "writableIndexes")] + pub writable_indexes: Vec, + /// Indices of readonly accounts in the lookup table + #[serde(rename = "readonlyIndexes")] + pub readonly_indexes: Vec, +} + +/// An instruction to include in the transaction. +/// +/// This is a discriminated union (tagged enum) that supports all instruction types. +/// Use the `type` field to determine which variant is being used. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Instruction { + // ===== System Program Instructions ===== + /// Transfer SOL from one account to another + Transfer { + from: String, + to: String, + /// Amount in lamports + lamports: u64, + }, + + /// Create a new account + CreateAccount { + from: String, + #[serde(rename = "newAccount")] + new_account: String, + /// Lamports to transfer to new account + lamports: u64, + /// Space to allocate in bytes + space: u64, + /// Program owner of the new account + owner: String, + }, + + /// Advance a nonce account + NonceAdvance { + /// Nonce account address + nonce: String, + /// Nonce authority + authority: String, + }, + + /// Initialize a nonce account + NonceInitialize { + /// Nonce account address + nonce: String, + /// Nonce authority + authority: String, + }, + + /// Allocate space in an account + Allocate { account: String, space: u64 }, + + /// Assign account to a program + Assign { account: String, owner: String }, + + // ===== Memo Program ===== + /// Add a memo to the transaction + Memo { message: String }, + + // ===== Compute Budget Program ===== + /// Set compute budget (priority fees) + ComputeBudget { + /// Compute unit limit (optional) + #[serde(rename = "unitLimit")] + unit_limit: Option, + /// Compute unit price in micro-lamports (optional) + #[serde(rename = "unitPrice")] + unit_price: Option, + }, + // ===== Stake Program Instructions ===== + /// Initialize a stake account with authorized staker and withdrawer + StakeInitialize { + /// Stake account address + stake: String, + /// Authorized staker pubkey + staker: String, + /// Authorized withdrawer pubkey + withdrawer: String, + }, + + /// Delegate stake to a validator + StakeDelegate { + /// Stake account address + stake: String, + /// Vote account (validator) to delegate to + vote: String, + /// Stake authority + authority: String, + }, + + /// Deactivate a stake account + StakeDeactivate { + /// Stake account address + stake: String, + /// Stake authority + authority: String, + }, + + /// Withdraw from a stake account + StakeWithdraw { + /// Stake account address + stake: String, + /// Recipient address for withdrawn lamports + recipient: String, + /// Amount in lamports to withdraw + lamports: u64, + /// Withdraw authority + authority: String, + }, + + /// Change stake account authorization + StakeAuthorize { + /// Stake account address + stake: String, + /// New authority pubkey + #[serde(rename = "newAuthority")] + new_authority: String, + /// Authorization type: "staker" or "withdrawer" + #[serde(rename = "authorizeType")] + authorize_type: String, + /// Current authority + authority: String, + }, + + /// Split stake account (used for partial deactivation) + StakeSplit { + /// Source stake account address + stake: String, + /// Destination stake account (must be uninitialized/created first) + #[serde(rename = "splitStake")] + split_stake: String, + /// Stake authority + authority: String, + /// Amount in lamports to split + lamports: u64, + }, + + // ===== SPL Token Instructions ===== + /// Transfer tokens (uses TransferChecked for safety) + TokenTransfer { + /// Source token account + source: String, + /// Destination token account + destination: String, + /// Token mint address + mint: String, + /// Amount of tokens to transfer (in smallest units) + amount: u64, + /// Number of decimals for the token + decimals: u8, + /// Owner/authority of the source account + authority: String, + /// Token program ID (TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA or Token-2022) + #[serde(rename = "programId", default = "default_token_program")] + program_id: String, + }, + + /// Create an Associated Token Account + CreateAssociatedTokenAccount { + /// Payer for account creation + payer: String, + /// Owner of the new ATA + owner: String, + /// Token mint address + mint: String, + /// Token program ID (optional, defaults to Token Program) + #[serde(rename = "tokenProgramId", default = "default_token_program")] + token_program_id: String, + }, + + /// Close an Associated Token Account + CloseAssociatedTokenAccount { + /// Token account to close + account: String, + /// Destination for remaining lamports + destination: String, + /// Authority of the account + authority: String, + /// Token program ID (optional, defaults to Token Program) + #[serde(rename = "programId", default = "default_token_program")] + program_id: String, + }, + + /// Mint tokens to an account (requires mint authority) + MintTo { + /// Token mint address + mint: String, + /// Destination token account + destination: String, + /// Mint authority + authority: String, + /// Amount of tokens to mint (in smallest units) + amount: u64, + /// Token program ID (optional, defaults to Token Program) + #[serde(rename = "programId", default = "default_token_program")] + program_id: String, + }, + + /// Burn tokens from an account + Burn { + /// Token mint address + mint: String, + /// Source token account to burn from + account: String, + /// Token account authority + authority: String, + /// Amount of tokens to burn (in smallest units) + amount: u64, + /// Token program ID (optional, defaults to Token Program) + #[serde(rename = "programId", default = "default_token_program")] + program_id: String, + }, + + /// Approve a delegate to transfer tokens + Approve { + /// Token account to approve delegation for + account: String, + /// Delegate address (who can transfer) + delegate: String, + /// Token account owner + owner: String, + /// Amount of tokens to approve (in smallest units) + amount: u64, + /// Token program ID (optional, defaults to Token Program) + #[serde(rename = "programId", default = "default_token_program")] + program_id: String, + }, + + // ===== Jito Stake Pool Instructions ===== + /// Deposit SOL into a stake pool (Jito liquid staking) + StakePoolDepositSol { + /// Stake pool address + #[serde(rename = "stakePool")] + stake_pool: String, + /// Withdraw authority PDA + #[serde(rename = "withdrawAuthority")] + withdraw_authority: String, + /// Reserve stake account + #[serde(rename = "reserveStake")] + reserve_stake: String, + /// Funding account (SOL source, signer) + #[serde(rename = "fundingAccount")] + funding_account: String, + /// Destination for pool tokens + #[serde(rename = "destinationPoolAccount")] + destination_pool_account: String, + /// Manager fee account + #[serde(rename = "managerFeeAccount")] + manager_fee_account: String, + /// Referral pool account + #[serde(rename = "referralPoolAccount")] + referral_pool_account: String, + /// Pool mint address + #[serde(rename = "poolMint")] + pool_mint: String, + /// Amount in lamports to deposit + lamports: u64, + }, + + /// Withdraw stake from a stake pool (Jito liquid staking) + StakePoolWithdrawStake { + /// Stake pool address + #[serde(rename = "stakePool")] + stake_pool: String, + /// Validator list account + #[serde(rename = "validatorList")] + validator_list: String, + /// Withdraw authority PDA + #[serde(rename = "withdrawAuthority")] + withdraw_authority: String, + /// Validator stake account to split from + #[serde(rename = "validatorStake")] + validator_stake: String, + /// Destination stake account (uninitialized) + #[serde(rename = "destinationStake")] + destination_stake: String, + /// Authority for the destination stake account + #[serde(rename = "destinationStakeAuthority")] + destination_stake_authority: String, + /// Source pool token account authority (signer) + #[serde(rename = "sourceTransferAuthority")] + source_transfer_authority: String, + /// Source pool token account + #[serde(rename = "sourcePoolAccount")] + source_pool_account: String, + /// Manager fee account + #[serde(rename = "managerFeeAccount")] + manager_fee_account: String, + /// Pool mint address + #[serde(rename = "poolMint")] + pool_mint: String, + /// Amount of pool tokens to burn + #[serde(rename = "poolTokens")] + pool_tokens: u64, + }, + + // ===== Custom/Raw Instruction ===== + /// A custom instruction that can invoke any program. + /// This enables passthrough of arbitrary instructions for extensibility. + Custom { + /// The program ID to invoke (base58) + #[serde(rename = "programId")] + program_id: String, + /// Account metas for the instruction + accounts: Vec, + /// Instruction data (base64 or hex encoded) + data: String, + /// Encoding of the data field: "base64" (default) or "hex" + #[serde(default = "default_encoding")] + encoding: String, + }, +} + +/// Account meta for custom instructions +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomAccountMeta { + /// Account public key (base58) + pub pubkey: String, + /// Whether the account is a signer + #[serde(default)] + pub is_signer: bool, + /// Whether the account is writable + #[serde(default)] + pub is_writable: bool, +} + +fn default_token_program() -> String { + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA".to_string() +} + +fn default_encoding() -> String { + "base64".to_string() +} + +// ============================================================================= +// Raw Versioned Transaction Data (for fromVersionedTransactionData path) +// ============================================================================= + +/// Raw versioned transaction data for direct serialization. +/// This is used when we have pre-formed MessageV0 data that just needs to be serialized. +/// No instruction compilation is needed - just serialize the raw structure. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RawVersionedTransactionData { + /// Static account keys (base58 encoded public keys) + #[serde(rename = "staticAccountKeys")] + pub static_account_keys: Vec, + + /// Address lookup tables + #[serde(rename = "addressLookupTables")] + pub address_lookup_tables: Vec, + + /// Pre-compiled instructions with index-based account references + #[serde(rename = "versionedInstructions")] + pub versioned_instructions: Vec, + + /// Message header + #[serde(rename = "messageHeader")] + pub message_header: MessageHeader, + + /// Recent blockhash (base58) + #[serde(rename = "recentBlockhash")] + pub recent_blockhash: String, +} + +/// A pre-compiled versioned instruction (uses indexes, not pubkeys) +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VersionedInstruction { + /// Index into the account keys array for the program ID + #[serde(rename = "programIdIndex")] + pub program_id_index: u8, + + /// Indexes into the account keys array for instruction accounts + #[serde(rename = "accountKeyIndexes")] + pub account_key_indexes: Vec, + + /// Instruction data (base58 encoded) + pub data: String, +} + +/// Message header for versioned transactions +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MessageHeader { + /// Number of required signatures + #[serde(rename = "numRequiredSignatures")] + pub num_required_signatures: u8, + + /// Number of readonly signed accounts + #[serde(rename = "numReadonlySignedAccounts")] + pub num_readonly_signed_accounts: u8, + + /// Number of readonly unsigned accounts + #[serde(rename = "numReadonlyUnsignedAccounts")] + pub num_readonly_unsigned_accounts: u8, +} diff --git a/packages/wasm-solana/src/builder/versioned.rs b/packages/wasm-solana/src/builder/versioned.rs new file mode 100644 index 00000000..7e7f149a --- /dev/null +++ b/packages/wasm-solana/src/builder/versioned.rs @@ -0,0 +1,258 @@ +//! Versioned transaction building (MessageV0). +//! +//! This module handles building versioned transactions with Address Lookup Tables (ALTs). +//! Versioned transactions use MessageV0 which supports referencing accounts from ALTs, +//! allowing transactions with more accounts than the legacy format. +//! +//! # When to Use +//! +//! Build a versioned transaction when: +//! - The `TransactionIntent` has `address_lookup_tables` field set +//! - Round-tripping a parsed versioned transaction +//! +//! # Wire Format +//! +//! MessageV0 transactions have a version byte (0x80) followed by the message. +//! The ALT references allow account indices beyond 255. + +use crate::builder::types::{ + AddressLookupTable, Nonce, RawVersionedTransactionData, TransactionIntent, +}; +use crate::error::WasmSolanaError; +use solana_message::v0::Message as MessageV0; +use solana_message::AddressLookupTableAccount; +use solana_sdk::bs58; +use solana_sdk::hash::Hash; +use solana_sdk::instruction::Instruction; +use solana_sdk::pubkey::Pubkey; +use solana_transaction::versioned::VersionedTransaction; +use std::str::FromStr; + +/// Build a versioned transaction (MessageV0) from an intent. +/// +/// This is called when the intent has `address_lookup_tables` set. +/// The ALTs must include the actual account keys for proper compilation. +/// +/// # Arguments +/// +/// * `intent` - The transaction intent with ALT data +/// * `instructions` - Pre-built instructions (built by build.rs) +/// +/// # Returns +/// +/// Serialized versioned transaction bytes +pub fn build_versioned_transaction( + intent: &TransactionIntent, + instructions: Vec, +) -> Result, WasmSolanaError> { + // Parse fee payer + let fee_payer: Pubkey = intent + .fee_payer + .parse() + .map_err(|e| WasmSolanaError::new(&format!("Invalid fee payer: {}", e)))?; + + // Parse blockhash + let blockhash_str = match &intent.nonce { + Nonce::Blockhash { value } => value.clone(), + Nonce::Durable { value, .. } => value.clone(), + }; + let blockhash = Hash::from_str(&blockhash_str) + .map_err(|e| WasmSolanaError::new(&format!("Invalid blockhash: {}", e)))?; + + // Convert ALT data to AddressLookupTableAccount format + // Note: For compilation, we need the actual account keys from the ALT. + // Since we don't have them, we use a simplified approach that works for + // round-tripping pre-built versioned transactions. + let alt_accounts = convert_alts_for_compile(&intent.address_lookup_tables)?; + + // Try to compile the MessageV0 + let message = MessageV0::try_compile(&fee_payer, &instructions, &alt_accounts, blockhash) + .map_err(|e| WasmSolanaError::new(&format!("Failed to compile MessageV0: {:?}", e)))?; + + // Create versioned transaction with empty signatures + let versioned_tx = VersionedTransaction { + signatures: vec![], + message: solana_message::VersionedMessage::V0(message), + }; + + // Serialize to bytes + bincode::serialize(&versioned_tx).map_err(|e| { + WasmSolanaError::new(&format!("Failed to serialize versioned transaction: {}", e)) + }) +} + +/// Convert AddressLookupTable data to AddressLookupTableAccount for compilation. +/// +/// Note: This is a simplified conversion. For full ALT support, we'd need +/// the actual account keys stored in each ALT. For now, this supports +/// transactions where all accounts are in static_account_keys. +fn convert_alts_for_compile( + alts: &Option>, +) -> Result, WasmSolanaError> { + let Some(alts) = alts else { + return Ok(vec![]); + }; + + let mut accounts = Vec::with_capacity(alts.len()); + + for alt in alts { + let key: Pubkey = alt + .account_key + .parse() + .map_err(|e| WasmSolanaError::new(&format!("Invalid ALT account key: {}", e)))?; + + // For now, we create empty address lists. + // Full ALT support would require fetching ALT account data. + accounts.push(AddressLookupTableAccount { + key, + addresses: vec![], // Would need actual ALT data for new transactions + }); + } + + Ok(accounts) +} + +/// Check if an intent should be built as a versioned transaction. +pub fn should_build_versioned(intent: &TransactionIntent) -> bool { + intent.address_lookup_tables.is_some() +} + +/// Build a versioned transaction directly from raw MessageV0 data. +/// +/// This function is used for the `fromVersionedTransactionData()` path where we already +/// have pre-compiled versioned data (indexes + ALT refs). No instruction compilation +/// is needed - we just serialize the raw structure to bytes. +/// +/// # Arguments +/// +/// * `data` - Raw versioned transaction data with pre-compiled instructions +/// +/// # Returns +/// +/// Serialized versioned transaction bytes (unsigned) +pub fn build_from_raw_versioned_data( + data: &RawVersionedTransactionData, +) -> Result, WasmSolanaError> { + use solana_message::compiled_instruction::CompiledInstruction; + use solana_message::v0::MessageAddressTableLookup; + use solana_message::MessageHeader; + + // Parse static account keys + let static_account_keys: Vec = data + .static_account_keys + .iter() + .map(|key| { + key.parse().map_err(|e| { + WasmSolanaError::new(&format!("Invalid static account key '{}': {}", key, e)) + }) + }) + .collect::, _>>()?; + + // Parse blockhash + let recent_blockhash = Hash::from_str(&data.recent_blockhash) + .map_err(|e| WasmSolanaError::new(&format!("Invalid blockhash: {}", e)))?; + + // Convert instructions to compiled format + let compiled_instructions: Vec = data + .versioned_instructions + .iter() + .map(|ix| { + // Decode base58 instruction data + let instruction_data = bs58::decode(&ix.data) + .into_vec() + .map_err(|e| WasmSolanaError::new(&format!("Invalid instruction data: {}", e)))?; + + Ok(CompiledInstruction { + program_id_index: ix.program_id_index, + accounts: ix.account_key_indexes.clone(), + data: instruction_data, + }) + }) + .collect::, WasmSolanaError>>()?; + + // Convert address lookup tables + let address_table_lookups: Vec = + data.address_lookup_tables + .iter() + .map(|alt| { + let account_key: Pubkey = alt.account_key.parse().map_err(|e| { + WasmSolanaError::new(&format!("Invalid ALT account key: {}", e)) + })?; + + Ok(MessageAddressTableLookup { + account_key, + writable_indexes: alt.writable_indexes.clone(), + readonly_indexes: alt.readonly_indexes.clone(), + }) + }) + .collect::, WasmSolanaError>>()?; + + // Create MessageV0 directly (no compilation needed) + let message = MessageV0 { + header: MessageHeader { + num_required_signatures: data.message_header.num_required_signatures, + num_readonly_signed_accounts: data.message_header.num_readonly_signed_accounts, + num_readonly_unsigned_accounts: data.message_header.num_readonly_unsigned_accounts, + }, + account_keys: static_account_keys, + recent_blockhash, + instructions: compiled_instructions, + address_table_lookups, + }; + + // Create versioned transaction with empty signatures + // The number of signatures is determined by num_required_signatures + let signatures = vec![ + solana_sdk::signature::Signature::default(); + data.message_header.num_required_signatures as usize + ]; + + let versioned_tx = VersionedTransaction { + signatures, + message: solana_message::VersionedMessage::V0(message), + }; + + // Serialize to bytes + bincode::serialize(&versioned_tx).map_err(|e| { + WasmSolanaError::new(&format!("Failed to serialize versioned transaction: {}", e)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_should_build_versioned_with_alts() { + let intent = TransactionIntent { + fee_payer: "11111111111111111111111111111111".to_string(), + nonce: Nonce::Blockhash { + value: "11111111111111111111111111111111".to_string(), + }, + instructions: vec![], + address_lookup_tables: Some(vec![AddressLookupTable { + account_key: "11111111111111111111111111111111".to_string(), + writable_indexes: vec![0], + readonly_indexes: vec![1], + }]), + static_account_keys: None, + }; + + assert!(should_build_versioned(&intent)); + } + + #[test] + fn test_should_not_build_versioned_without_alts() { + let intent = TransactionIntent { + fee_payer: "11111111111111111111111111111111".to_string(), + nonce: Nonce::Blockhash { + value: "11111111111111111111111111111111".to_string(), + }, + instructions: vec![], + address_lookup_tables: None, + static_account_keys: None, + }; + + assert!(!should_build_versioned(&intent)); + } +} diff --git a/packages/wasm-solana/src/instructions/decode.rs b/packages/wasm-solana/src/instructions/decode.rs index e1354d99..d57c37f2 100644 --- a/packages/wasm-solana/src/instructions/decode.rs +++ b/packages/wasm-solana/src/instructions/decode.rs @@ -337,14 +337,25 @@ fn decode_token_instruction(ctx: InstructionContext) -> ParsedInstruction { fn decode_ata_instruction(ctx: InstructionContext) -> ParsedInstruction { // ATA program: Create instruction has no data (discriminator 0 or empty) - // Accounts: [0] payer, [1] ata, [2] owner, [3] mint, [4] system, [5] token - if ctx.accounts.len() >= 4 { + // Accounts: [0] payer, [1] ata, [2] owner, [3] mint, [4] system, [5] token program + // Note: We return the token program (index 5) as programId, not the ATA program, + // because BitGoJS uses programId to indicate which token program owns the ATA. + if ctx.accounts.len() >= 6 { ParsedInstruction::CreateAssociatedTokenAccount(CreateAtaParams { payer_address: ctx.accounts[0].clone(), ata_address: ctx.accounts[1].clone(), owner_address: ctx.accounts[2].clone(), mint_address: ctx.accounts[3].clone(), - program_id: ctx.program_id.to_string(), + program_id: ctx.accounts[5].clone(), // Token program, not ATA program + }) + } else if ctx.accounts.len() >= 4 { + // Fallback for transactions without token program in accounts (older format) + ParsedInstruction::CreateAssociatedTokenAccount(CreateAtaParams { + payer_address: ctx.accounts[0].clone(), + ata_address: ctx.accounts[1].clone(), + owner_address: ctx.accounts[2].clone(), + mint_address: ctx.accounts[3].clone(), + program_id: TOKEN_PROGRAM_ID.to_string(), // Default to standard token program }) } else { make_unknown(ctx) diff --git a/packages/wasm-solana/src/instructions/types.rs b/packages/wasm-solana/src/instructions/types.rs index addb9538..09aa72b1 100644 --- a/packages/wasm-solana/src/instructions/types.rs +++ b/packages/wasm-solana/src/instructions/types.rs @@ -14,19 +14,36 @@ pub const TOKEN_2022_PROGRAM_ID: &str = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpP pub const ATA_PROGRAM_ID: &str = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"; pub const STAKE_POOL_PROGRAM_ID: &str = "SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy"; +/// Sysvar Recent Blockhashes address. +/// Required for NonceAdvance instruction to verify the nonce account's stored blockhash. +/// +/// Note: We hardcode this because solana_sdk::sysvar::recent_blockhashes::ID +/// is not available in the WASM-compatible subset of solana-sdk. +/// The value matches: https://github.com/solana-labs/solana/blob/v1.18.26/sdk/program/src/sysvar/recent_blockhashes.rs +pub const SYSVAR_RECENT_BLOCKHASHES: &str = "SysvarRecentB1ockHashes11111111111111111111"; + /// A parsed instruction with type discriminant and params. +/// +/// Note: Some variants like `CreateNonceAccount` and `StakingActivate` are defined +/// for API completeness but never constructed in Rust. Instruction combining +/// (e.g., CreateAccount + NonceInitialize → CreateNonceAccount) is handled by +/// TypeScript in mapWasmInstructionsToBitGoJS for flexibility. #[derive(Debug, Clone)] pub enum ParsedInstruction { // System Program instructions Transfer(TransferParams), CreateAccount(CreateAccountParams), NonceAdvance(NonceAdvanceParams), + /// Combined type for CreateAccount + NonceInitialize (constructed in TypeScript) + #[allow(dead_code)] CreateNonceAccount(CreateNonceAccountParams), /// Intermediate type for SystemInstruction::InitializeNonceAccount /// Will be combined with CreateAccount to form CreateNonceAccount NonceInitialize(NonceInitializeParams), // Stake Program instructions + /// Combined type for CreateAccount + StakeInitialize + Delegate (constructed in TypeScript) + #[allow(dead_code)] StakingActivate(StakingActivateParams), StakingDeactivate(StakingDeactivateParams), StakingWithdraw(StakingWithdrawParams), diff --git a/packages/wasm-solana/src/lib.rs b/packages/wasm-solana/src/lib.rs index d88c0376..ae25013e 100644 --- a/packages/wasm-solana/src/lib.rs +++ b/packages/wasm-solana/src/lib.rs @@ -23,12 +23,14 @@ //! let pubkey = Pubkey::from_base58("FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH").unwrap(); //! ``` +pub mod builder; mod error; mod instructions; pub mod keypair; mod parser; pub mod pubkey; pub mod transaction; +pub mod versioned; pub mod wasm; // Re-export core types at crate root @@ -36,6 +38,12 @@ pub use error::WasmSolanaError; pub use keypair::{Keypair, KeypairExt}; pub use pubkey::{Pubkey, PubkeyExt}; pub use transaction::{Transaction, TransactionExt}; +pub use versioned::{ + detect_transaction_version, AddressLookupTableData, TxVersion, VersionedTransactionExt, +}; // Re-export WASM types -pub use wasm::{ParserNamespace, WasmKeypair, WasmPubkey, WasmTransaction}; +pub use wasm::{ + is_versioned_transaction, BuilderNamespace, ParserNamespace, WasmKeypair, WasmPubkey, + WasmTransaction, WasmVersionedTransaction, +}; diff --git a/packages/wasm-solana/src/parser.rs b/packages/wasm-solana/src/parser.rs index 9cce44d7..1bdb2ed4 100644 --- a/packages/wasm-solana/src/parser.rs +++ b/packages/wasm-solana/src/parser.rs @@ -9,8 +9,10 @@ use crate::instructions::{decode_instruction, InstructionContext, ParsedInstruction}; use crate::js_obj; -use crate::transaction::{Transaction, TransactionExt}; +use crate::versioned::VersionedTransactionExt; use crate::wasm::try_into_js_value::{JsConversionError, TryIntoJsValue}; +use solana_message::VersionedMessage; +use solana_transaction::versioned::VersionedTransaction; use wasm_bindgen::JsValue; /// A fully parsed Solana transaction with decoded instructions. @@ -81,38 +83,50 @@ impl TryIntoJsValue for ParsedTransaction { /// # Returns /// A `ParsedTransaction` with all instructions decoded to semantic types. pub fn parse_transaction(bytes: &[u8]) -> Result { - // Deserialize the transaction - let tx = Transaction::from_bytes(bytes).map_err(|e| e.to_string())?; - - let message = &tx.message; + // Deserialize the transaction - VersionedTransaction handles both legacy and V0 + let tx = VersionedTransaction::from_bytes(bytes).map_err(|e| e.to_string())?; + + // Extract account keys and instructions based on message type + let (account_keys, instructions, recent_blockhash, num_required_signatures) = match &tx.message + { + VersionedMessage::Legacy(msg) => ( + msg.account_keys.iter().map(|k| k.to_string()).collect(), + &msg.instructions, + msg.recent_blockhash.to_string(), + msg.header.num_required_signatures, + ), + VersionedMessage::V0(msg) => ( + msg.account_keys.iter().map(|k| k.to_string()).collect(), + &msg.instructions, + msg.recent_blockhash.to_string(), + msg.header.num_required_signatures, + ), + }; + + let account_keys: Vec = account_keys; // Extract fee payer (first account key) - let fee_payer = message - .account_keys + let fee_payer = account_keys .first() - .map(|k| k.to_string()) + .cloned() .ok_or("Transaction has no account keys")?; - // Extract all account keys as base58 strings - let account_keys: Vec = message.account_keys.iter().map(|k| k.to_string()).collect(); - // Decode all instructions - let mut instructions_data = Vec::with_capacity(message.instructions.len()); + let mut instructions_data = Vec::with_capacity(instructions.len()); let mut durable_nonce = None; - for (idx, instruction) in message.instructions.iter().enumerate() { + for (idx, instruction) in instructions.iter().enumerate() { // Get program ID - let program_id = message - .account_keys + let program_id = account_keys .get(instruction.program_id_index as usize) - .map(|k| k.to_string()) + .cloned() .ok_or_else(|| format!("Invalid program_id_index in instruction {}", idx))?; // Resolve account indices to addresses let accounts: Vec = instruction .accounts .iter() - .filter_map(|&i| message.account_keys.get(i as usize).map(|k| k.to_string())) + .filter_map(|&i| account_keys.get(i as usize).cloned()) .collect(); // Decode the instruction @@ -139,17 +153,13 @@ pub fn parse_transaction(bytes: &[u8]) -> Result { // Note: Instruction combining (e.g., CreateAccount + StakeInitialize → StakingActivate) // is handled by TypeScript in mapWasmInstructionsToBitGoJS for flexibility - // The nonce is either the blockhash or, for durable nonce txs, still the blockhash - // (which is the nonce value from the nonce account) - let nonce = message.recent_blockhash.to_string(); - // Extract signatures as base58 strings let signatures: Vec = tx.signatures.iter().map(|s| s.to_string()).collect(); Ok(ParsedTransaction { fee_payer, - num_signatures: message.header.num_required_signatures, - nonce, + num_signatures: num_required_signatures, + nonce: recent_blockhash, durable_nonce, instructions_data, account_keys, @@ -176,11 +186,6 @@ mod tests { assert!(!parsed.nonce.is_empty()); assert_eq!(parsed.instructions_data.len(), 1); - // Check signatures are returned - assert_eq!(parsed.signatures.len(), 1); - // Unsigned transactions have all-zero signatures (base58 encoded) - assert!(!parsed.signatures[0].is_empty()); - // Check the instruction is a Transfer match &parsed.instructions_data[0] { ParsedInstruction::Transfer(params) => { diff --git a/packages/wasm-solana/src/transaction.rs b/packages/wasm-solana/src/transaction.rs index 0684d7b5..08f47d84 100644 --- a/packages/wasm-solana/src/transaction.rs +++ b/packages/wasm-solana/src/transaction.rs @@ -1,4 +1,4 @@ -//! Solana transaction deserialization. +//! Solana transaction deserialization and manipulation. //! //! Wraps `solana_transaction::Transaction` for WASM compatibility. //! @@ -8,10 +8,13 @@ //! - Signatures (variable length array) //! - Message (contains instructions, accounts, blockhash) //! -//! This module deserializes transaction bytes. Base64 encoding/decoding -//! is handled in the TypeScript layer. +//! This module deserializes transaction bytes and provides signature +//! manipulation. Base64 encoding/decoding is handled in the TypeScript layer. use crate::error::WasmSolanaError; +use solana_address::Address; +use solana_signature::Signature; +use std::str::FromStr; /// Re-export the underlying Solana Transaction type. pub use solana_transaction::Transaction; @@ -38,6 +41,15 @@ pub trait TransactionExt { /// Serialize transaction to bytes (wire format). fn to_bytes(&self) -> Result, WasmSolanaError>; + + /// Add a signature for a given public key. + /// + /// The pubkey must be one of the required signers in the transaction. + /// The signature bytes must be exactly 64 bytes (Ed25519 signature). + fn add_signature(&mut self, pubkey: &str, signature: &[u8]) -> Result<(), WasmSolanaError>; + + /// Get the index of a pubkey in the account keys, if it's a signer. + fn signer_index(&self, pubkey: &str) -> Option; } impl TransactionExt for Transaction { @@ -70,6 +82,48 @@ impl TransactionExt for Transaction { bincode::serialize(self) .map_err(|e| WasmSolanaError::new(&format!("Failed to serialize transaction: {}", e))) } + + fn signer_index(&self, pubkey: &str) -> Option { + let target_address = Address::from_str(pubkey).ok()?; + let num_signers = self.message.header.num_required_signatures as usize; + + // Use the same pattern as Solana's get_signing_keypair_positions + let signed_keys = &self.message.account_keys[0..num_signers]; + signed_keys.iter().position(|x| *x == target_address) + } + + fn add_signature( + &mut self, + pubkey: &str, + signature_bytes: &[u8], + ) -> Result<(), WasmSolanaError> { + // Validate signature length (Ed25519 signature is 64 bytes) + if signature_bytes.len() != 64 { + return Err(WasmSolanaError::new(&format!( + "Invalid signature length: expected 64 bytes, got {}", + signature_bytes.len() + ))); + } + + // Find the signer index using the same approach as Solana's get_signing_keypair_positions + let signer_idx = self + .signer_index(pubkey) + .ok_or_else(|| WasmSolanaError::new(&format!("unknown signer: {}", pubkey)))?; + + // Create signature from bytes + let signature = Signature::from(<[u8; 64]>::try_from(signature_bytes).unwrap()); + + // Ensure signatures array is properly sized (same as Solana's internal pattern) + let num_signers = self.message.header.num_required_signatures as usize; + if self.signatures.len() < num_signers { + self.signatures.resize(num_signers, Signature::default()); + } + + // Set the signature at the correct index (same pattern as try_partial_sign_unchecked) + self.signatures[signer_idx] = signature; + + Ok(()) + } } #[cfg(test)] @@ -137,4 +191,56 @@ mod tests { let result = Transaction::from_bytes(&[0, 1, 2, 3]); assert!(result.is_err()); } + + #[test] + fn test_signer_index() { + let tx = decode_test_tx(); + let fee_payer = tx.fee_payer_string().unwrap(); + + // Fee payer should be at index 0 + let idx = tx.signer_index(&fee_payer); + assert_eq!(idx, Some(0)); + + // Non-existent pubkey should return None + let fake_pubkey = "11111111111111111111111111111111"; + assert_eq!(tx.signer_index(fake_pubkey), None); + } + + #[test] + fn test_add_signature() { + let mut tx = decode_test_tx(); + let fee_payer = tx.fee_payer_string().unwrap(); + + // Create a dummy 64-byte signature + let signature = [42u8; 64]; + + // Add the signature + let result = tx.add_signature(&fee_payer, &signature); + assert!(result.is_ok()); + + // Verify the signature was added + assert_eq!(tx.signatures[0].as_ref(), &signature); + } + + #[test] + fn test_add_signature_invalid_length() { + let mut tx = decode_test_tx(); + let fee_payer = tx.fee_payer_string().unwrap(); + + // Try to add a signature with wrong length + let bad_signature = [0u8; 32]; + let result = tx.add_signature(&fee_payer, &bad_signature); + assert!(result.is_err()); + } + + #[test] + fn test_add_signature_invalid_pubkey() { + let mut tx = decode_test_tx(); + let signature = [0u8; 64]; + + // Try to add signature for non-signer pubkey + let non_signer = "11111111111111111111111111111111"; // System program + let result = tx.add_signature(non_signer, &signature); + assert!(result.is_err()); + } } diff --git a/packages/wasm-solana/src/versioned.rs b/packages/wasm-solana/src/versioned.rs new file mode 100644 index 00000000..5e8ad487 --- /dev/null +++ b/packages/wasm-solana/src/versioned.rs @@ -0,0 +1,341 @@ +//! Versioned transaction support for Solana. +//! +//! This module handles both legacy and versioned (MessageV0) transactions, +//! providing a unified interface for parsing and building. +//! +//! # Transaction Versions +//! +//! - **Legacy**: Original transaction format with all accounts inline +//! - **V0**: Versioned format with Address Lookup Tables (ALTs) for account compression +//! +//! # Wire Format Detection +//! +//! Legacy transactions start with a compact-u16 signature count. +//! Versioned transactions have a version byte with high bit set (0x80). + +use crate::error::WasmSolanaError; +use solana_address::Address; +use solana_message::VersionedMessage; +use solana_signature::Signature; +use solana_transaction::versioned::VersionedTransaction; +use std::str::FromStr; + +/// Transaction version enumeration. +/// +/// Note: Named `TxVersion` to avoid conflict with `solana_transaction::versioned::TxVersion` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TxVersion { + /// Legacy transaction format (pre-versioned) + Legacy, + /// Version 0 transaction with Address Lookup Tables + V0, +} + +/// Address Lookup Table data extracted from versioned transactions. +#[derive(Debug, Clone)] +pub struct AddressLookupTableData { + /// The lookup table account address (base58) + pub account_key: String, + /// Indices of writable accounts in the lookup table + pub writable_indexes: Vec, + /// Indices of readonly accounts in the lookup table + pub readonly_indexes: Vec, +} + +/// Detect the transaction version from raw bytes. +/// +/// # Wire Format +/// +/// - Legacy: Starts with compact-u16 for signature count (values 0-127 fit in single byte) +/// - Versioned: First byte has high bit set (0x80 = version 0) +/// +/// The version byte comes AFTER the signatures array in versioned transactions, +/// but the signature count encoding differs. +pub fn detect_transaction_version(bytes: &[u8]) -> TxVersion { + // Versioned transactions use a specific serialization format where + // after the signatures, the message version byte has high bit set. + // + // However, detecting this reliably requires parsing the signature count first. + // For simplicity, we try to deserialize as VersionedTransaction which handles both. + // + // The solana-transaction crate's VersionedTransaction can deserialize both formats. + // We detect based on the deserialized message type. + + if let Ok(tx) = bincode::deserialize::(bytes) { + match tx.message { + VersionedMessage::Legacy(_) => TxVersion::Legacy, + VersionedMessage::V0(_) => TxVersion::V0, + } + } else { + // If we can't deserialize, assume legacy (will fail later with proper error) + TxVersion::Legacy + } +} + +/// Extension trait for VersionedTransaction to add WASM-friendly methods. +pub trait VersionedTransactionExt { + /// Deserialize a transaction from raw bytes (handles both legacy and versioned). + fn from_bytes(bytes: &[u8]) -> Result; + + /// Check if this is a versioned transaction (MessageV0). + fn is_versioned(&self) -> bool; + + /// Get the transaction version as our TxVersion enum. + fn tx_version(&self) -> TxVersion; + + /// Get the fee payer address as base58 string. + fn fee_payer_string(&self) -> Option; + + /// Get the recent blockhash as base58 string. + fn blockhash_string(&self) -> String; + + /// Get the number of instructions. + fn num_instructions(&self) -> usize; + + /// Get the number of signatures. + fn num_signatures(&self) -> usize; + + /// Get the signable message bytes (what gets signed). + fn signable_payload(&self) -> Vec; + + /// Serialize transaction to bytes (wire format). + fn to_bytes(&self) -> Result, WasmSolanaError>; + + /// Get static account keys (accounts stored directly in the message). + fn static_account_keys(&self) -> Vec; + + /// Get Address Lookup Table data (empty for legacy transactions). + fn address_lookup_tables(&self) -> Vec; + + /// Add a signature for a given public key. + fn add_signature(&mut self, pubkey: &str, signature: &[u8]) -> Result<(), WasmSolanaError>; + + /// Get the index of a pubkey in the static account keys, if it's a signer. + fn signer_index(&self, pubkey: &str) -> Option; +} + +impl VersionedTransactionExt for VersionedTransaction { + fn from_bytes(bytes: &[u8]) -> Result { + bincode::deserialize(bytes).map_err(|e| { + WasmSolanaError::new(&format!( + "Failed to deserialize versioned transaction: {}", + e + )) + }) + } + + fn is_versioned(&self) -> bool { + matches!(self.message, VersionedMessage::V0(_)) + } + + fn tx_version(&self) -> TxVersion { + match &self.message { + VersionedMessage::Legacy(_) => TxVersion::Legacy, + VersionedMessage::V0(_) => TxVersion::V0, + } + } + + fn fee_payer_string(&self) -> Option { + match &self.message { + VersionedMessage::Legacy(msg) => msg.account_keys.first().map(|p| p.to_string()), + VersionedMessage::V0(msg) => msg.account_keys.first().map(|p| p.to_string()), + } + } + + fn blockhash_string(&self) -> String { + match &self.message { + VersionedMessage::Legacy(msg) => msg.recent_blockhash.to_string(), + VersionedMessage::V0(msg) => msg.recent_blockhash.to_string(), + } + } + + fn num_instructions(&self) -> usize { + match &self.message { + VersionedMessage::Legacy(msg) => msg.instructions.len(), + VersionedMessage::V0(msg) => msg.instructions.len(), + } + } + + fn num_signatures(&self) -> usize { + self.signatures.len() + } + + fn signable_payload(&self) -> Vec { + self.message.serialize() + } + + fn to_bytes(&self) -> Result, WasmSolanaError> { + bincode::serialize(self).map_err(|e| { + WasmSolanaError::new(&format!("Failed to serialize versioned transaction: {}", e)) + }) + } + + fn static_account_keys(&self) -> Vec { + match &self.message { + VersionedMessage::Legacy(msg) => { + msg.account_keys.iter().map(|k| k.to_string()).collect() + } + VersionedMessage::V0(msg) => msg.account_keys.iter().map(|k| k.to_string()).collect(), + } + } + + fn address_lookup_tables(&self) -> Vec { + match &self.message { + VersionedMessage::Legacy(_) => Vec::new(), + VersionedMessage::V0(msg) => msg + .address_table_lookups + .iter() + .map(|alt| AddressLookupTableData { + account_key: alt.account_key.to_string(), + writable_indexes: alt.writable_indexes.clone(), + readonly_indexes: alt.readonly_indexes.clone(), + }) + .collect(), + } + } + + fn signer_index(&self, pubkey: &str) -> Option { + let target_address = Address::from_str(pubkey).ok()?; + let (account_keys, num_signers) = match &self.message { + VersionedMessage::Legacy(msg) => ( + &msg.account_keys, + msg.header.num_required_signatures as usize, + ), + VersionedMessage::V0(msg) => ( + &msg.account_keys, + msg.header.num_required_signatures as usize, + ), + }; + + let signed_keys = &account_keys[0..num_signers]; + signed_keys.iter().position(|x| *x == target_address) + } + + fn add_signature( + &mut self, + pubkey: &str, + signature_bytes: &[u8], + ) -> Result<(), WasmSolanaError> { + // Validate signature length (Ed25519 signature is 64 bytes) + if signature_bytes.len() != 64 { + return Err(WasmSolanaError::new(&format!( + "Invalid signature length: expected 64 bytes, got {}", + signature_bytes.len() + ))); + } + + // Find the signer index + let signer_idx = self + .signer_index(pubkey) + .ok_or_else(|| WasmSolanaError::new(&format!("unknown signer: {}", pubkey)))?; + + // Create signature from bytes + let signature = Signature::from(<[u8; 64]>::try_from(signature_bytes).unwrap()); + + // Ensure signatures array is properly sized + let num_signers = match &self.message { + VersionedMessage::Legacy(msg) => msg.header.num_required_signatures as usize, + VersionedMessage::V0(msg) => msg.header.num_required_signatures as usize, + }; + + if self.signatures.len() < num_signers { + self.signatures.resize(num_signers, Signature::default()); + } + + // Set the signature at the correct index + self.signatures[signer_idx] = signature; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::prelude::*; + + // Legacy transaction from previous tests + const LEGACY_TX_BASE64: &str = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEDFVMqpim7tqEi2XL8R6KKkP0DYJvY3eiRXLlL1P9EjYgXKQC+k0FKnqyC4AZGJR7OhJXfpPP3NHOhS8t/6G7bLAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/1c7Oaj3RbyLIjU0/ZPpsmVfVUWAzc8g36fK5g6A0JoBAgIAAQwCAAAAoIYBAAAAAAA="; + + fn decode_legacy_tx() -> VersionedTransaction { + let bytes = BASE64_STANDARD.decode(LEGACY_TX_BASE64).unwrap(); + VersionedTransaction::from_bytes(&bytes).unwrap() + } + + #[test] + fn test_deserialize_legacy_as_versioned() { + let tx = decode_legacy_tx(); + + // Should parse as legacy + assert!(!tx.is_versioned()); + assert_eq!(tx.tx_version(), TxVersion::Legacy); + } + + #[test] + fn test_detect_version_legacy() { + let bytes = BASE64_STANDARD.decode(LEGACY_TX_BASE64).unwrap(); + let version = detect_transaction_version(&bytes); + assert_eq!(version, TxVersion::Legacy); + } + + #[test] + fn test_static_account_keys_legacy() { + let tx = decode_legacy_tx(); + let keys = tx.static_account_keys(); + + // Legacy transaction should have accounts inline + assert!(!keys.is_empty()); + } + + #[test] + fn test_address_lookup_tables_empty_for_legacy() { + let tx = decode_legacy_tx(); + let alts = tx.address_lookup_tables(); + + // Legacy transaction has no ALTs + assert!(alts.is_empty()); + } + + #[test] + fn test_fee_payer() { + let tx = decode_legacy_tx(); + let fee_payer = tx.fee_payer_string(); + assert!(fee_payer.is_some()); + } + + #[test] + fn test_blockhash() { + let tx = decode_legacy_tx(); + let blockhash = tx.blockhash_string(); + assert!(!blockhash.is_empty()); + } + + #[test] + fn test_signable_payload() { + let tx = decode_legacy_tx(); + let payload = tx.signable_payload(); + assert!(!payload.is_empty()); + } + + #[test] + fn test_roundtrip() { + let tx = decode_legacy_tx(); + let serialized = tx.to_bytes().unwrap(); + + let tx2 = VersionedTransaction::from_bytes(&serialized).unwrap(); + assert_eq!(tx.num_signatures(), tx2.num_signatures()); + assert_eq!(tx.num_instructions(), tx2.num_instructions()); + } + + #[test] + fn test_add_signature() { + let mut tx = decode_legacy_tx(); + let fee_payer = tx.fee_payer_string().unwrap(); + + let signature = [42u8; 64]; + let result = tx.add_signature(&fee_payer, &signature); + assert!(result.is_ok()); + + assert_eq!(tx.signatures[0].as_ref(), &signature); + } +} diff --git a/packages/wasm-solana/src/wasm/builder.rs b/packages/wasm-solana/src/wasm/builder.rs new file mode 100644 index 00000000..20d7996c --- /dev/null +++ b/packages/wasm-solana/src/wasm/builder.rs @@ -0,0 +1,109 @@ +//! WASM binding for transaction building. +//! +//! Exposes transaction building functions: +//! - `buildTransaction` - Creates transactions from a high-level intent structure +//! - `buildFromVersionedData` - Creates versioned transactions from raw MessageV0 data + +use crate::builder; +use wasm_bindgen::prelude::*; + +/// Namespace for transaction building operations. +#[wasm_bindgen] +pub struct BuilderNamespace; + +#[wasm_bindgen] +impl BuilderNamespace { + /// Build a Solana transaction from an intent structure. + /// + /// Takes a TransactionIntent JSON object and returns serialized transaction bytes. + /// + /// # Intent Structure + /// + /// ```json + /// { + /// "feePayer": "DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB", + /// "nonce": { + /// "type": "blockhash", + /// "value": "GWaQEymC3Z9SHM2gkh8u12xL1zJPMHPCSVR3pSDpEXE4" + /// }, + /// "instructions": [ + /// { "type": "transfer", "from": "...", "to": "...", "lamports": "1000000" }, + /// { "type": "memo", "message": "BitGo tx" } + /// ] + /// } + /// ``` + /// + /// # Instruction Types + /// + /// - `transfer`: SOL transfer (from, to, lamports) + /// - `createAccount`: Create new account (from, newAccount, lamports, space, owner) + /// - `nonceAdvance`: Advance durable nonce (nonce, authority) + /// - `nonceInitialize`: Initialize nonce account (nonce, authority) + /// - `allocate`: Allocate space (account, space) + /// - `assign`: Assign to program (account, owner) + /// - `memo`: Add memo (message) + /// - `computeBudget`: Set compute units (unitLimit, unitPrice) + /// + /// # Returns + /// + /// Serialized unsigned transaction bytes (Uint8Array). + /// The transaction will have empty signature placeholders that can be + /// filled in later by signing. + /// + /// @param intent - The transaction intent as a JSON object + /// @returns Serialized transaction bytes + #[wasm_bindgen] + pub fn build_transaction(intent: JsValue) -> Result, JsValue> { + // Deserialize the intent from JavaScript + let intent: builder::TransactionIntent = + serde_wasm_bindgen::from_value(intent).map_err(|e| { + JsValue::from_str(&format!("Failed to parse transaction intent: {}", e)) + })?; + + // Build the transaction + builder::build_transaction(intent).map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Build a versioned transaction directly from raw MessageV0 data. + /// + /// This function is used for the `fromVersionedTransactionData()` path where we already + /// have pre-compiled versioned data (indexes + ALT refs). No instruction compilation + /// is needed - we just serialize the raw structure to bytes. + /// + /// # Data Structure + /// + /// ```json + /// { + /// "staticAccountKeys": ["pubkey1", "pubkey2", ...], + /// "addressLookupTables": [ + /// { "accountKey": "altPubkey", "writableIndexes": [0, 1], "readonlyIndexes": [2] } + /// ], + /// "versionedInstructions": [ + /// { "programIdIndex": 0, "accountKeyIndexes": [1, 2], "data": "base58EncodedData" } + /// ], + /// "messageHeader": { + /// "numRequiredSignatures": 1, + /// "numReadonlySignedAccounts": 0, + /// "numReadonlyUnsignedAccounts": 3 + /// }, + /// "recentBlockhash": "blockhash" + /// } + /// ``` + /// + /// @param data - Raw versioned transaction data as a JSON object + /// @returns Serialized versioned transaction bytes (unsigned) + #[wasm_bindgen] + pub fn build_from_versioned_data(data: JsValue) -> Result, JsValue> { + // Deserialize the raw versioned data from JavaScript + let data: builder::RawVersionedTransactionData = serde_wasm_bindgen::from_value(data) + .map_err(|e| { + JsValue::from_str(&format!( + "Failed to parse versioned transaction data: {}", + e + )) + })?; + + // Build the versioned transaction + builder::build_from_raw_versioned_data(&data).map_err(|e| JsValue::from_str(&e.to_string())) + } +} diff --git a/packages/wasm-solana/src/wasm/constants.rs b/packages/wasm-solana/src/wasm/constants.rs new file mode 100644 index 00000000..ce83eca2 --- /dev/null +++ b/packages/wasm-solana/src/wasm/constants.rs @@ -0,0 +1,148 @@ +//! Program ID constants exported via WASM. +//! +//! These constants allow JavaScript code to reference well-known Solana program IDs +//! without needing to import @solana/web3.js. + +use wasm_bindgen::prelude::*; + +// Use re-exported constants from instructions module +use crate::instructions::{ + ATA_PROGRAM_ID, COMPUTE_BUDGET_PROGRAM_ID, MEMO_PROGRAM_ID, STAKE_POOL_PROGRAM_ID, + STAKE_PROGRAM_ID, SYSTEM_PROGRAM_ID, SYSVAR_RECENT_BLOCKHASHES, TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, +}; + +/// System Program ID +#[wasm_bindgen] +pub fn system_program_id() -> String { + SYSTEM_PROGRAM_ID.to_string() +} + +/// Stake Program ID +#[wasm_bindgen] +pub fn stake_program_id() -> String { + STAKE_PROGRAM_ID.to_string() +} + +/// Compute Budget Program ID +#[wasm_bindgen] +pub fn compute_budget_program_id() -> String { + COMPUTE_BUDGET_PROGRAM_ID.to_string() +} + +/// Memo Program ID +#[wasm_bindgen] +pub fn memo_program_id() -> String { + MEMO_PROGRAM_ID.to_string() +} + +/// Token Program ID (SPL Token) +#[wasm_bindgen] +pub fn token_program_id() -> String { + TOKEN_PROGRAM_ID.to_string() +} + +/// Token 2022 Program ID +#[wasm_bindgen] +pub fn token_2022_program_id() -> String { + TOKEN_2022_PROGRAM_ID.to_string() +} + +/// Associated Token Account Program ID +#[wasm_bindgen] +pub fn ata_program_id() -> String { + ATA_PROGRAM_ID.to_string() +} + +/// Stake Pool Program ID (Jito) +#[wasm_bindgen] +pub fn stake_pool_program_id() -> String { + STAKE_POOL_PROGRAM_ID.to_string() +} + +/// Sysvar Recent Blockhashes address +/// Reference: https://github.com/solana-labs/solana/blob/v1.18.26/sdk/program/src/sysvar/recent_blockhashes.rs +#[wasm_bindgen] +pub fn sysvar_recent_blockhashes() -> String { + SYSVAR_RECENT_BLOCKHASHES.to_string() +} + +/// Stake account space in bytes (200) +#[wasm_bindgen] +pub fn stake_account_space() -> u64 { + 200 +} + +/// Nonce account space in bytes (80) +#[wasm_bindgen] +pub fn nonce_account_space() -> u64 { + 80 +} + +/// Derive the Associated Token Account address for a given wallet and mint. +/// +/// This allows JavaScript code to compute ATA addresses without needing @solana/web3.js. +/// The ATA is a PDA derived from seeds: [wallet_address, token_program_id, mint_address] +/// +/// @param wallet_address - Owner wallet address (base58) +/// @param mint_address - Token mint address (base58) +/// @param token_program_id - Token program ID (base58), use TOKEN_PROGRAM_ID or TOKEN_2022_PROGRAM_ID +/// @returns The derived ATA address (base58) +#[wasm_bindgen] +pub fn get_associated_token_address( + wallet_address: &str, + mint_address: &str, + token_program_id: &str, +) -> Result { + use solana_sdk::pubkey::Pubkey; + + let wallet: Pubkey = wallet_address + .parse() + .map_err(|_| JsValue::from_str(&format!("Invalid wallet address: {}", wallet_address)))?; + let mint: Pubkey = mint_address + .parse() + .map_err(|_| JsValue::from_str(&format!("Invalid mint address: {}", mint_address)))?; + let token_program: Pubkey = token_program_id.parse().map_err(|_| { + JsValue::from_str(&format!("Invalid token program ID: {}", token_program_id)) + })?; + + // ATA PDA derivation: seeds = [wallet, token_program, mint], program = ATA_PROGRAM + let ata_program: Pubkey = ATA_PROGRAM_ID + .parse() + .map_err(|_| JsValue::from_str("Failed to parse ATA program ID"))?; + + let seeds = &[wallet.as_ref(), token_program.as_ref(), mint.as_ref()]; + let (ata, _bump) = Pubkey::find_program_address(seeds, &ata_program); + + Ok(ata.to_string()) +} + +/// Derive the Stake Pool withdraw authority PDA. +/// +/// This allows JavaScript code to compute the withdraw authority without needing @solana/spl-stake-pool. +/// The withdraw authority is a PDA derived from seeds: ["withdraw", stake_pool_address] +/// +/// @param stake_pool_address - Stake pool address (base58) +/// @returns The derived withdraw authority address (base58) +#[wasm_bindgen] +pub fn find_withdraw_authority_program_address( + stake_pool_address: &str, +) -> Result { + use solana_sdk::pubkey::Pubkey; + + let stake_pool: Pubkey = stake_pool_address.parse().map_err(|_| { + JsValue::from_str(&format!( + "Invalid stake pool address: {}", + stake_pool_address + )) + })?; + + let stake_pool_program: Pubkey = STAKE_POOL_PROGRAM_ID + .parse() + .map_err(|_| JsValue::from_str("Failed to parse stake pool program ID"))?; + + let seeds = &[stake_pool.as_ref(), b"withdraw".as_ref()]; + let (withdraw_authority, _bump) = Pubkey::find_program_address(seeds, &stake_pool_program); + + Ok(withdraw_authority.to_string()) +} diff --git a/packages/wasm-solana/src/wasm/mod.rs b/packages/wasm-solana/src/wasm/mod.rs index 7aa90e5f..46e2ecf6 100644 --- a/packages/wasm-solana/src/wasm/mod.rs +++ b/packages/wasm-solana/src/wasm/mod.rs @@ -1,10 +1,16 @@ +mod builder; +mod constants; mod keypair; mod parser; mod pubkey; mod transaction; pub mod try_into_js_value; +pub use builder::BuilderNamespace; pub use keypair::WasmKeypair; pub use parser::ParserNamespace; pub use pubkey::WasmPubkey; -pub use transaction::WasmTransaction; +pub use transaction::{is_versioned_transaction, WasmTransaction, WasmVersionedTransaction}; + +// Re-export constants functions +pub use constants::*; diff --git a/packages/wasm-solana/src/wasm/transaction.rs b/packages/wasm-solana/src/wasm/transaction.rs index 4d6c2954..803e688f 100644 --- a/packages/wasm-solana/src/wasm/transaction.rs +++ b/packages/wasm-solana/src/wasm/transaction.rs @@ -1,18 +1,22 @@ //! WASM bindings for Solana transaction deserialization. //! -//! Wraps `solana_transaction::Transaction` for JavaScript. +//! Wraps `solana_transaction::Transaction` and `VersionedTransaction` for JavaScript. //! //! Note: For semantic transaction parsing with decoded instructions, //! use `ParserNamespace.parse_transaction()` instead. use crate::error::WasmSolanaError; use crate::transaction::{Transaction, TransactionExt}; +use crate::versioned::{detect_transaction_version, TxVersion, VersionedTransactionExt}; +use solana_message::VersionedMessage; +use solana_transaction::versioned::VersionedTransaction; use wasm_bindgen::prelude::*; /// WASM wrapper for Solana transactions. /// -/// This type provides low-level access to transaction structure. -/// For high-level semantic parsing, use `ParserNamespace.parse_transaction()`. +/// This type provides low-level access to transaction structure and +/// signature manipulation. For high-level semantic parsing, use +/// `ParserNamespace.parse_transaction()`. #[wasm_bindgen] pub struct WasmTransaction { inner: Transaction, @@ -91,6 +95,27 @@ impl WasmTransaction { arr } + /// Add a signature for a given public key. + /// + /// The pubkey must be one of the required signers in the transaction. + /// The signature must be exactly 64 bytes (Ed25519 signature). + /// + /// @param pubkey - The public key as a base58 string + /// @param signature - The 64-byte signature + #[wasm_bindgen] + pub fn add_signature(&mut self, pubkey: &str, signature: &[u8]) -> Result<(), WasmSolanaError> { + self.inner.add_signature(pubkey, signature) + } + + /// Check if a public key is a required signer for this transaction. + /// + /// @param pubkey - The public key as a base58 string + /// @returns The signer index if the pubkey is a signer, null otherwise + #[wasm_bindgen] + pub fn signer_index(&self, pubkey: &str) -> Option { + self.inner.signer_index(pubkey) + } + /// Get all instructions as an array. /// /// Each instruction is a JS object with programId, accounts, and data. @@ -152,3 +177,224 @@ impl WasmTransaction { &self.inner } } + +// ============================================================================ +// Versioned Transaction Support +// ============================================================================ + +/// Detect if transaction bytes represent a versioned transaction. +/// +/// @param bytes - Raw transaction bytes +/// @returns true if versioned (MessageV0), false if legacy +#[wasm_bindgen] +pub fn is_versioned_transaction(bytes: &[u8]) -> bool { + detect_transaction_version(bytes) == TxVersion::V0 +} + +/// WASM wrapper for Solana versioned transactions. +/// +/// Handles both legacy and versioned (MessageV0) transactions with +/// Address Lookup Tables (ALTs). +#[wasm_bindgen] +pub struct WasmVersionedTransaction { + inner: VersionedTransaction, +} + +#[wasm_bindgen] +impl WasmVersionedTransaction { + /// Deserialize a transaction from raw bytes. + /// + /// Automatically handles both legacy and versioned formats. + #[wasm_bindgen] + pub fn from_bytes(bytes: &[u8]) -> Result { + VersionedTransaction::from_bytes(bytes).map(|inner| WasmVersionedTransaction { inner }) + } + + /// Check if this is a versioned transaction (MessageV0). + /// + /// @returns true for MessageV0, false for legacy + #[wasm_bindgen(getter)] + pub fn is_versioned(&self) -> bool { + self.inner.is_versioned() + } + + /// Get the fee payer address as a base58 string. + #[wasm_bindgen(getter)] + pub fn fee_payer(&self) -> Option { + self.inner.fee_payer_string() + } + + /// Get the recent blockhash as a base58 string. + #[wasm_bindgen(getter)] + pub fn recent_blockhash(&self) -> String { + self.inner.blockhash_string() + } + + /// Get the number of instructions. + #[wasm_bindgen(getter)] + pub fn num_instructions(&self) -> usize { + self.inner.num_instructions() + } + + /// Get the number of signatures. + #[wasm_bindgen(getter)] + pub fn num_signatures(&self) -> usize { + self.inner.num_signatures() + } + + /// Get the signable message payload. + #[wasm_bindgen] + pub fn signable_payload(&self) -> js_sys::Uint8Array { + let bytes = self.inner.signable_payload(); + js_sys::Uint8Array::from(&bytes[..]) + } + + /// Serialize the transaction to bytes. + #[wasm_bindgen] + pub fn to_bytes(&self) -> Result { + let bytes = self.inner.to_bytes()?; + Ok(js_sys::Uint8Array::from(&bytes[..])) + } + + /// Get static account keys (accounts stored directly in the message). + /// + /// For versioned transactions, additional accounts may be referenced + /// via Address Lookup Tables. + #[wasm_bindgen] + pub fn static_account_keys(&self) -> js_sys::Array { + let arr = js_sys::Array::new(); + for key in self.inner.static_account_keys() { + arr.push(&JsValue::from_str(&key)); + } + arr + } + + /// Get Address Lookup Table data. + /// + /// Returns an array of ALT objects, each containing: + /// - accountKey: The lookup table account address + /// - writableIndexes: Indices of writable accounts in the table + /// - readonlyIndexes: Indices of readonly accounts in the table + /// + /// For legacy transactions, returns an empty array. + #[wasm_bindgen] + pub fn address_lookup_tables(&self) -> js_sys::Array { + let arr = js_sys::Array::new(); + for alt in self.inner.address_lookup_tables() { + let obj = js_sys::Object::new(); + let _ = js_sys::Reflect::set(&obj, &"accountKey".into(), &alt.account_key.into()); + let _ = js_sys::Reflect::set( + &obj, + &"writableIndexes".into(), + &js_sys::Uint8Array::from(&alt.writable_indexes[..]), + ); + let _ = js_sys::Reflect::set( + &obj, + &"readonlyIndexes".into(), + &js_sys::Uint8Array::from(&alt.readonly_indexes[..]), + ); + arr.push(&obj); + } + arr + } + + /// Get all signatures as an array of byte arrays. + #[wasm_bindgen] + pub fn signatures(&self) -> js_sys::Array { + let arr = js_sys::Array::new(); + for sig in &self.inner.signatures { + let bytes: &[u8] = sig.as_ref(); + arr.push(&js_sys::Uint8Array::from(bytes)); + } + arr + } + + /// Add a signature for a given public key. + /// + /// @param pubkey - The public key as a base58 string + /// @param signature - The 64-byte signature + #[wasm_bindgen] + pub fn add_signature(&mut self, pubkey: &str, signature: &[u8]) -> Result<(), WasmSolanaError> { + self.inner.add_signature(pubkey, signature) + } + + /// Check if a public key is a required signer. + /// + /// @returns The signer index if the pubkey is a signer, null otherwise + #[wasm_bindgen] + pub fn signer_index(&self, pubkey: &str) -> Option { + self.inner.signer_index(pubkey) + } + + /// Get all instructions as an array. + /// + /// Note: For versioned transactions with ALTs, account indices may + /// reference accounts beyond static_account_keys. Use address_lookup_tables() + /// to resolve additional accounts. + #[wasm_bindgen] + pub fn instructions(&self) -> js_sys::Array { + let arr = js_sys::Array::new(); + + let (account_keys, instructions, header) = match &self.inner.message { + VersionedMessage::Legacy(msg) => (&msg.account_keys, &msg.instructions, &msg.header), + VersionedMessage::V0(msg) => (&msg.account_keys, &msg.instructions, &msg.header), + }; + + for instruction in instructions { + let obj = js_sys::Object::new(); + + // Get the program ID + if let Some(program_id) = account_keys.get(instruction.program_id_index as usize) { + let _ = + js_sys::Reflect::set(&obj, &"programId".into(), &program_id.to_string().into()); + } + + // Build accounts array + let accounts = js_sys::Array::new(); + for &account_index in &instruction.accounts { + let account_obj = js_sys::Object::new(); + let _ = js_sys::Reflect::set( + &account_obj, + &"index".into(), + &(account_index as u32).into(), + ); + + // Get pubkey if it's a static account (index within static keys) + if let Some(pubkey) = account_keys.get(account_index as usize) { + let _ = js_sys::Reflect::set( + &account_obj, + &"pubkey".into(), + &pubkey.to_string().into(), + ); + let _ = + js_sys::Reflect::set(&account_obj, &"isLookupTable".into(), &false.into()); + } else { + // Account is from an Address Lookup Table + let _ = + js_sys::Reflect::set(&account_obj, &"isLookupTable".into(), &true.into()); + } + + // Determine if signer/writable based on index position + let is_signer = (account_index as usize) < header.num_required_signatures as usize; + let _ = js_sys::Reflect::set(&account_obj, &"isSigner".into(), &is_signer.into()); + + accounts.push(&account_obj); + } + let _ = js_sys::Reflect::set(&obj, &"accounts".into(), &accounts); + + // Set instruction data + let data = js_sys::Uint8Array::from(&instruction.data[..]); + let _ = js_sys::Reflect::set(&obj, &"data".into(), &data); + + arr.push(&obj); + } + arr + } +} + +impl WasmVersionedTransaction { + /// Get the inner VersionedTransaction for internal Rust use. + pub fn inner(&self) -> &VersionedTransaction { + &self.inner + } +} diff --git a/packages/wasm-solana/test/builder.ts b/packages/wasm-solana/test/builder.ts new file mode 100644 index 00000000..8aaa18fe --- /dev/null +++ b/packages/wasm-solana/test/builder.ts @@ -0,0 +1,693 @@ +import * as assert from "assert"; +import { + buildTransaction, + parseTransaction, + type TransactionIntent, + type BuilderInstruction, +} from "../js/index.js"; + +describe("buildTransaction", () => { + // Test addresses from BitGoJS sdk-coin-sol/test/resources/sol.ts + const AUTH_ACCOUNT = "5hr5fisPi6DXNuuRpm5XUbzpiEnmdyxXuBDTwzwZj5Pe"; // authAccount.pub + const RECIPIENT = "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH"; // accountWithSeed.publicKey + const NONCE_ACCOUNT = "8Y7RM6JfcX4ASSNBkrkrmSbRu431YVi9Y3oLFnzC2dCh"; // nonceAccount.pub + const BLOCKHASH = "5ne7phA48Jrvpn39AtupB8ZkCCAy8gLTfpGihZPuDqen"; // blockHashes.validBlockHashes[0] + const STAKE_ACCOUNT = "3c5emUWjViFqT72LxQYec8gkU8ZtmfKKXHvGgJNUBdYx"; // stakeAccount.pub + + // Aliases for clarity + const SENDER = AUTH_ACCOUNT; + + describe("simple transfer", () => { + it("should build a SOL transfer transaction", () => { + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [{ type: "transfer", from: SENDER, to: RECIPIENT, lamports: 1000000n }], + }; + + const txBytes = buildTransaction(intent); + assert.ok(txBytes instanceof Uint8Array); + assert.ok(txBytes.length > 0); + + // Parse it back to verify structure + const parsed = parseTransaction(txBytes); + assert.strictEqual(parsed.feePayer, SENDER); + assert.strictEqual(parsed.nonce, BLOCKHASH); + assert.strictEqual(parsed.instructionsData.length, 1); + assert.strictEqual(parsed.instructionsData[0].type, "Transfer"); + }); + + it("should parse the transfer instruction correctly", () => { + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [ + { + type: "transfer", + from: SENDER, + to: RECIPIENT, + lamports: 1000000n, + }, + ], + }; + + const txBytes = buildTransaction(intent); + const parsed = parseTransaction(txBytes); + + const transfer = parsed.instructionsData[0]; + assert.strictEqual(transfer.type, "Transfer"); + if (transfer.type === "Transfer") { + // Parser uses fromAddress/toAddress/amount + assert.strictEqual(transfer.fromAddress, SENDER); + assert.strictEqual(transfer.toAddress, RECIPIENT); + assert.strictEqual(transfer.amount, 1000000n); + } + }); + }); + + describe("transfer with memo", () => { + it("should build a transfer with memo", () => { + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [ + { type: "transfer", from: SENDER, to: RECIPIENT, lamports: 1000000n }, + { type: "memo", message: "BitGo transfer" }, + ], + }; + + const txBytes = buildTransaction(intent); + const parsed = parseTransaction(txBytes); + + assert.strictEqual(parsed.instructionsData.length, 2); + assert.strictEqual(parsed.instructionsData[0].type, "Transfer"); + assert.strictEqual(parsed.instructionsData[1].type, "Memo"); + + const memo = parsed.instructionsData[1]; + if (memo.type === "Memo") { + // Parser uses 'memo' field + assert.strictEqual(memo.memo, "BitGo transfer"); + } + }); + }); + + describe("compute budget", () => { + it("should build with compute unit limit", () => { + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [ + { type: "computeBudget", unitLimit: 200000 }, + { type: "transfer", from: SENDER, to: RECIPIENT, lamports: 1000000n }, + ], + }; + + const txBytes = buildTransaction(intent); + const parsed = parseTransaction(txBytes); + + assert.strictEqual(parsed.instructionsData.length, 2); + assert.strictEqual(parsed.instructionsData[0].type, "SetComputeUnitLimit"); + assert.strictEqual(parsed.instructionsData[1].type, "Transfer"); + + const computeBudget = parsed.instructionsData[0]; + if (computeBudget.type === "SetComputeUnitLimit") { + assert.strictEqual(computeBudget.units, 200000); + } + }); + + it("should build with compute unit price (priority fee)", () => { + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [ + { type: "computeBudget", unitPrice: 5000 }, + { type: "transfer", from: SENDER, to: RECIPIENT, lamports: 1000000n }, + ], + }; + + const txBytes = buildTransaction(intent); + const parsed = parseTransaction(txBytes); + + assert.strictEqual(parsed.instructionsData.length, 2); + assert.strictEqual(parsed.instructionsData[0].type, "SetPriorityFee"); + assert.strictEqual(parsed.instructionsData[1].type, "Transfer"); + + const priorityFee = parsed.instructionsData[0]; + if (priorityFee.type === "SetPriorityFee") { + // Parser uses 'fee' as BigInt + assert.strictEqual(priorityFee.fee, BigInt(5000)); + } + }); + }); + + describe("durable nonce", () => { + it("should prepend nonce advance instruction for durable nonce", () => { + // Use BitGoJS nonceAccount.pub and a sample nonce value + const NONCE_AUTHORITY = SENDER; + // This is the nonce value stored in the nonce account (becomes the blockhash) + const NONCE_VALUE = "GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi"; + + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { + type: "durable", + address: NONCE_ACCOUNT, + authority: NONCE_AUTHORITY, + value: NONCE_VALUE, + }, + instructions: [{ type: "transfer", from: SENDER, to: RECIPIENT, lamports: 1000000n }], + }; + + const txBytes = buildTransaction(intent); + const parsed = parseTransaction(txBytes); + + // Should have 2 instructions: NonceAdvance + Transfer + assert.strictEqual(parsed.instructionsData.length, 2); + assert.strictEqual(parsed.instructionsData[0].type, "NonceAdvance"); + assert.strictEqual(parsed.instructionsData[1].type, "Transfer"); + + // Verify nonce advance params + const nonceAdvance = parsed.instructionsData[0]; + if (nonceAdvance.type === "NonceAdvance") { + // Parser uses walletNonceAddress/authWalletAddress + assert.strictEqual(nonceAdvance.walletNonceAddress, NONCE_ACCOUNT); + assert.strictEqual(nonceAdvance.authWalletAddress, NONCE_AUTHORITY); + } + }); + }); + + describe("create account", () => { + it("should build create account instruction", () => { + // Use BitGoJS stakeAccount.pub as the new account + const NEW_ACCOUNT = STAKE_ACCOUNT; + const SYSTEM_PROGRAM = "11111111111111111111111111111111"; + + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [ + { + type: "createAccount", + from: SENDER, + newAccount: NEW_ACCOUNT, + lamports: 1000000n, + space: 165, + owner: SYSTEM_PROGRAM, + }, + ], + }; + + const txBytes = buildTransaction(intent); + const parsed = parseTransaction(txBytes); + + assert.strictEqual(parsed.instructionsData.length, 1); + assert.strictEqual(parsed.instructionsData[0].type, "CreateAccount"); + + const createAccount = parsed.instructionsData[0]; + if (createAccount.type === "CreateAccount") { + // Parser uses fromAddress/newAddress/amount/space/owner + assert.strictEqual(createAccount.fromAddress, SENDER); + assert.strictEqual(createAccount.newAddress, NEW_ACCOUNT); + assert.strictEqual(createAccount.amount, 1000000n); + assert.strictEqual(createAccount.space, 165n); + assert.strictEqual(createAccount.owner, SYSTEM_PROGRAM); + } + }); + }); + + describe("error handling", () => { + it("should reject invalid public key", () => { + const intent: TransactionIntent = { + feePayer: "invalid", + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [], + }; + + assert.throws(() => buildTransaction(intent), /Invalid fee_payer/); + }); + + it("should reject invalid blockhash", () => { + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: "invalid" }, + instructions: [], + }; + + assert.throws(() => buildTransaction(intent), /Invalid blockhash/); + }); + + it("should reject computeBudget without unitLimit or unitPrice", () => { + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [{ type: "computeBudget" } as BuilderInstruction], + }; + + assert.throws(() => buildTransaction(intent), /ComputeBudget.*unitLimit.*unitPrice/); + }); + }); + + describe("roundtrip", () => { + it("should produce consistent bytes on rebuild", () => { + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [ + { type: "transfer", from: SENDER, to: RECIPIENT, lamports: 1000000n }, + { type: "memo", message: "Test" }, + ], + }; + + const txBytes1 = buildTransaction(intent); + const txBytes2 = buildTransaction(intent); + + assert.deepStrictEqual(txBytes1, txBytes2); + }); + }); + + // ===== Stake Program Tests ===== + describe("stake program", () => { + // From BitGoJS test/resources/sol.ts + const VALIDATOR = "CyjoLt3kjqB57K7ewCBHmnHq3UgEj3ak6A7m6EsBsuhA"; // validator.pub + + it("should build stake initialize instruction", () => { + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [ + { + type: "stakeInitialize", + stake: STAKE_ACCOUNT, + staker: SENDER, + withdrawer: SENDER, + }, + ], + }; + + const txBytes = buildTransaction(intent); + const parsed = parseTransaction(txBytes); + + assert.strictEqual(parsed.instructionsData.length, 1); + assert.strictEqual(parsed.instructionsData[0].type, "StakeInitialize"); + + const stakeInit = parsed.instructionsData[0]; + if (stakeInit.type === "StakeInitialize") { + assert.strictEqual(stakeInit.stakingAddress, STAKE_ACCOUNT); + assert.strictEqual(stakeInit.staker, SENDER); + assert.strictEqual(stakeInit.withdrawer, SENDER); + } + }); + + it("should build stake delegate instruction", () => { + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [ + { + type: "stakeDelegate", + stake: STAKE_ACCOUNT, + vote: VALIDATOR, + authority: SENDER, + }, + ], + }; + + const txBytes = buildTransaction(intent); + const parsed = parseTransaction(txBytes); + + assert.strictEqual(parsed.instructionsData.length, 1); + assert.strictEqual(parsed.instructionsData[0].type, "StakingDelegate"); + + const stakeDelegate = parsed.instructionsData[0]; + if (stakeDelegate.type === "StakingDelegate") { + assert.strictEqual(stakeDelegate.stakingAddress, STAKE_ACCOUNT); + assert.strictEqual(stakeDelegate.validator, VALIDATOR); + assert.strictEqual(stakeDelegate.fromAddress, SENDER); + } + }); + + it("should build stake deactivate instruction", () => { + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [ + { + type: "stakeDeactivate", + stake: STAKE_ACCOUNT, + authority: SENDER, + }, + ], + }; + + const txBytes = buildTransaction(intent); + const parsed = parseTransaction(txBytes); + + assert.strictEqual(parsed.instructionsData.length, 1); + assert.strictEqual(parsed.instructionsData[0].type, "StakingDeactivate"); + + const stakeDeactivate = parsed.instructionsData[0]; + if (stakeDeactivate.type === "StakingDeactivate") { + assert.strictEqual(stakeDeactivate.stakingAddress, STAKE_ACCOUNT); + assert.strictEqual(stakeDeactivate.fromAddress, SENDER); + } + }); + + it("should build stake withdraw instruction", () => { + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [ + { + type: "stakeWithdraw", + stake: STAKE_ACCOUNT, + recipient: RECIPIENT, + lamports: 300000n, + authority: SENDER, + }, + ], + }; + + const txBytes = buildTransaction(intent); + const parsed = parseTransaction(txBytes); + + assert.strictEqual(parsed.instructionsData.length, 1); + assert.strictEqual(parsed.instructionsData[0].type, "StakingWithdraw"); + + const stakeWithdraw = parsed.instructionsData[0]; + if (stakeWithdraw.type === "StakingWithdraw") { + assert.strictEqual(stakeWithdraw.stakingAddress, STAKE_ACCOUNT); + assert.strictEqual(stakeWithdraw.fromAddress, SENDER); + assert.strictEqual(stakeWithdraw.amount, 300000n); + } + }); + + it("should build full staking activate flow", () => { + // Typical staking activate: CreateAccount + StakeInitialize + StakeDelegate + // The parser combines these into a single StakingActivate instruction + const STAKE_PROGRAM = "Stake11111111111111111111111111111111111111"; + + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [ + { + type: "createAccount", + from: SENDER, + newAccount: STAKE_ACCOUNT, + lamports: 300000n, + space: 200, // Stake account size + owner: STAKE_PROGRAM, + }, + { + type: "stakeInitialize", + stake: STAKE_ACCOUNT, + staker: SENDER, + withdrawer: SENDER, + }, + { + type: "stakeDelegate", + stake: STAKE_ACCOUNT, + vote: VALIDATOR, + authority: SENDER, + }, + ], + }; + + const txBytes = buildTransaction(intent); + const parsed = parseTransaction(txBytes); + + // Parser returns individual instructions; combining is done in BitGoJS wasmInstructionCombiner + assert.strictEqual(parsed.instructionsData.length, 3); + assert.strictEqual(parsed.instructionsData[0].type, "CreateAccount"); + assert.strictEqual(parsed.instructionsData[1].type, "StakeInitialize"); + assert.strictEqual(parsed.instructionsData[2].type, "StakingDelegate"); + + // Verify CreateAccount details + const createAccount = parsed.instructionsData[0]; + if (createAccount.type === "CreateAccount") { + assert.strictEqual(createAccount.fromAddress, SENDER); + assert.strictEqual(createAccount.newAddress, STAKE_ACCOUNT); + assert.strictEqual(createAccount.amount, 300000n); + } + + // Verify StakingDelegate details + const stakeDelegate = parsed.instructionsData[2]; + if (stakeDelegate.type === "StakingDelegate") { + assert.strictEqual(stakeDelegate.stakingAddress, STAKE_ACCOUNT); + assert.strictEqual(stakeDelegate.validator, VALIDATOR); + } + }); + }); + + // ===== SPL Token Tests ===== + describe("spl token", () => { + // From BitGoJS test/resources/sol.ts + const MINT_USDC = "F4uLeXJoFz3hw13MposuwaQbMcZbCjqvEGPPeRRB1Byf"; // tokenTransfers.mintUSDC + const SOURCE_ATA = "2fyhC1YbqaYszkUQw2YGNRVkr2abr69UwFXVCjz4Q5f5"; // tokenTransfers.sourceUSDC + const DEST_ATA = "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH"; + + it("should build token transfer instruction", () => { + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [ + { + type: "tokenTransfer", + source: SOURCE_ATA, + destination: DEST_ATA, + mint: MINT_USDC, + amount: 300000n, + decimals: 9, + authority: SENDER, + }, + ], + }; + + const txBytes = buildTransaction(intent); + const parsed = parseTransaction(txBytes); + + assert.strictEqual(parsed.instructionsData.length, 1); + assert.strictEqual(parsed.instructionsData[0].type, "TokenTransfer"); + + const tokenTransfer = parsed.instructionsData[0]; + if (tokenTransfer.type === "TokenTransfer") { + assert.strictEqual(tokenTransfer.sourceAddress, SOURCE_ATA); + assert.strictEqual(tokenTransfer.toAddress, DEST_ATA); + assert.strictEqual(tokenTransfer.amount, 300000n); + assert.strictEqual(tokenTransfer.tokenAddress, MINT_USDC); + assert.strictEqual(tokenTransfer.decimalPlaces, 9); + } + }); + + it("should build create associated token account instruction", () => { + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [ + { + type: "createAssociatedTokenAccount", + payer: SENDER, + owner: RECIPIENT, + mint: MINT_USDC, + }, + ], + }; + + const txBytes = buildTransaction(intent); + const parsed = parseTransaction(txBytes); + + assert.strictEqual(parsed.instructionsData.length, 1); + assert.strictEqual(parsed.instructionsData[0].type, "CreateAssociatedTokenAccount"); + + const createAta = parsed.instructionsData[0]; + if (createAta.type === "CreateAssociatedTokenAccount") { + assert.strictEqual(createAta.payerAddress, SENDER); + assert.strictEqual(createAta.ownerAddress, RECIPIENT); + assert.strictEqual(createAta.mintAddress, MINT_USDC); + } + }); + + it("should build close associated token account instruction", () => { + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [ + { + type: "closeAssociatedTokenAccount", + account: SOURCE_ATA, + destination: SENDER, + authority: SENDER, + }, + ], + }; + + const txBytes = buildTransaction(intent); + const parsed = parseTransaction(txBytes); + + assert.strictEqual(parsed.instructionsData.length, 1); + assert.strictEqual(parsed.instructionsData[0].type, "CloseAssociatedTokenAccount"); + + const closeAta = parsed.instructionsData[0]; + if (closeAta.type === "CloseAssociatedTokenAccount") { + assert.strictEqual(closeAta.accountAddress, SOURCE_ATA); + assert.strictEqual(closeAta.destinationAddress, SENDER); + assert.strictEqual(closeAta.authorityAddress, SENDER); + } + }); + + it("should build token transfer with create ATA", () => { + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [ + { + type: "createAssociatedTokenAccount", + payer: SENDER, + owner: RECIPIENT, + mint: MINT_USDC, + }, + { + type: "tokenTransfer", + source: SOURCE_ATA, + destination: DEST_ATA, + mint: MINT_USDC, + amount: 300000n, + decimals: 9, + authority: SENDER, + }, + { type: "memo", message: "test memo" }, + ], + }; + + const txBytes = buildTransaction(intent); + const parsed = parseTransaction(txBytes); + + assert.strictEqual(parsed.instructionsData.length, 3); + assert.strictEqual(parsed.instructionsData[0].type, "CreateAssociatedTokenAccount"); + assert.strictEqual(parsed.instructionsData[1].type, "TokenTransfer"); + assert.strictEqual(parsed.instructionsData[2].type, "Memo"); + }); + }); + + // ===== Jito Stake Pool Tests ===== + describe("jito stake pool", () => { + // From BitGoJS Jito constants + const JITO_STAKE_POOL = "Jito4APyf642JPZPx3hGc6WWJ8zPKtRbRs4P815Awbb"; + const JITO_WITHDRAW_AUTHORITY = "6iQKfEyhr3bZMotVkW6beNZz5CPAkiwvgV2CTje9pVSS"; + const JITO_RESERVE_STAKE = "BgKUXdS4Wy6Vdgp1jwT2dz5ZgxPG94aPL77dQscSPGmc"; + const JITO_POOL_MINT = "J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn"; // JitoSOL + const MANAGER_FEE_ACCOUNT = "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN"; + const VALIDATOR_LIST = "3R3nGZpQs2aZo5FDQvd2MUQ5R5E9g7NvHQaxpLPYA8r2"; + const VALIDATOR_STAKE = "BgKUXdS4Wy6Vdgp1jwT2dz5ZgxPG94aPL77dQscSPGmc"; + const DEST_STAKE = "FKjSjCqByQRwSzZoMXA7bKnDbJe41YgJTHFFzBeC42bH"; + const SOURCE_POOL_ACCOUNT = "5ZWgXcyqrrNpQHCme5SdC5hCeYb2o3fEJhF7Gok3bTVN"; + + it("should build stake pool deposit sol instruction", () => { + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [ + { + type: "stakePoolDepositSol", + stakePool: JITO_STAKE_POOL, + withdrawAuthority: JITO_WITHDRAW_AUTHORITY, + reserveStake: JITO_RESERVE_STAKE, + fundingAccount: SENDER, + destinationPoolAccount: SOURCE_POOL_ACCOUNT, + managerFeeAccount: MANAGER_FEE_ACCOUNT, + referralPoolAccount: MANAGER_FEE_ACCOUNT, + poolMint: JITO_POOL_MINT, + lamports: 300000n, + }, + ], + }; + + const txBytes = buildTransaction(intent); + const parsed = parseTransaction(txBytes); + + assert.strictEqual(parsed.instructionsData.length, 1); + assert.strictEqual(parsed.instructionsData[0].type, "StakePoolDepositSol"); + + const depositSol = parsed.instructionsData[0]; + if (depositSol.type === "StakePoolDepositSol") { + assert.strictEqual(depositSol.stakePool, JITO_STAKE_POOL); + assert.strictEqual(depositSol.fundingAccount, SENDER); + assert.strictEqual(depositSol.poolMint, JITO_POOL_MINT); + assert.strictEqual(depositSol.lamports, 300000n); + } + }); + + it("should build stake pool withdraw stake instruction", () => { + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [ + { + type: "stakePoolWithdrawStake", + stakePool: JITO_STAKE_POOL, + validatorList: VALIDATOR_LIST, + withdrawAuthority: JITO_WITHDRAW_AUTHORITY, + validatorStake: VALIDATOR_STAKE, + destinationStake: DEST_STAKE, + destinationStakeAuthority: SENDER, + sourceTransferAuthority: SENDER, + sourcePoolAccount: SOURCE_POOL_ACCOUNT, + managerFeeAccount: MANAGER_FEE_ACCOUNT, + poolMint: JITO_POOL_MINT, + poolTokens: 300000n, + }, + ], + }; + + const txBytes = buildTransaction(intent); + const parsed = parseTransaction(txBytes); + + assert.strictEqual(parsed.instructionsData.length, 1); + assert.strictEqual(parsed.instructionsData[0].type, "StakePoolWithdrawStake"); + + const withdrawStake = parsed.instructionsData[0]; + if (withdrawStake.type === "StakePoolWithdrawStake") { + assert.strictEqual(withdrawStake.stakePool, JITO_STAKE_POOL); + assert.strictEqual(withdrawStake.destinationStake, DEST_STAKE); + assert.strictEqual(withdrawStake.sourceTransferAuthority, SENDER); + assert.strictEqual(withdrawStake.poolMint, JITO_POOL_MINT); + assert.strictEqual(withdrawStake.poolTokens, 300000n); + } + }); + + it("should build jito deposit with create ATA", () => { + // Typical Jito deposit flow: Create ATA for JitoSOL + DepositSol + const intent: TransactionIntent = { + feePayer: SENDER, + nonce: { type: "blockhash", value: BLOCKHASH }, + instructions: [ + { + type: "createAssociatedTokenAccount", + payer: SENDER, + owner: SENDER, + mint: JITO_POOL_MINT, + }, + { + type: "stakePoolDepositSol", + stakePool: JITO_STAKE_POOL, + withdrawAuthority: JITO_WITHDRAW_AUTHORITY, + reserveStake: JITO_RESERVE_STAKE, + fundingAccount: SENDER, + destinationPoolAccount: SOURCE_POOL_ACCOUNT, + managerFeeAccount: MANAGER_FEE_ACCOUNT, + referralPoolAccount: MANAGER_FEE_ACCOUNT, + poolMint: JITO_POOL_MINT, + lamports: 1000000000n, // 1 SOL + }, + ], + }; + + const txBytes = buildTransaction(intent); + const parsed = parseTransaction(txBytes); + + assert.strictEqual(parsed.instructionsData.length, 2); + assert.strictEqual(parsed.instructionsData[0].type, "CreateAssociatedTokenAccount"); + assert.strictEqual(parsed.instructionsData[1].type, "StakePoolDepositSol"); + }); + }); +}); diff --git a/packages/wasm-solana/test/parser.ts b/packages/wasm-solana/test/parser.ts index 500ea047..9f7d41c5 100644 --- a/packages/wasm-solana/test/parser.ts +++ b/packages/wasm-solana/test/parser.ts @@ -25,20 +25,6 @@ describe("parseTransaction", () => { assert.ok(parsed.accountKeys.length > 0); }); - it("should include signatures as base58 strings", () => { - const parsed = parseTransaction(TEST_TX_BYTES); - - // Should have signatures array - assert.ok(Array.isArray(parsed.signatures)); - assert.strictEqual(parsed.signatures.length, parsed.numSignatures); - - // Each signature should be a non-empty string (base58 encoded) - for (const sig of parsed.signatures) { - assert.strictEqual(typeof sig, "string"); - assert.ok(sig.length > 0); - } - }); - it("should decode SOL transfer instruction correctly", () => { const parsed = parseTransaction(TEST_TX_BYTES); diff --git a/packages/wasm-solana/test/transaction.ts b/packages/wasm-solana/test/transaction.ts index 528d3a24..768ed48b 100644 --- a/packages/wasm-solana/test/transaction.ts +++ b/packages/wasm-solana/test/transaction.ts @@ -1,5 +1,6 @@ import * as assert from "assert"; import { Transaction } from "../js/transaction.js"; +import { VersionedTransaction } from "../js/versioned.js"; // Helper to decode base64 in tests function base64ToBytes(base64: string): Uint8Array { @@ -19,7 +20,7 @@ describe("Transaction", () => { const tx = Transaction.fromBytes(TEST_TX_BYTES); assert.ok(tx.numSignatures > 0); - assert.ok(tx.instructions().length > 0); + assert.ok(tx.instructions.length > 0); }); it("should get fee payer", () => { @@ -55,7 +56,7 @@ describe("Transaction", () => { const tx2 = Transaction.fromBytes(serialized); assert.strictEqual(tx.numSignatures, tx2.numSignatures); - assert.strictEqual(tx.instructions().length, tx2.instructions().length); + assert.strictEqual(tx.instructions.length, tx2.instructions.length); assert.strictEqual(tx.recentBlockhash, tx2.recentBlockhash); }); @@ -69,7 +70,7 @@ describe("Transaction", () => { it("should get signatures as bytes", () => { const tx = Transaction.fromBytes(TEST_TX_BYTES); - const sigs = tx.signatures(); + const sigs = tx.signatures; assert.ok(Array.isArray(sigs)); assert.strictEqual(sigs.length, tx.numSignatures); @@ -88,7 +89,7 @@ describe("Transaction", () => { it("should get instructions", () => { const tx = Transaction.fromBytes(TEST_TX_BYTES); - const instructions = tx.instructions(); + const instructions = tx.instructions; assert.ok(Array.isArray(instructions)); assert.ok(instructions.length > 0); @@ -102,7 +103,7 @@ describe("Transaction", () => { it("should get instruction accounts with signer/writable flags", () => { const tx = Transaction.fromBytes(TEST_TX_BYTES); - const instructions = tx.instructions(); + const instructions = tx.instructions; assert.ok(instructions.length > 0); const instr = instructions[0]; @@ -117,11 +118,229 @@ describe("Transaction", () => { it("should have System Program as program ID for SOL transfer", () => { const tx = Transaction.fromBytes(TEST_TX_BYTES); - const instructions = tx.instructions(); + const instructions = tx.instructions; assert.ok(instructions.length > 0); const instr = instructions[0]; // System program ID is 11111111111111111111111111111111 assert.strictEqual(instr.programId, "11111111111111111111111111111111"); }); + + describe("signerIndex", () => { + it("should return signer index for fee payer", () => { + const tx = Transaction.fromBytes(TEST_TX_BYTES); + const feePayer = tx.feePayer; + + const idx = tx.signerIndex(feePayer); + assert.strictEqual(idx, 0); // Fee payer is always at index 0 + }); + + it("should return null for non-signer pubkey", () => { + const tx = Transaction.fromBytes(TEST_TX_BYTES); + + // System program is not a signer + const idx = tx.signerIndex("11111111111111111111111111111111"); + assert.strictEqual(idx, null); + }); + }); + + describe("addSignature", () => { + it("should add signature for valid signer", () => { + const tx = Transaction.fromBytes(TEST_TX_BYTES); + const feePayer = tx.feePayer; + + // Create a dummy 64-byte signature + const signature = new Uint8Array(64).fill(42); + + // Add the signature + tx.addSignature(feePayer, signature); + + // Verify the signature was added + const sigs = tx.signatures; + assert.strictEqual(sigs.length, 1); + assert.deepStrictEqual(sigs[0], signature); + }); + + it("should throw for invalid signature length", () => { + const tx = Transaction.fromBytes(TEST_TX_BYTES); + const feePayer = tx.feePayer; + + // Try to add a signature with wrong length + const badSignature = new Uint8Array(32); + assert.throws(() => tx.addSignature(feePayer, badSignature), /Invalid signature length/); + }); + + it("should throw for non-signer pubkey", () => { + const tx = Transaction.fromBytes(TEST_TX_BYTES); + const signature = new Uint8Array(64); + + // Try to add signature for non-signer (System program) + assert.throws( + () => tx.addSignature("11111111111111111111111111111111", signature), + /unknown signer:/, + ); + }); + + it("should roundtrip after adding signature", () => { + const tx = Transaction.fromBytes(TEST_TX_BYTES); + const feePayer = tx.feePayer; + + // Add a signature + const signature = new Uint8Array(64); + for (let i = 0; i < 64; i++) signature[i] = i; + tx.addSignature(feePayer, signature); + + // Serialize and deserialize + const bytes = tx.toBytes(); + const tx2 = Transaction.fromBytes(bytes); + + // Verify signature is preserved + const sigs = tx2.signatures; + assert.deepStrictEqual(sigs[0], signature); + }); + }); + + describe("VersionedTransaction.fromVersionedData", () => { + it("should build versioned transaction from raw MessageV0 data", () => { + // Create minimal versioned transaction data + // Fee payer is first account + const feePayer = "2gCzKgSETrQ74HZfisZUENTLyNhV6cAgV77xDMhxmHg2"; + const data = { + staticAccountKeys: [ + feePayer, + "11111111111111111111111111111111", // system program + ], + addressLookupTables: [], + versionedInstructions: [ + { + programIdIndex: 1, + accountKeyIndexes: [0], + data: "3Bxs4ThwQbE4vyj", // base58 encoded transfer instruction data + }, + ], + messageHeader: { + numRequiredSignatures: 1, + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 1, + }, + recentBlockhash: "GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi", + }; + + const tx = VersionedTransaction.fromVersionedData(data); + + // Verify basic properties + assert.ok(tx.feePayer); // Fee payer exists + assert.strictEqual(tx.recentBlockhash, data.recentBlockhash); + assert.strictEqual(tx.numSignatures, 1); + assert.ok(tx.numInstructions > 0); + // Fee payer should be the first static account key (index 0) + assert.strictEqual(tx.feePayer, feePayer); + }); + + it("should roundtrip versioned transaction", () => { + const data = { + staticAccountKeys: [ + "2gCzKgSETrQ74HZfisZUENTLyNhV6cAgV77xDMhxmHg2", + "11111111111111111111111111111111", + ], + addressLookupTables: [], + versionedInstructions: [ + { + programIdIndex: 1, + accountKeyIndexes: [0], + data: "3Bxs4ThwQbE4vyj", + }, + ], + messageHeader: { + numRequiredSignatures: 1, + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 1, + }, + recentBlockhash: "GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi", + }; + + const tx = VersionedTransaction.fromVersionedData(data); + const bytes = tx.toBytes(); + + // Deserialize and verify using VersionedTransaction + const tx2 = VersionedTransaction.fromBytes(bytes); + assert.strictEqual(tx2.feePayer, tx.feePayer); + assert.strictEqual(tx2.recentBlockhash, tx.recentBlockhash); + }); + + it("should build versioned transaction with ALTs (Jupiter-like)", () => { + // This is extracted from a real Jupiter swap versioned transaction + // which uses Address Lookup Tables + const data = { + staticAccountKeys: [ + "35aKHPPJqb7qVNAaUb8DQLRC3Njp5RJZJSQM3v2PZhM7", + "ESuE8KSzSHBRCtgDwauL7vCR2ohxrWXf8rw75vVbNFvL", + "DWkKDVpGEVeABT4xh4SoBJzzxhSZxBuK7fWAD5LiMBui", + "4fxWJ1umh7bWbMrhrPaJcdV3EYjwm2kqPVKWHq7JcNXb", + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "11111111111111111111111111111111", + "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4", + "D8cy77BBepLMngZx6ZukaTff5hCt1HrWyKk3Hnd9oitf", + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", + "ComputeBudget111111111111111111111111111111", + "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8", + "5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1", + "srmqPvymJeFKQ4zGQed1GFppgkRHL9kaELCbyksJtPX", + ], + addressLookupTables: [ + { + accountKey: "2immgwYNHBbyVQKVGCEkgWpi53bLwWNRMB5G2nbgYV17", + writableIndexes: [0, 16, 21, 23, 34, 45], + readonlyIndexes: [1, 4, 22, 24, 37, 53, 61, 65], + }, + { + accountKey: "6i9zbbghVBpHm6A8DqqBDDnJZ9zRLcqZVTdNkQyTpGjC", + writableIndexes: [2, 3], + readonlyIndexes: [5, 6, 7], + }, + ], + versionedInstructions: [ + { + programIdIndex: 9, + accountKeyIndexes: [], + data: "3DdGGhkhJbjm", + }, + { + programIdIndex: 9, + accountKeyIndexes: [], + data: "Fj2Eoy", + }, + { + programIdIndex: 6, + accountKeyIndexes: [7, 0, 5, 4, 10, 11, 12, 1, 2, 3, 8], + data: "2gCNTm5Pp1JgJmCK3KqDm", + }, + ], + messageHeader: { + numRequiredSignatures: 1, + numReadonlySignedAccounts: 0, + numReadonlyUnsignedAccounts: 5, + }, + recentBlockhash: "GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi", + }; + + const tx = VersionedTransaction.fromVersionedData(data); + + // Verify basic properties + assert.ok(tx.feePayer); + assert.strictEqual(tx.feePayer, data.staticAccountKeys[0]); + assert.strictEqual(tx.recentBlockhash, data.recentBlockhash); + assert.strictEqual(tx.numSignatures, 1); + assert.strictEqual(tx.numInstructions, 3); + + // Verify we can serialize and it's a valid versioned transaction + const bytes = tx.toBytes(); + assert.ok(bytes.length > 0); + + // Verify we can parse it back + const tx2 = VersionedTransaction.fromBytes(bytes); + assert.strictEqual(tx2.feePayer, tx.feePayer); + assert.strictEqual(tx2.recentBlockhash, tx.recentBlockhash); + }); + }); }); diff --git a/packages/wasm-solana/test/versioned.ts b/packages/wasm-solana/test/versioned.ts new file mode 100644 index 00000000..085f08ca --- /dev/null +++ b/packages/wasm-solana/test/versioned.ts @@ -0,0 +1,95 @@ +import * as assert from "assert"; +import { VersionedTransaction, isVersionedTransaction } from "../js/versioned.js"; + +describe("VersionedTransaction", () => { + // Legacy transaction (same as transaction.ts test) + const LEGACY_TX_BASE64 = + "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEDFVMqpim7tqEi2XL8R6KKkP0DYJvY3eiRXLlL1P9EjYgXKQC+k0FKnqyC4AZGJR7OhJXfpPP3NHOhS8t/6G7bLAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/1c7Oaj3RbyLIjU0/ZPpsmVfVUWAzc8g36fK5g6A0JoBAgIAAQwCAAAAoIYBAAAAAAA="; + + describe("isVersionedTransaction", () => { + it("should return false for legacy transaction", () => { + const bytes = Buffer.from(LEGACY_TX_BASE64, "base64"); + assert.strictEqual(isVersionedTransaction(bytes), false); + }); + }); + + describe("legacy transaction parsing", () => { + it("should parse legacy transaction as versioned", () => { + const tx = VersionedTransaction.fromBase64(LEGACY_TX_BASE64); + + assert.strictEqual(tx.isVersioned, false); + assert.ok(tx.feePayer); + assert.ok(tx.recentBlockhash); + }); + + it("should have empty address lookup tables for legacy", () => { + const tx = VersionedTransaction.fromBase64(LEGACY_TX_BASE64); + const alts = tx.addressLookupTables(); + + assert.strictEqual(alts.length, 0); + }); + + it("should have static account keys", () => { + const tx = VersionedTransaction.fromBase64(LEGACY_TX_BASE64); + const keys = tx.staticAccountKeys(); + + assert.ok(Array.isArray(keys)); + assert.ok(keys.length > 0); + // First key should be fee payer + assert.strictEqual(keys[0], tx.feePayer); + }); + + it("should get instructions", () => { + const tx = VersionedTransaction.fromBase64(LEGACY_TX_BASE64); + const instructions = tx.instructions(); + + assert.ok(Array.isArray(instructions)); + assert.ok(instructions.length > 0); + + const instr = instructions[0]; + assert.ok(instr.programId); + assert.ok(Array.isArray(instr.accounts)); + assert.ok(instr.data instanceof Uint8Array); + }); + + it("should get signable payload", () => { + const tx = VersionedTransaction.fromBase64(LEGACY_TX_BASE64); + const payload = tx.signablePayload(); + + assert.ok(payload instanceof Uint8Array); + assert.ok(payload.length > 0); + }); + + it("should roundtrip", () => { + const tx = VersionedTransaction.fromBase64(LEGACY_TX_BASE64); + const bytes = tx.toBytes(); + + const tx2 = VersionedTransaction.fromBytes(bytes); + assert.strictEqual(tx.isVersioned, tx2.isVersioned); + assert.strictEqual(tx.feePayer, tx2.feePayer); + assert.strictEqual(tx.recentBlockhash, tx2.recentBlockhash); + }); + + it("should add signature", () => { + const tx = VersionedTransaction.fromBase64(LEGACY_TX_BASE64); + const feePayer = tx.feePayer; + + const signature = new Uint8Array(64).fill(42); + tx.addSignature(feePayer, signature); + + const sigs = tx.signatures; + assert.strictEqual(sigs.length, 1); + assert.deepStrictEqual(sigs[0], signature); + }); + }); + + describe("base64 serialization", () => { + it("should roundtrip base64", () => { + const tx = VersionedTransaction.fromBase64(LEGACY_TX_BASE64); + const base64 = tx.toBase64(); + + const tx2 = VersionedTransaction.fromBase64(base64); + assert.strictEqual(tx.feePayer, tx2.feePayer); + }); + }); +});