Skip to content

Commit 4b02833

Browse files
feat(sdk-coin-sui): sui sponsorship support
TICKET: WIN-6028
1 parent aeb595f commit 4b02833

File tree

10 files changed

+439
-27
lines changed

10 files changed

+439
-27
lines changed

modules/sdk-coin-sui/src/lib/customTransactionBuilder.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ export class CustomTransactionBuilder extends TransactionBuilder<CustomProgramma
5858
});
5959
}
6060

61+
// Ensure the transaction includes the fee payer signature if present
62+
if (this._gasData && 'sponsor' in this._gasData && this._gasData.sponsor && this.transaction.feePayerSignature) {
63+
this.transaction.addFeePayerSignature(
64+
this.transaction.feePayerSignature.publicKey,
65+
this.transaction.feePayerSignature.signature
66+
);
67+
}
68+
6169
this.transaction.loadInputsAndOutputs();
6270
return this.transaction;
6371
}

modules/sdk-coin-sui/src/lib/iface.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,20 @@ import {
33
TransactionType as BitGoTransactionType,
44
} from '@bitgo/sdk-core';
55
import BigNumber from 'bignumber.js';
6-
import {
7-
CallArg,
8-
GasData,
9-
ProgrammableTransaction,
10-
SuiAddress,
11-
SuiObjectRef,
12-
TransactionExpiration,
13-
} from './mystenlab/types';
6+
import { CallArg, ProgrammableTransaction, SuiAddress, SuiObjectRef, TransactionExpiration } from './mystenlab/types';
7+
8+
/**
9+
* Gas data for Sui transactions.
10+
* For sponsored transactions, the sponsor field must be set.
11+
*/
12+
export interface GasData {
13+
payment: SuiObjectRef[];
14+
owner: SuiAddress;
15+
price: number;
16+
budget: number;
17+
sponsor?: SuiAddress;
18+
}
19+
1420
import { TransactionBlockInput, TransactionType } from './mystenlab/builder';
1521

1622
export enum SuiTransactionType {

modules/sdk-coin-sui/src/lib/mystenlab/builder/TransactionDataBlock.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const GasConfig = object({
4343
price: optional(StringEncodedBigint),
4444
payment: optional(array(SuiObjectRef)),
4545
owner: optional(SuiAddress),
46+
sponsor: optional(SuiAddress),
4647
});
4748
type GasConfig = Infer<typeof GasConfig>;
4849

@@ -213,6 +214,9 @@ export class TransactionBlockDataBuilder {
213214
owner: prepareSuiAddress(this.gasConfig.owner ?? sender),
214215
price: BigInt(gasConfig.price),
215216
budget: BigInt(gasConfig.budget),
217+
...(gasConfig.sponsor && {
218+
sponsor: prepareSuiAddress(gasConfig.sponsor),
219+
}),
216220
},
217221
kind: {
218222
ProgrammableTransaction: {

modules/sdk-coin-sui/src/lib/tokenTransferBuilder.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ export class TokenTransferBuilder extends TransactionBuilder<TokenTransferProgra
6161
this.transaction.addSignature(signature.publicKey, signature.signature);
6262
});
6363

64+
// Ensure the transaction includes the fee payer signature if present
65+
if (this._gasData && 'sponsor' in this._gasData && this._gasData.sponsor && this.transaction.feePayerSignature) {
66+
this.transaction.addFeePayerSignature(
67+
this.transaction.feePayerSignature.publicKey,
68+
this.transaction.feePayerSignature.signature
69+
);
70+
}
71+
6472
this.transaction.loadInputsAndOutputs();
6573
return this.transaction;
6674
}

modules/sdk-coin-sui/src/lib/transaction.ts

Lines changed: 105 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import {
66
Signature,
77
TransactionType as BitGoTransactionType,
88
} from '@bitgo/sdk-core';
9-
import { SuiProgrammableTransaction, SuiTransaction, SuiTransactionType, TxData } from './iface';
9+
import { SuiProgrammableTransaction, SuiTransaction, SuiTransactionType, TxData, GasData } from './iface';
1010
import { BaseCoin as CoinConfig } from '@bitgo/statics';
1111
import utils, { AppId, Intent, IntentScope, IntentVersion, isImmOrOwnedObj } from './utils';
12-
import { GasData, normalizeSuiAddress, normalizeSuiObjectId, SuiObjectRef } from './mystenlab/types';
12+
import { normalizeSuiAddress, normalizeSuiObjectId, SuiObjectRef } from './mystenlab/types';
1313
import { SIGNATURE_SCHEME_BYTES } from './constants';
1414
import { Buffer } from 'buffer';
1515
import { fromB64, toB64 } from '@mysten/bcs';
@@ -20,10 +20,12 @@ import { builder, MergeCoinsTransaction, TransactionType } from './mystenlab/bui
2020
import blake2b from '@bitgo/blake2b';
2121
import { hashTypedData } from './mystenlab/cryptography/hash';
2222

23-
export abstract class Transaction<T> extends BaseTransaction {
23+
export abstract class Transaction<T = SuiProgrammableTransaction> extends BaseTransaction {
2424
protected _suiTransaction: SuiTransaction<T>;
2525
protected _signature: Signature;
26+
protected _feePayerSignature: Signature;
2627
private _serializedSig: Uint8Array;
28+
private _serializedFeePayerSig: Uint8Array;
2729

2830
protected constructor(_coinConfig: Readonly<CoinConfig>) {
2931
super(_coinConfig);
@@ -48,17 +50,31 @@ export abstract class Transaction<T> extends BaseTransaction {
4850
addSignature(publicKey: BasePublicKey, signature: Buffer): void {
4951
this._signatures.push(signature.toString('hex'));
5052
this._signature = { publicKey, signature };
53+
this.setSerializedSig(publicKey, signature);
5154
this.serialize();
5255
}
5356

57+
addFeePayerSignature(publicKey: BasePublicKey, signature: Buffer): void {
58+
this._feePayerSignature = { publicKey, signature };
59+
this.setSerializedFeePayerSig(publicKey, signature);
60+
}
61+
5462
get suiSignature(): Signature {
5563
return this._signature;
5664
}
5765

66+
get feePayerSignature(): Signature {
67+
return this._feePayerSignature;
68+
}
69+
5870
get serializedSig(): Uint8Array {
5971
return this._serializedSig;
6072
}
6173

74+
get serializedFeePayerSig(): Uint8Array {
75+
return this._serializedFeePayerSig;
76+
}
77+
6278
setSerializedSig(publicKey: BasePublicKey, signature: Buffer): void {
6379
const pubKey = Buffer.from(publicKey.pub, 'hex');
6480
const serialized_sig = new Uint8Array(1 + signature.length + pubKey.length);
@@ -68,6 +84,15 @@ export abstract class Transaction<T> extends BaseTransaction {
6884
this._serializedSig = serialized_sig;
6985
}
7086

87+
setSerializedFeePayerSig(publicKey: BasePublicKey, signature: Buffer): void {
88+
const pubKey = Buffer.from(publicKey.pub, 'hex');
89+
const serialized_sig = new Uint8Array(1 + signature.length + pubKey.length);
90+
serialized_sig.set(SIGNATURE_SCHEME_BYTES);
91+
serialized_sig.set(signature, 1);
92+
serialized_sig.set(pubKey, 1 + signature.length);
93+
this._serializedFeePayerSig = serialized_sig;
94+
}
95+
7196
/** @inheritdoc */
7297
canSign(key: BaseKey): boolean {
7398
return true;
@@ -78,7 +103,6 @@ export abstract class Transaction<T> extends BaseTransaction {
78103
*
79104
* @param {KeyPair} signer key
80105
*/
81-
82106
sign(signer: KeyPair): void {
83107
if (!this._suiTransaction) {
84108
throw new InvalidTransactionError('empty transaction to sign');
@@ -87,16 +111,56 @@ export abstract class Transaction<T> extends BaseTransaction {
87111
const intentMessage = this.signablePayload;
88112
const signature = signer.signMessageinUint8Array(intentMessage);
89113

90-
this.setSerializedSig({ pub: signer.getKeys().pub }, Buffer.from(signature));
91114
this.addSignature({ pub: signer.getKeys().pub }, Buffer.from(signature));
92115
}
93116

117+
/**
118+
* Sign this transaction as a fee payer
119+
*
120+
* @param {KeyPair} signer key
121+
*/
122+
signFeePayer(signer: KeyPair): void {
123+
if (!this._suiTransaction) {
124+
throw new InvalidTransactionError('empty transaction to sign');
125+
}
126+
127+
if (
128+
!this._suiTransaction.gasData ||
129+
!('sponsor' in this._suiTransaction.gasData) ||
130+
!this._suiTransaction.gasData.sponsor
131+
) {
132+
throw new InvalidTransactionError('transaction does not have a fee payer');
133+
}
134+
135+
const intentMessage = this.signablePayload;
136+
const signature = signer.signMessageinUint8Array(intentMessage);
137+
138+
this.addFeePayerSignature({ pub: signer.getKeys().pub }, Buffer.from(signature));
139+
}
140+
94141
/** @inheritdoc */
95142
toBroadcastFormat(): string {
96143
if (!this._suiTransaction) {
97144
throw new InvalidTransactionError('Empty transaction');
98145
}
99-
return this.serialize();
146+
147+
if (!this._serializedSig) {
148+
throw new InvalidTransactionError('Transaction must be signed');
149+
}
150+
151+
const result = {
152+
txBytes: this.serialize(),
153+
senderSignature: toB64(this._serializedSig),
154+
};
155+
156+
if (this._suiTransaction.gasData?.sponsor) {
157+
if (!this._serializedFeePayerSig) {
158+
throw new InvalidTransactionError('Sponsored transaction must have fee payer signature');
159+
}
160+
result['sponsorSignature'] = toB64(this._serializedFeePayerSig);
161+
}
162+
163+
return JSON.stringify(result);
100164
}
101165

102166
/** @inheritdoc */
@@ -165,6 +229,19 @@ export abstract class Transaction<T> extends BaseTransaction {
165229
const inputs = transactionBlock.inputs.map((txInput) => txInput.value);
166230
const transactions = transactionBlock.transactions;
167231
const txType = this.getSuiTransactionType(transactions);
232+
233+
const gasData: GasData = {
234+
payment: this.normalizeCoins(transactionBlock.gasConfig.payment!),
235+
owner: normalizeSuiAddress(transactionBlock.gasConfig.owner!),
236+
price: Number(transactionBlock.gasConfig.price as string),
237+
budget: Number(transactionBlock.gasConfig.budget as string),
238+
};
239+
240+
// Only add sponsor if it exists
241+
if (transactionBlock.gasConfig.sponsor) {
242+
gasData.sponsor = normalizeSuiAddress(transactionBlock.gasConfig.sponsor);
243+
}
244+
168245
return {
169246
id: transactionBlock.getDigest(),
170247
type: txType,
@@ -173,12 +250,7 @@ export abstract class Transaction<T> extends BaseTransaction {
173250
inputs: inputs,
174251
transactions: transactions,
175252
},
176-
gasData: {
177-
payment: this.normalizeCoins(transactionBlock.gasConfig.payment!),
178-
owner: normalizeSuiAddress(transactionBlock.gasConfig.owner!),
179-
price: Number(transactionBlock.gasConfig.price as string),
180-
budget: Number(transactionBlock.gasConfig.budget as string),
181-
},
253+
gasData: gasData,
182254
};
183255
}
184256

@@ -213,12 +285,20 @@ export abstract class Transaction<T> extends BaseTransaction {
213285
}
214286

215287
static getProperGasData(k: any): GasData {
216-
return {
217-
payment: [this.normalizeSuiObjectRef(k.gasData.payment)],
288+
const gasData: GasData = {
289+
payment: Array.isArray(k.gasData.payment)
290+
? k.gasData.payment.map((p: any) => this.normalizeSuiObjectRef(p))
291+
: [this.normalizeSuiObjectRef(k.gasData.payment)],
218292
owner: utils.normalizeHexId(k.gasData.owner),
219293
price: Number(k.gasData.price),
220294
budget: Number(k.gasData.budget),
221295
};
296+
297+
if (k.gasData.sponsor) {
298+
gasData.sponsor = utils.normalizeHexId(k.gasData.sponsor);
299+
}
300+
301+
return gasData;
222302
}
223303

224304
private static normalizeCoins(coins: any[]): SuiObjectRef[] {
@@ -267,4 +347,15 @@ export abstract class Transaction<T> extends BaseTransaction {
267347

268348
return inputGasPaymentObjects;
269349
}
350+
351+
hasFeePayerSig(): boolean {
352+
return this._feePayerSignature !== undefined;
353+
}
354+
355+
getFeePayerPubKey(): string | undefined {
356+
if (!this._feePayerSignature || !this._feePayerSignature.publicKey) {
357+
return undefined;
358+
}
359+
return this._feePayerSignature.publicKey.pub;
360+
}
270361
}

modules/sdk-coin-sui/src/lib/transactionBuilder.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ import { Transaction } from './transaction';
1414
import utils from './utils';
1515
import BigNumber from 'bignumber.js';
1616
import { BaseCoin as CoinConfig } from '@bitgo/statics';
17-
import { SuiProgrammableTransaction, SuiTransactionType } from './iface';
17+
import { GasData, SuiProgrammableTransaction, SuiTransactionType } from './iface';
1818
import { DUMMY_SUI_GAS_PRICE } from './constants';
1919
import { KeyPair } from './keyPair';
20-
import { GasData, SuiObjectRef } from './mystenlab/types';
20+
import { SuiObjectRef } from './mystenlab/types';
2121

2222
export abstract class TransactionBuilder<T = SuiProgrammableTransaction> extends BaseTransactionBuilder {
2323
protected _transaction: Transaction<T>;
@@ -52,7 +52,34 @@ export abstract class TransactionBuilder<T = SuiProgrammableTransaction> extends
5252
protected signImplementation(key: BaseKey): Transaction<T> {
5353
const signer = new KeyPair({ prv: key.key });
5454
this._signer = signer;
55-
this.transaction.sign(signer);
55+
const signable = this.transaction.signablePayload;
56+
const signature = signer.signMessageinUint8Array(signable);
57+
const signatureBuffer = Buffer.from(signature);
58+
this.transaction.addSignature({ pub: signer.getKeys().pub }, signatureBuffer);
59+
this.transaction.setSerializedSig({ pub: signer.getKeys().pub }, signatureBuffer);
60+
return this.transaction;
61+
}
62+
63+
/**
64+
* Signs the transaction as a fee payer.
65+
*
66+
* @param {BaseKey} key - The private key to sign the transaction with.
67+
* @returns {Transaction<T>} - The signed transaction.
68+
*/
69+
signFeePayer(key: BaseKey): Transaction<T> {
70+
this.validateKey(key);
71+
72+
// Check if gasData exists and has a sponsor
73+
if (!this._gasData?.sponsor) {
74+
throw new BuildTransactionError('Transaction must have a fee payer (sponsor) to sign as fee payer');
75+
}
76+
77+
const signer = new KeyPair({ prv: key.key });
78+
const signable = this.transaction.signablePayload;
79+
const signature = signer.signMessageinUint8Array(signable);
80+
const signatureBuffer = Buffer.from(signature);
81+
this.transaction.addFeePayerSignature({ pub: signer.getKeys().pub }, signatureBuffer);
82+
5683
return this.transaction;
5784
}
5885

@@ -87,6 +114,30 @@ export abstract class TransactionBuilder<T = SuiProgrammableTransaction> extends
87114
return this;
88115
}
89116

117+
/**
118+
* Sets the gas sponsor (fee payer) address for this transaction.
119+
* When specified, the sponsor will be responsible for paying transaction fees.
120+
*
121+
* @param {string} sponsorAddress the account that will pay for this transaction
122+
* @returns {TransactionBuilder} This transaction builder
123+
*/
124+
sponsor(sponsorAddress: string): this {
125+
if (!utils.isValidAddress(sponsorAddress)) {
126+
throw new BuildTransactionError('Invalid or missing sponsor, got: ' + sponsorAddress);
127+
}
128+
if (!this._gasData) {
129+
throw new BuildTransactionError('gasData must be set before setting sponsor');
130+
}
131+
132+
// Update the gasData with the sponsor
133+
this._gasData = {
134+
...this._gasData,
135+
sponsor: sponsorAddress,
136+
};
137+
138+
return this;
139+
}
140+
90141
/**
91142
* Initialize the transaction builder fields using the decoded transaction data
92143
*
@@ -117,6 +168,14 @@ export abstract class TransactionBuilder<T = SuiProgrammableTransaction> extends
117168
if (!utils.isValidAddress(gasData.owner)) {
118169
throw new BuildTransactionError('Invalid gas address ' + gasData.owner);
119170
}
171+
172+
// Validate sponsor address if present
173+
if ('sponsor' in gasData && gasData.sponsor !== undefined) {
174+
if (!utils.isValidAddress(gasData.sponsor)) {
175+
throw new BuildTransactionError('Invalid sponsor address ' + gasData.sponsor);
176+
}
177+
}
178+
120179
this.validateGasPayment(gasData.payment);
121180
this.validateGasBudget(gasData.budget);
122181
this.validateGasPrice(gasData.price);

0 commit comments

Comments
 (0)