Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions modules/sdk-coin-sol/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
13 changes: 13 additions & 0 deletions modules/sdk-coin-sol/src/lib/transactionBuilderFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
*
Expand Down
80 changes: 80 additions & 0 deletions modules/sdk-coin-sol/src/lib/v1PaymentBuilder.ts
Original file line number Diff line number Diff line change
@@ -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<CoinConfig>) {
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<Transaction> {
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();
}
}
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading