diff --git a/modules/sdk-coin-sol/src/lib/index.ts b/modules/sdk-coin-sol/src/lib/index.ts index 1489ded54f0..8c7fd1f2497 100644 --- a/modules/sdk-coin-sol/src/lib/index.ts +++ b/modules/sdk-coin-sol/src/lib/index.ts @@ -23,6 +23,7 @@ export { type V1TransactionConfigInput, } from './v1TransactionBuilder'; export { V1CustomInstructionBuilder } from './v1CustomInstructionBuilder'; +export { V1PaymentBuilder, type SendParams } from './v1PaymentBuilder'; export { TransactionBuilderFactory } from './transactionBuilderFactory'; export { TransferBuilder } from './transferBuilder'; export { TransferBuilderV2 } from './transferBuilderV2'; diff --git a/modules/sdk-coin-sol/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-sol/src/lib/transactionBuilderFactory.ts index 02b349db8d9..b39c140b467 100644 --- a/modules/sdk-coin-sol/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-sol/src/lib/transactionBuilderFactory.ts @@ -6,6 +6,7 @@ import { ConfidentialTransferBuilder } from './confidentialTransferBuilder'; import { RecoverNestedAtaBuilder } from './recoverNestedAtaBuilder'; import { CustomInstructionBuilder } from './customInstructionBuilder'; import { V1CustomInstructionBuilder } from './v1CustomInstructionBuilder'; +import { V1PaymentBuilder } from './v1PaymentBuilder'; import { StakingActivateBuilder } from './stakingActivateBuilder'; import { StakingAuthorizeBuilder } from './stakingAuthorizeBuilder'; import { StakingDeactivateBuilder } from './stakingDeactivateBuilder'; @@ -212,6 +213,18 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return new V1CustomInstructionBuilder(this._coinConfig); } + /** + * Returns the builder to create Solana v1 (SIMD-0296/0385) native SOL + * transfers from structured params (sender + recipients/amounts). + * + * Server-side construction surface — the caller supplies fields, never + * instructions. Set the fee payer via sender, optional durable nonce via + * nonce, and the v1 config via transactionConfig. + */ + getV1PaymentBuilder(): V1PaymentBuilder { + return new V1PaymentBuilder(this._coinConfig); + } + /** * Returns the builder to create Token-2022 confidential transfer transactions. * diff --git a/modules/sdk-coin-sol/src/lib/v1PaymentBuilder.ts b/modules/sdk-coin-sol/src/lib/v1PaymentBuilder.ts new file mode 100644 index 00000000000..8974b439059 --- /dev/null +++ b/modules/sdk-coin-sol/src/lib/v1PaymentBuilder.ts @@ -0,0 +1,80 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { BuildTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { V1TransactionBuilder } from './v1TransactionBuilder'; +import { Transaction } from './transaction'; +import { isValidAmount, validateAddress } from './utils'; +import { InstructionBuilderTypes } from './constants'; +import { InstructionParams, Transfer } from './iface'; + +import assert from 'assert'; + +export interface SendParams { + address: string; + amount: string; +} + +/** + * Transaction builder for Solana v1 (SIMD-0296/0385) native SOL payments. + * + * Server-side construction surface: builds a v1 payment envelope from + * structured params (sender + recipients/amounts) — the caller supplies fields, + * never instructions. The builder owns the `AdvanceNonceAccount` injection, the + * message compile, and the message-signer-order signature assembly (inherited + * from {@link V1TransactionBuilder}); set the fee payer via + * {@link V1TransactionBuilder.sender}, optional durable nonce via + * {@link V1TransactionBuilder.nonce}, and the v1 config via + * {@link V1TransactionBuilder.transactionConfig}. + */ +export class V1PaymentBuilder extends V1TransactionBuilder { + private _sendParams: SendParams[] = []; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + protected get transactionType(): TransactionType { + return TransactionType.Send; + } + + /** + * Set a transfer. + * + * @param {string} address - the receiver address + * @param {string} amount - the amount sent (lamports) + * @returns {V1PaymentBuilder} This transaction builder + */ + send({ address, amount }: SendParams): this { + validateAddress(address, 'address'); + if (!amount || !isValidAmount(amount)) { + throw new BuildTransactionError('Invalid or missing amount, got: ' + amount); + } + if (BigInt(amount) > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new BuildTransactionError(`input amount ${amount} exceeds max safe int ${Number.MAX_SAFE_INTEGER}`); + } + + this._sendParams.push({ address, amount }); + + return this; + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + assert(this._sender, 'Sender must be set before building the transaction'); + + const transferData: InstructionParams[] = this._sendParams.map((sendParams: SendParams): Transfer => { + return { + type: InstructionBuilderTypes.Transfer, + params: { + fromAddress: this._sender, + toAddress: sendParams.address, + amount: sendParams.amount, + }, + }; + }); + // v1: the priority fee is carried in the message config mask (transactionConfig), + // so no explicit SetPriorityFee instruction is needed (unlike the v0 flow). + this.instructions(transferData); + + return await super.buildImplementation(); + } +} diff --git a/modules/sdk-coin-sol/test/unit/transactionBuilder/v1PaymentBuilder.ts b/modules/sdk-coin-sol/test/unit/transactionBuilder/v1PaymentBuilder.ts new file mode 100644 index 00000000000..f95ac7b80ea --- /dev/null +++ b/modules/sdk-coin-sol/test/unit/transactionBuilder/v1PaymentBuilder.ts @@ -0,0 +1,122 @@ +import should from 'should'; +import nacl from 'tweetnacl'; +import { coins } from '@bitgo/statics'; +import { SystemProgram } from '@solana/web3.js'; +import { decodeV1Message, KeyPair, parseWireTransaction, Transaction, V1PaymentBuilder } from '../../../src'; +import { verifyV1Signatures } from '../../../src/lib/serialization/parseWireTransaction'; +import { SolanaKeys } from '../../../src/lib/iface'; +import * as testData from '../../resources/sol'; + +describe('Solana V1 Payment Builder (SIMD-0296/0385) — server-side construction surface', () => { + const coinConfig = coins.get('tsol'); + const user = new KeyPair(testData.authAccount).getKeys(); + const gasTank = new KeyPair({ prv: testData.prvKeys.prvKey1.base58 }).getKeys(); + const nonceAccount = new KeyPair(testData.nonceAccount).getKeys(); + const recipient = 'ENn8a2tGMS9bR5XV7smGHJvNgzkyxJmnD2eUvQxY5jSP'; + const recentBlockHash = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'; + const amount = '300000'; + + const v1Config = { + computeUnitLimit: 200_000, + heapSize: 32_768, + loadedAccountsDataSizeLimit: 65_536, + priorityFee: 5_000, + }; + + const newBuilder = () => { + const builder = new V1PaymentBuilder(coinConfig); + builder.sender(user.pub); + builder.send({ address: recipient, amount }); + builder.nonce(recentBlockHash); + builder.transactionConfig(v1Config); + return builder; + }; + + const signWith = (keyPair: SolanaKeys, messageBytes: Uint8Array): Uint8Array => { + const prv = keyPair.prv; + if (typeof prv !== 'string') { + throw new Error('Missing private key for test signer'); + } + const kp = new KeyPair({ prv }); + return nacl.sign.detached(messageBytes, kp.getKeys(true).prv as Uint8Array); + }; + + it('exposes the structured send() surface', () => { + const builder = newBuilder(); + builder.send({ address: recipient, amount }).should.equal(builder); + }); + + it('builds a v1 transfer message + signed wire and round-trips through parse', async () => { + const builder = newBuilder(); + builder.sign({ key: user.prv }); + const tx = (await builder.build()) as Transaction; + + tx.v1MessageBytes![0].should.equal(0x81); + tx.v1TransactionBytes!.length.should.equal(tx.v1MessageBytes!.length + 64); + tx.signablePayload.equals(Buffer.from(tx.v1MessageBytes!)).should.be.true(); + + // the transfer instruction is a SystemProgram transfer (discriminator 2) + const decoded = decodeV1Message(tx.v1MessageBytes!); + decoded.compiledInstructions[0].data[0].should.equal(2); + decoded.staticAccounts[decoded.compiledInstructions[0].programAddressIndex].should.equal( + SystemProgram.programId.toBase58() + ); + + const parsed = parseWireTransaction(tx.v1TransactionBytes!); + parsed.signerPublicKeys.should.deepEqual([user.pub]); + verifyV1Signatures(parsed.messageBytes, parsed.signatures, parsed.signerPublicKeys); + }); + + it('injects the AdvanceNonceAccount instruction and assembles user + gas-tank signatures in signer order', async () => { + const builder = newBuilder(); + builder.nonce(recentBlockHash, { + walletNonceAddress: nonceAccount.pub, + authWalletAddress: gasTank.pub, + }); + builder.sign({ key: user.prv }); + + const unsigned = (await builder.build()) as Transaction; + const messageBytes = unsigned.v1MessageBytes!; + + // 2 required signers: user (fee payer) + gas-tank nonce authority + messageBytes[1].should.equal(2); + + const decoded = decodeV1Message(messageBytes); + // nonce hash occupies the blockhash field + decoded.blockhash.should.equal(recentBlockHash); + // first instruction is AdvanceNonceAccount (System Program, discriminator 4) + decoded.compiledInstructions[0].data[0].should.equal(4); + decoded.staticAccounts[decoded.compiledInstructions[0].programAddressIndex].should.equal( + SystemProgram.programId.toBase58() + ); + // signer order: fee payer (user) first, then gas tank + decoded.staticAccounts.slice(0, 2).should.deepEqual([user.pub, gasTank.pub]); + + // attach the gas-tank HSM signature over the same message bytes + const gasTankSig = signWith(gasTank, messageBytes); + builder.addSignature({ pub: gasTank.pub }, Buffer.from(gasTankSig)); + const signed = (await builder.build()) as Transaction; + + // message byte-identity between build and sign + Buffer.from(signed.v1MessageBytes!).equals(Buffer.from(messageBytes)).should.be.true(); + signed.v1TransactionBytes!.length.should.equal(messageBytes.length + 2 * 64); + + const parsed = parseWireTransaction(signed.v1TransactionBytes!); + parsed.signerPublicKeys.should.deepEqual([user.pub, gasTank.pub]); + verifyV1Signatures(parsed.messageBytes, parsed.signatures, parsed.signerPublicKeys); + }); + + it('throws when transactionConfig is missing', async () => { + const builder = new V1PaymentBuilder(coinConfig); + builder.sender(user.pub); + builder.send({ address: recipient, amount }); + builder.nonce(recentBlockHash); + await builder.build().should.be.rejectedWith(/transactionConfig is required/); + }); + + it('validates recipient address and amount', () => { + const builder = new V1PaymentBuilder(coinConfig); + should.throws(() => builder.send({ address: 'not-an-address', amount }), /address/); + should.throws(() => builder.send({ address: recipient, amount: 'abc' }), /amount/); + }); +});