diff --git a/modules/sdk-coin-sui/src/lib/walrusStakingBuilder.ts b/modules/sdk-coin-sui/src/lib/walrusStakingBuilder.ts index 965c18e3cb..3c0bbabfbc 100644 --- a/modules/sdk-coin-sui/src/lib/walrusStakingBuilder.ts +++ b/modules/sdk-coin-sui/src/lib/walrusStakingBuilder.ts @@ -21,10 +21,12 @@ import { import { MAX_GAS_OBJECTS } from './constants'; import { WALRUS_PROD_CONFIG, WALRUS_TESTNET_CONFIG } from './resources/walrusConfig'; import { SuiObjectRef } from './mystenlab/types'; +import BigNumber from 'bignumber.js'; export class WalrusStakingBuilder extends TransactionBuilder { protected _stakeWithPoolTx: RequestWalrusStakeWithPool[]; protected _inputObjects: SuiObjectRef[]; + protected _fundsInAddressBalance: BigNumber = new BigNumber(0); private walrusConfig: any; // TODO improve @@ -108,6 +110,19 @@ export class WalrusStakingBuilder extends TransactionBuilder before + * merging/splitting and staking. WAL principal can come from coin + * objects, address balance, or a mix. + * + * @param {string} amount - amount in WAL base units held in address balance + */ + fundsInAddressBalance(amount: string): this { + this._fundsInAddressBalance = new BigNumber(amount); + return this; + } + private validateInputObjects(inputObjects: SuiObjectRef[]): void { assert( inputObjects && inputObjects.length > 0, @@ -161,11 +176,27 @@ export class WalrusStakingBuilder extends TransactionBuilder + (input !== null && typeof input === 'object' && 'BalanceWithdrawal' in input) || + (input?.value !== null && typeof input?.value === 'object' && 'BalanceWithdrawal' in (input.value ?? {})) + ); + if (withdrawalInput) { + const bw = withdrawalInput.BalanceWithdrawal ?? withdrawalInput.value?.BalanceWithdrawal; + this._fundsInAddressBalance = new BigNumber(String(bw.reservation?.MaxAmountU64 ?? bw.amount)); + } + const requests = utils.getWalrusStakeWithPoolRequests(tx.suiTransaction.tx); this.stake(requests); - assert(txData.inputObjects); - this.inputObjects(txData.inputObjects); + if (txData.inputObjects && txData.inputObjects.length > 0) { + this.inputObjects(txData.inputObjects); + } } /** @@ -180,7 +211,17 @@ export class WalrusStakingBuilder extends TransactionBuilder 0; + const hasAddrBal = this._fundsInAddressBalance && this._fundsInAddressBalance.gt(0); + assert( + hasInputObjects || hasAddrBal, + new BuildTransactionError('either inputObjects or fundsInAddressBalance is required before building') + ); + if (hasInputObjects) { + this._inputObjects.forEach((inputObject) => { + this.validateSuiObjectRef(inputObject, 'input object'); + }); + } } /** @@ -194,20 +235,54 @@ export class WalrusStakingBuilder extends TransactionBuilder programmableTxBuilder.object(Inputs.ObjectRef(token))); - const mergedObject = inputObjects.shift() as TransactionArgument; + case SuiTransactionType.WalrusStakeWithPool: { + const walCoinType = `${this.walrusConfig.WAL_PKG_ID}::${this.walrusConfig.WAL_COIN_MODULE_NAME}::${this.walrusConfig.WAL_COIN_NAME}`; + + // Step 1: consolidate any provided Coin objects into a single coin. + let baseWalCoin: TransactionArgument | undefined; + if (this._inputObjects && this._inputObjects.length > 0) { + const inputObjects = this._inputObjects.map((token) => programmableTxBuilder.object(Inputs.ObjectRef(token))); + baseWalCoin = inputObjects.shift() as TransactionArgument; + if (inputObjects.length > 0) { + programmableTxBuilder.mergeCoins(baseWalCoin, inputObjects); + } + } + + // Step 2: redeem address-balance funds as a Coin if requested. + let redeemedCoin: TransactionArgument | undefined; + if (this._fundsInAddressBalance.gt(0)) { + const [coin] = programmableTxBuilder.moveCall({ + target: '0x2::coin::redeem_funds', + typeArguments: [walCoinType], + arguments: [ + programmableTxBuilder.withdrawal({ + amount: BigInt(this._fundsInAddressBalance.toFixed()), + type: walCoinType, + }), + ], + }); + redeemedCoin = coin; + } - if (inputObjects.length > 0) { - programmableTxBuilder.mergeCoins(mergedObject, inputObjects); + // Step 3: combine into a single definite walCoin. + let walCoin: TransactionArgument; + if (baseWalCoin !== undefined && redeemedCoin !== undefined) { + programmableTxBuilder.mergeCoins(baseWalCoin, [redeemedCoin]); + walCoin = baseWalCoin; + } else if (baseWalCoin !== undefined) { + walCoin = baseWalCoin; + } else if (redeemedCoin !== undefined) { + walCoin = redeemedCoin; + } else { + // Unreachable: validateTransactionFields ensures at least one source. + throw new BuildTransactionError('either inputObjects or fundsInAddressBalance is required before building'); } - // Create a new coin with staking balance, based on the coins used as gas payment. + // Create a new coin with staking balance and stake each request. const stakedWals = this._stakeWithPoolTx.map((req) => { - const splitObject = programmableTxBuilder.splitCoins(mergedObject, [ + const splitObject = programmableTxBuilder.splitCoins(walCoin, [ programmableTxBuilder.pure(Number(req.amount)), ]); - // Stake the split coin to a specific validator address. return programmableTxBuilder.moveCall({ target: `${this.walrusConfig.WALRUS_PKG_ID}::${this.walrusConfig.WALRUS_STAKING_MODULE_NAME}::${this.walrusConfig.WALRUS_STAKE_WITH_POOL_FUN_NAME}`, arguments: [ @@ -218,8 +293,17 @@ export class WalrusStakingBuilder extends TransactionBuilder cannot be merged into the SUI gas coin — transfer any + // residual WAL coin back to sender to avoid a no-drop violation. + if (redeemedCoin !== undefined) { + programmableTxBuilder.transferObjects([walCoin], programmableTxBuilder.object(this._sender)); + } + break; + } default: throw new InvalidTransactionError(`unsupported target method`); } @@ -236,6 +320,8 @@ export class WalrusStakingBuilder extends TransactionBuilder 0 && { inputObjects }), + fundsInAddressBalance: tx.fundsInAddressBalance, }; } @@ -191,7 +193,15 @@ export class WalrusStakingTransaction extends Transaction { - if (arg.kind === 'Input') { - let input = inputs[arg.index]; - if ('value' in input) { - input = input.value; - } - if ('Object' in input && isImmOrOwnedObj(input.Object)) { - inputObjects.push(input.Object.ImmOrOwned); + for (const txn of tx.transactions as SuiTransactionBlockType[]) { + let args: TransactionArgument[] = []; + if (txn.kind === 'MergeCoins') { + const { destination, sources } = txn; + args = [destination, ...sources]; + } else if (txn.kind === 'SplitCoins') { + args = [txn.coin]; + } else { + continue; + } + + args.forEach((arg) => { + if (arg.kind === 'Input') { + let input = inputs[arg.index]; + if ('value' in input) { + input = input.value; + } + if ('Object' in input && isImmOrOwnedObj(input.Object)) { + inputObjects.push(input.Object.ImmOrOwned); + } } + }); + + // Stop after the first merge/split that yields coin object refs. + if (inputObjects.length > 0) { + break; } - }); + } return inputObjects; } diff --git a/modules/sdk-coin-sui/test/unit/transactionBuilder/walrusStakingBuilder.ts b/modules/sdk-coin-sui/test/unit/transactionBuilder/walrusStakingBuilder.ts index 38eb35aa5f..96edb4b819 100644 --- a/modules/sdk-coin-sui/test/unit/transactionBuilder/walrusStakingBuilder.ts +++ b/modules/sdk-coin-sui/test/unit/transactionBuilder/walrusStakingBuilder.ts @@ -50,6 +50,7 @@ describe('Walrus Staking Builder', () => { const ptb = tx.suiTransaction.tx; ptb.inputs.length.should.equal(5); // WAL object, Staking shared object, Amount, Validator ptb.transactions[0].kind.should.equal('SplitCoins'); // Only providing one WAL token + should.not.exist(tx.suiTransaction.fundsInAddressBalance); }); it('should build a staking tx for multiple gas objects and WAL tokens', async function () { @@ -101,7 +102,148 @@ describe('Walrus Staking Builder', () => { const rebuiltTx = await rebuilder.build(); rebuiltTx.toBroadcastFormat().should.equal(rawTx); rebuiltTx.toJson().gasData.payment.length.should.equal(numberOfGasPaymentObjects); - rebuiltTx.toJson().inputObjects.length.should.equal(numberOfInputObjects); + rebuiltTx.toJson().inputObjects!.length.should.equal(numberOfInputObjects); + }); + + it('should build a staking tx with mixed WAL coin objects and address balance', async function () { + const addrBal = testData.STAKING_AMOUNT.toString(); + + const txBuilder = factory.getWalrusStakingBuilder(); + txBuilder.type(SuiTransactionType.WalrusStakeWithPool); + txBuilder.sender(testData.sender.address); + txBuilder.stake([testData.requestWalrusStakeWithPool]); + txBuilder.gasData(testData.gasData); + txBuilder.inputObjects([testData.walToken]); + txBuilder.fundsInAddressBalance(addrBal); + const tx = await txBuilder.build(); + + assert(tx instanceof SuiTransaction); + tx.type.should.equal(TransactionType.StakingAdd); + should.exist(tx.suiTransaction.fundsInAddressBalance); + tx.suiTransaction.fundsInAddressBalance!.should.equal(addrBal); + + const ptb = tx.suiTransaction.tx; + // redeem_funds MoveCall, mergeCoins(walCoin, redeemedCoin), splitCoins, stake_with_pool MoveCall, + // transferObjects(stakedWals), transferObjects(residualWal) + const kinds = ptb.transactions.map((t: any) => t.kind); + kinds.should.containEql('MoveCall'); // redeem_funds + kinds.should.containEql('MergeCoins'); // merge redeemed into existing coin + kinds.should.containEql('SplitCoins'); + kinds.filter((k: string) => k === 'TransferObjects').length.should.equal(2); // staked WALs + residual WAL + + const rawTx = tx.toBroadcastFormat(); + utils.isValidRawTransaction(rawTx).should.be.true(); + + // Round-trip: rebuild from raw transaction + const rebuilder = factory.from(rawTx); + rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); + const rebuiltTx = await rebuilder.build(); + rebuiltTx.toBroadcastFormat().should.equal(rawTx); + should.exist(rebuiltTx.toJson().fundsInAddressBalance); + rebuiltTx.toJson().fundsInAddressBalance!.should.equal(addrBal); + rebuiltTx.toJson().inputObjects!.length.should.equal(1); + }); + + it('should build a staking tx with address balance only (exact amount)', async function () { + const addrBal = testData.STAKING_AMOUNT.toString(); + + const txBuilder = factory.getWalrusStakingBuilder(); + txBuilder.type(SuiTransactionType.WalrusStakeWithPool); + txBuilder.sender(testData.sender.address); + txBuilder.stake([testData.requestWalrusStakeWithPool]); + txBuilder.gasData(testData.gasData); + txBuilder.fundsInAddressBalance(addrBal); + const tx = await txBuilder.build(); + + assert(tx instanceof SuiTransaction); + tx.type.should.equal(TransactionType.StakingAdd); + should.exist(tx.suiTransaction.fundsInAddressBalance); + tx.suiTransaction.fundsInAddressBalance!.should.equal(addrBal); + + const ptb = tx.suiTransaction.tx; + // No coin object inputs — first tx is MoveCall (redeem_funds) + ptb.transactions[0].kind.should.equal('MoveCall'); + + // Verify redeem_funds uses WAL type, not SUI + const redeemCall = ptb.transactions[0] as any; + redeemCall.typeArguments[0].should.containEql('::wal::WAL'); + + // BalanceWithdrawal input must carry the WAL coin type, not SUI + const withdrawalInput = ptb.inputs.find((i: any) => { + const v = i?.BalanceWithdrawal ?? i?.value?.BalanceWithdrawal; + return v !== undefined; + }) as any; + should.exist(withdrawalInput); + const bw = withdrawalInput.BalanceWithdrawal ?? withdrawalInput.value?.BalanceWithdrawal; + // typeArg is { Balance: } where TypeTag encodes the coin type + JSON.stringify(bw.typeArg).should.containEql('wal'); + + // Two TransferObjects: staked WALs + residual WAL + const transferTxs = ptb.transactions.filter((t: any) => t.kind === 'TransferObjects'); + transferTxs.length.should.equal(2); + + should.not.exist(tx.toJson().inputObjects); + + const rawTx = tx.toBroadcastFormat(); + utils.isValidRawTransaction(rawTx).should.be.true(); + + // Round-trip: rebuild from raw transaction + const rebuilder = factory.from(rawTx); + rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); + const rebuiltTx = await rebuilder.build(); + rebuiltTx.toBroadcastFormat().should.equal(rawTx); + should.exist(rebuiltTx.toJson().fundsInAddressBalance); + rebuiltTx.toJson().fundsInAddressBalance!.should.equal(addrBal); + }); + + it('should build a staking tx with address balance only (partial / residual transfer)', async function () { + // Request is for STAKING_AMOUNT but we fund double that from addr-bal — residual goes back to sender + const addrBal = (testData.STAKING_AMOUNT * 2).toString(); + + const txBuilder = factory.getWalrusStakingBuilder(); + txBuilder.type(SuiTransactionType.WalrusStakeWithPool); + txBuilder.sender(testData.sender.address); + txBuilder.stake([testData.requestWalrusStakeWithPool]); + txBuilder.gasData(testData.gasData); + txBuilder.fundsInAddressBalance(addrBal); + const tx = await txBuilder.build(); + + assert(tx instanceof SuiTransaction); + should.exist(tx.suiTransaction.fundsInAddressBalance); + tx.suiTransaction.fundsInAddressBalance!.should.equal(addrBal); + + const ptb = tx.suiTransaction.tx; + // residual WAL coin must be transferred back to sender + const transferTxs = ptb.transactions.filter((t: any) => t.kind === 'TransferObjects'); + transferTxs.length.should.equal(2); + + const rawTx = tx.toBroadcastFormat(); + utils.isValidRawTransaction(rawTx).should.be.true(); + + const rebuilder = factory.from(rawTx); + rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); + const rebuiltTx = await rebuilder.build(); + rebuiltTx.toBroadcastFormat().should.equal(rawTx); + should.exist(rebuiltTx.toJson().fundsInAddressBalance); + rebuiltTx.toJson().fundsInAddressBalance!.should.equal(addrBal); + }); + + it('should have default fundsInAddressBalance of 0 when setter not called', async function () { + const txBuilder = factory.getWalrusStakingBuilder(); + txBuilder.type(SuiTransactionType.WalrusStakeWithPool); + txBuilder.sender(testData.sender.address); + txBuilder.stake([testData.requestWalrusStakeWithPool]); + txBuilder.gasData(testData.gasData); + txBuilder.inputObjects([testData.walToken]); + const tx = await txBuilder.build(); + + assert(tx instanceof SuiTransaction); + should.not.exist(tx.suiTransaction.fundsInAddressBalance); + + // PTB should not contain a redeem_funds call + const ptb = tx.suiTransaction.tx; + const moveCalls = ptb.transactions.filter((t: any) => t.kind === 'MoveCall'); + moveCalls.every((mc: any) => !mc.target?.includes('redeem_funds')).should.be.true(); }); }); @@ -137,5 +279,16 @@ describe('Walrus Staking Builder', () => { }; should(() => builder.gasData(invalidGasPayment)).throwError('Invalid payment, invalid or missing version'); }); + + it('should fail when neither inputObjects nor fundsInAddressBalance is provided', async function () { + const txBuilder = factory.getWalrusStakingBuilder(); + txBuilder.type(SuiTransactionType.WalrusStakeWithPool); + txBuilder.sender(testData.sender.address); + txBuilder.stake([testData.requestWalrusStakeWithPool]); + txBuilder.gasData(testData.gasData); + await txBuilder + .build() + .should.be.rejectedWith('either inputObjects or fundsInAddressBalance is required before building'); + }); }); });