diff --git a/modules/sdk-coin-sui/src/lib/customTransactionBuilder.ts b/modules/sdk-coin-sui/src/lib/customTransactionBuilder.ts index d8cac02b4f..38ddea2dd9 100644 --- a/modules/sdk-coin-sui/src/lib/customTransactionBuilder.ts +++ b/modules/sdk-coin-sui/src/lib/customTransactionBuilder.ts @@ -58,6 +58,14 @@ export class CustomTransactionBuilder extends TransactionBuilder; @@ -213,6 +214,9 @@ export class TransactionBlockDataBuilder { owner: prepareSuiAddress(this.gasConfig.owner ?? sender), price: BigInt(gasConfig.price), budget: BigInt(gasConfig.budget), + ...(gasConfig.sponsor && { + sponsor: prepareSuiAddress(gasConfig.sponsor), + }), }, kind: { ProgrammableTransaction: { diff --git a/modules/sdk-coin-sui/src/lib/tokenTransferBuilder.ts b/modules/sdk-coin-sui/src/lib/tokenTransferBuilder.ts index 8206c92f28..bff36eadaf 100644 --- a/modules/sdk-coin-sui/src/lib/tokenTransferBuilder.ts +++ b/modules/sdk-coin-sui/src/lib/tokenTransferBuilder.ts @@ -61,6 +61,14 @@ export class TokenTransferBuilder extends TransactionBuilder extends BaseTransaction { +export abstract class Transaction extends BaseTransaction { protected _suiTransaction: SuiTransaction; protected _signature: Signature; + protected _feePayerSignature: Signature; private _serializedSig: Uint8Array; + private _serializedFeePayerSig: Uint8Array; protected constructor(_coinConfig: Readonly) { super(_coinConfig); @@ -48,17 +50,31 @@ export abstract class Transaction extends BaseTransaction { addSignature(publicKey: BasePublicKey, signature: Buffer): void { this._signatures.push(signature.toString('hex')); this._signature = { publicKey, signature }; + this.setSerializedSig(publicKey, signature); this.serialize(); } + addFeePayerSignature(publicKey: BasePublicKey, signature: Buffer): void { + this._feePayerSignature = { publicKey, signature }; + this.setSerializedFeePayerSig(publicKey, signature); + } + get suiSignature(): Signature { return this._signature; } + get feePayerSignature(): Signature { + return this._feePayerSignature; + } + get serializedSig(): Uint8Array { return this._serializedSig; } + get serializedFeePayerSig(): Uint8Array { + return this._serializedFeePayerSig; + } + setSerializedSig(publicKey: BasePublicKey, signature: Buffer): void { const pubKey = Buffer.from(publicKey.pub, 'hex'); const serialized_sig = new Uint8Array(1 + signature.length + pubKey.length); @@ -68,6 +84,15 @@ export abstract class Transaction extends BaseTransaction { this._serializedSig = serialized_sig; } + setSerializedFeePayerSig(publicKey: BasePublicKey, signature: Buffer): void { + const pubKey = Buffer.from(publicKey.pub, 'hex'); + const serialized_sig = new Uint8Array(1 + signature.length + pubKey.length); + serialized_sig.set(SIGNATURE_SCHEME_BYTES); + serialized_sig.set(signature, 1); + serialized_sig.set(pubKey, 1 + signature.length); + this._serializedFeePayerSig = serialized_sig; + } + /** @inheritdoc */ canSign(key: BaseKey): boolean { return true; @@ -78,7 +103,6 @@ export abstract class Transaction extends BaseTransaction { * * @param {KeyPair} signer key */ - sign(signer: KeyPair): void { if (!this._suiTransaction) { throw new InvalidTransactionError('empty transaction to sign'); @@ -87,18 +111,73 @@ export abstract class Transaction extends BaseTransaction { const intentMessage = this.signablePayload; const signature = signer.signMessageinUint8Array(intentMessage); - this.setSerializedSig({ pub: signer.getKeys().pub }, Buffer.from(signature)); this.addSignature({ pub: signer.getKeys().pub }, Buffer.from(signature)); } + /** + * Sign this transaction as a fee payer + * + * @param {KeyPair} signer key + */ + signFeePayer(signer: KeyPair): void { + if (!this._suiTransaction) { + throw new InvalidTransactionError('empty transaction to sign'); + } + + if ( + !this._suiTransaction.gasData || + !('sponsor' in this._suiTransaction.gasData) || + !this._suiTransaction.gasData.sponsor + ) { + throw new InvalidTransactionError('transaction does not have a fee payer'); + } + + const intentMessage = this.signablePayload; + const signature = signer.signMessageinUint8Array(intentMessage); + + this.addFeePayerSignature({ pub: signer.getKeys().pub }, Buffer.from(signature)); + } + /** @inheritdoc */ toBroadcastFormat(): string { if (!this._suiTransaction) { throw new InvalidTransactionError('Empty transaction'); } + + if (!this._serializedSig) { + throw new InvalidTransactionError('Transaction must be signed'); + } + // Return only the raw transaction bytes (base64) return this.serialize(); } + /** + * Get the full broadcast payload including signatures for Sui RPC + */ + toBroadcastPayload(): string { + if (!this._suiTransaction) { + throw new InvalidTransactionError('Empty transaction'); + } + + if (!this._serializedSig) { + throw new InvalidTransactionError('Transaction must be signed'); + } + + const result = { + txBytes: this.serialize(), + senderSignature: toB64(this._serializedSig), + }; + + if (this._suiTransaction.gasData?.sponsor) { + if (!this._serializedFeePayerSig) { + throw new InvalidTransactionError('Sponsored transaction must have fee payer signature'); + } + result['sponsorSignature'] = toB64(this._serializedFeePayerSig); + } + + return JSON.stringify(result); + } + /** @inheritdoc */ abstract toJson(): TxData; @@ -165,6 +244,19 @@ export abstract class Transaction extends BaseTransaction { const inputs = transactionBlock.inputs.map((txInput) => txInput.value); const transactions = transactionBlock.transactions; const txType = this.getSuiTransactionType(transactions); + + const gasData: GasData = { + payment: this.normalizeCoins(transactionBlock.gasConfig.payment!), + owner: normalizeSuiAddress(transactionBlock.gasConfig.owner!), + price: Number(transactionBlock.gasConfig.price as string), + budget: Number(transactionBlock.gasConfig.budget as string), + }; + + // Only add sponsor if it exists + if (transactionBlock.gasConfig.sponsor) { + gasData.sponsor = normalizeSuiAddress(transactionBlock.gasConfig.sponsor); + } + return { id: transactionBlock.getDigest(), type: txType, @@ -173,12 +265,7 @@ export abstract class Transaction extends BaseTransaction { inputs: inputs, transactions: transactions, }, - gasData: { - payment: this.normalizeCoins(transactionBlock.gasConfig.payment!), - owner: normalizeSuiAddress(transactionBlock.gasConfig.owner!), - price: Number(transactionBlock.gasConfig.price as string), - budget: Number(transactionBlock.gasConfig.budget as string), - }, + gasData: gasData, }; } @@ -213,12 +300,20 @@ export abstract class Transaction extends BaseTransaction { } static getProperGasData(k: any): GasData { - return { - payment: [this.normalizeSuiObjectRef(k.gasData.payment)], + const gasData: GasData = { + payment: Array.isArray(k.gasData.payment) + ? k.gasData.payment.map((p: any) => this.normalizeSuiObjectRef(p)) + : [this.normalizeSuiObjectRef(k.gasData.payment)], owner: utils.normalizeHexId(k.gasData.owner), price: Number(k.gasData.price), budget: Number(k.gasData.budget), }; + + if (k.gasData.sponsor) { + gasData.sponsor = utils.normalizeHexId(k.gasData.sponsor); + } + + return gasData; } private static normalizeCoins(coins: any[]): SuiObjectRef[] { @@ -267,4 +362,15 @@ export abstract class Transaction extends BaseTransaction { return inputGasPaymentObjects; } + + hasFeePayerSig(): boolean { + return this._feePayerSignature !== undefined; + } + + getFeePayerPubKey(): string | undefined { + if (!this._feePayerSignature || !this._feePayerSignature.publicKey) { + return undefined; + } + return this._feePayerSignature.publicKey.pub; + } } diff --git a/modules/sdk-coin-sui/src/lib/transactionBuilder.ts b/modules/sdk-coin-sui/src/lib/transactionBuilder.ts index 1981d17611..81dd55efaf 100644 --- a/modules/sdk-coin-sui/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-sui/src/lib/transactionBuilder.ts @@ -14,10 +14,10 @@ import { Transaction } from './transaction'; import utils from './utils'; import BigNumber from 'bignumber.js'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; -import { SuiProgrammableTransaction, SuiTransactionType } from './iface'; +import { GasData, SuiProgrammableTransaction, SuiTransactionType } from './iface'; import { DUMMY_SUI_GAS_PRICE } from './constants'; import { KeyPair } from './keyPair'; -import { GasData, SuiObjectRef } from './mystenlab/types'; +import { SuiObjectRef } from './mystenlab/types'; export abstract class TransactionBuilder extends BaseTransactionBuilder { protected _transaction: Transaction; @@ -52,7 +52,34 @@ export abstract class TransactionBuilder extends protected signImplementation(key: BaseKey): Transaction { const signer = new KeyPair({ prv: key.key }); this._signer = signer; - this.transaction.sign(signer); + const signable = this.transaction.signablePayload; + const signature = signer.signMessageinUint8Array(signable); + const signatureBuffer = Buffer.from(signature); + this.transaction.addSignature({ pub: signer.getKeys().pub }, signatureBuffer); + this.transaction.setSerializedSig({ pub: signer.getKeys().pub }, signatureBuffer); + return this.transaction; + } + + /** + * Signs the transaction as a fee payer. + * + * @param {BaseKey} key - The private key to sign the transaction with. + * @returns {Transaction} - The signed transaction. + */ + signFeePayer(key: BaseKey): Transaction { + this.validateKey(key); + + // Check if gasData exists and has a sponsor + if (!this._gasData?.sponsor) { + throw new BuildTransactionError('Transaction must have a fee payer (sponsor) to sign as fee payer'); + } + + const signer = new KeyPair({ prv: key.key }); + const signable = this.transaction.signablePayload; + const signature = signer.signMessageinUint8Array(signable); + const signatureBuffer = Buffer.from(signature); + this.transaction.addFeePayerSignature({ pub: signer.getKeys().pub }, signatureBuffer); + return this.transaction; } @@ -87,6 +114,30 @@ export abstract class TransactionBuilder extends return this; } + /** + * Sets the gas sponsor (fee payer) address for this transaction. + * When specified, the sponsor will be responsible for paying transaction fees. + * + * @param {string} sponsorAddress the account that will pay for this transaction + * @returns {TransactionBuilder} This transaction builder + */ + sponsor(sponsorAddress: string): this { + if (!utils.isValidAddress(sponsorAddress)) { + throw new BuildTransactionError('Invalid or missing sponsor, got: ' + sponsorAddress); + } + if (!this._gasData) { + throw new BuildTransactionError('gasData must be set before setting sponsor'); + } + + // Update the gasData with the sponsor + this._gasData = { + ...this._gasData, + sponsor: sponsorAddress, + }; + + return this; + } + /** * Initialize the transaction builder fields using the decoded transaction data * @@ -117,6 +168,14 @@ export abstract class TransactionBuilder extends if (!utils.isValidAddress(gasData.owner)) { throw new BuildTransactionError('Invalid gas address ' + gasData.owner); } + + // Validate sponsor address if present + if ('sponsor' in gasData && gasData.sponsor !== undefined) { + if (!utils.isValidAddress(gasData.sponsor)) { + throw new BuildTransactionError('Invalid sponsor address ' + gasData.sponsor); + } + } + this.validateGasPayment(gasData.payment); this.validateGasBudget(gasData.budget); this.validateGasPrice(gasData.price); diff --git a/modules/sdk-coin-sui/src/lib/transferBuilder.ts b/modules/sdk-coin-sui/src/lib/transferBuilder.ts index 32de1c2afe..09883e29f5 100644 --- a/modules/sdk-coin-sui/src/lib/transferBuilder.ts +++ b/modules/sdk-coin-sui/src/lib/transferBuilder.ts @@ -67,6 +67,14 @@ export class TransferBuilder extends TransactionBuilder { + const signatures = feePayerSignature ? [senderSignature, feePayerSignature] : [senderSignature]; + return this.executeTransactionBlock(url, serializedTx, signatures); + } + validateNonNegativeNumber(defaultVal: number, errorMsg: string, inputVal?: number): number { if (inputVal === undefined) { return defaultVal; diff --git a/modules/sdk-coin-sui/src/sui.ts b/modules/sdk-coin-sui/src/sui.ts index 306ac19e3e..41263f234e 100644 --- a/modules/sdk-coin-sui/src/sui.ts +++ b/modules/sdk-coin-sui/src/sui.ts @@ -81,6 +81,10 @@ export interface SuiParsedTransaction extends ParsedTransaction { export type SuiTransactionExplanation = TransactionExplanation; +export interface SuiMPCTx extends MPCTx { + feePayerSignature?: string; +} + export class Sui extends BaseCoin { protected readonly _staticsCoin: Readonly; protected constructor(bitgo: BitGoBase, staticsCoin?: Readonly) { @@ -647,9 +651,18 @@ export class Sui extends BaseCoin { const url = this.getPublicNodeUrl(); let digest = ''; if (!!transactions) { - for (const txn of transactions) { + for (const txn of transactions as SuiMPCTx[]) { try { - digest = await utils.executeTransactionBlock(url, txn.serializedTx, [txn.signature!]); + if (txn.feePayerSignature) { + digest = await utils.executeTransactionBlockWithMultipleSigners( + url, + txn.serializedTx, + txn.signature!, + txn.feePayerSignature + ); + } else { + digest = await utils.executeTransactionBlock(url, txn.serializedTx, [txn.signature!]); + } } catch (e) { throw new Error(`Failed to broadcast transaction, error: ${e.message}`); } diff --git a/modules/sdk-coin-sui/test/unit/transactionBuilder/feePayer.ts b/modules/sdk-coin-sui/test/unit/transactionBuilder/feePayer.ts new file mode 100644 index 0000000000..5d91f5dea9 --- /dev/null +++ b/modules/sdk-coin-sui/test/unit/transactionBuilder/feePayer.ts @@ -0,0 +1,205 @@ +import { getBuilderFactory } from '../getBuilderFactory'; +import * as testData from '../../resources/sui'; +import should from 'should'; +import { TransactionType } from '@bitgo/sdk-core'; +import { KeyPair } from '../../../src/lib/keyPair'; +import { SuiTransactionType } from '../../../src/lib/iface'; + +describe('Sui Fee Payer (Gas Tank) Builder', () => { + const factory = getBuilderFactory('tsui'); + + describe('Succeed', () => { + it('should build a transfer tx with fee payer', async function () { + // Create a separate key pair for the fee payer + const feePayerPrv = testData.privateKeys.prvKey3; + const feePayerKeyPair = new KeyPair({ prv: feePayerPrv }); + const feePayerAddress = feePayerKeyPair.getAddress(); + + // Set up the transaction builder + const txBuilder = factory.getTransferBuilder(); + txBuilder.type(SuiTransactionType.Transfer); + txBuilder.sender(testData.sender.address); + txBuilder.send(testData.recipients); + + // Set gasData with default owner + const gasDataWithSponsor = { + ...testData.gasData, + sponsor: feePayerAddress, // Add sponsor field + }; + + txBuilder.gasData(gasDataWithSponsor); + + // Sign with sender key + const senderKey = testData.privateKeys.prvKey1; + txBuilder.sign({ key: senderKey }); + + // Sign with fee payer key + txBuilder.signFeePayer({ key: feePayerPrv }); + + // Build the transaction + const tx = await txBuilder.build(); + + // Verify the transaction was built correctly + should.equal(tx.type, TransactionType.Send); + + // Check gas data contains sponsor + (tx as any).suiTransaction.gasData.sponsor.should.equal(feePayerAddress); + + // Verify sender signature exists + should.exist(tx.signature); + + // Verify fee payer signature exists + should.exist((tx as any).feePayerSignature); + + // Get broadcast format and verify it's valid base64 + const rawTx = tx.toBroadcastFormat(); + should.ok(rawTx); + should.doesNotThrow(() => Buffer.from(rawTx, 'base64')); + }); + + it('should be able to add sponsor after setting gasData', async function () { + const feePayerPrv = testData.privateKeys.prvKey3; + const feePayerKeyPair = new KeyPair({ prv: feePayerPrv }); + const feePayerAddress = feePayerKeyPair.getAddress(); + + const txBuilder = factory.getTransferBuilder(); + txBuilder.type(SuiTransactionType.Transfer); + txBuilder.sender(testData.sender.address); + txBuilder.send(testData.recipients); + txBuilder.gasData(testData.gasData); + + // Add sponsor after setting gasData + txBuilder.sponsor(feePayerAddress); + + txBuilder.sign({ key: testData.privateKeys.prvKey1 }); + txBuilder.signFeePayer({ key: feePayerPrv }); + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + // Verify sponsor was added correctly + (tx as any).suiTransaction.gasData.sponsor.should.equal(feePayerAddress); + + // Verify signatures exist + should.exist(tx.signature); + should.exist((tx as any).feePayerSignature); + }); + + it('should build a custom tx with fee payer', async function () { + const feePayerPrv = testData.privateKeys.prvKey3; + const feePayerKeyPair = new KeyPair({ prv: feePayerPrv }); + const feePayerAddress = feePayerKeyPair.getAddress(); + + // Create a custom transaction + const builder = factory.from(testData.TRANSFER); + + // Set the sponsor + const gasDataWithSponsor = { + ...testData.gasData, + sponsor: feePayerAddress, + }; + + // Update gas data to include sponsor + builder.gasData(gasDataWithSponsor); + + // Sign with both keys + builder.sign({ key: testData.privateKeys.prvKey1 }); + builder.signFeePayer({ key: feePayerPrv }); + + const tx = await builder.build(); + + // Verify gas data contains sponsor + (tx as any).suiTransaction.gasData.sponsor.should.equal(feePayerAddress); + + // Verify both signatures exist + should.exist(tx.signature); + should.exist((tx as any).feePayerSignature); + + // Get broadcast format and verify it's valid base64 + const rawTx = tx.toBroadcastFormat(); + should.ok(rawTx); + should.doesNotThrow(() => Buffer.from(rawTx, 'base64')); + }); + + it('should build a token transfer tx with fee payer', async function () { + const feePayerPrv = testData.privateKeys.prvKey3; + const feePayerKeyPair = new KeyPair({ prv: feePayerPrv }); + const feePayerAddress = feePayerKeyPair.getAddress(); + + // Set up token transfer builder + const builder = factory.getTokenTransferBuilder(); + builder.type(SuiTransactionType.TokenTransfer); + builder.sender(testData.sender.address); + builder.send(testData.recipients); + builder.inputObjects([testData.coinsWithoutGasPayment[0]]); + + // Set gas data with sponsor + const gasDataWithSponsor = { + ...testData.gasData, + sponsor: feePayerAddress, + }; + builder.gasData(gasDataWithSponsor); + + // Sign with both keys + builder.sign({ key: testData.privateKeys.prvKey1 }); + builder.signFeePayer({ key: feePayerPrv }); + + const tx = await builder.build(); + + // Verify gas data contains sponsor + (tx as any).suiTransaction.gasData.sponsor.should.equal(feePayerAddress); + + // Verify both signatures exist + should.exist(tx.signature); + should.exist((tx as any).feePayerSignature); + + // Get broadcast format and verify it's valid base64 + const rawTx = tx.toBroadcastFormat(); + should.ok(rawTx); + should.doesNotThrow(() => Buffer.from(rawTx, 'base64')); + }); + }); + + describe('Fail', () => { + it('should fail when trying to sign as fee payer without setting sponsor', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.type(SuiTransactionType.Transfer); + txBuilder.sender(testData.sender.address); + txBuilder.send(testData.recipients); + txBuilder.gasData(testData.gasData); // No sponsor set + + // Sign with sender key + txBuilder.sign({ key: testData.privateKeys.prvKey1 }); + + // Try to sign as fee payer should fail + should(() => txBuilder.signFeePayer({ key: testData.privateKeys.prvKey3 })).throwError( + 'Transaction must have a fee payer (sponsor) to sign as fee payer' + ); + }); + + it('should fail with invalid sponsor address', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.type(SuiTransactionType.Transfer); + txBuilder.sender(testData.sender.address); + txBuilder.send(testData.recipients); + txBuilder.gasData(testData.gasData); + + // Try to set invalid sponsor address + should(() => txBuilder.sponsor('invalidSponsorAddress')).throwError( + 'Invalid or missing sponsor, got: invalidSponsorAddress' + ); + }); + + it('should fail when trying to set sponsor before gasData', async function () { + const txBuilder = factory.getTransferBuilder(); + txBuilder.type(SuiTransactionType.Transfer); + txBuilder.sender(testData.sender.address); + txBuilder.send(testData.recipients); + + // Try to set sponsor before gasData + should(() => txBuilder.sponsor(testData.addresses.validAddresses[0])).throwError( + 'gasData must be set before setting sponsor' + ); + }); + }); +});