diff --git a/modules/sdk-coin-sui/src/lib/stakingBuilder.ts b/modules/sdk-coin-sui/src/lib/stakingBuilder.ts index b1a2684879..dae40b9112 100644 --- a/modules/sdk-coin-sui/src/lib/stakingBuilder.ts +++ b/modules/sdk-coin-sui/src/lib/stakingBuilder.ts @@ -15,7 +15,6 @@ import { TransferTransaction } from './transferTransaction'; import { StakingTransaction } from './stakingTransaction'; import { TransactionBlock as ProgrammingTransactionBlockBuilder, - TransactionBlockInput, MoveCallTransaction, Inputs, } from './mystenlab/builder'; @@ -29,7 +28,7 @@ import { import { BCS } from '@mysten/bcs'; export class StakingBuilder extends TransactionBuilder { - protected _addStakeTx: RequestAddStake; + protected _addStakeTx: RequestAddStake[]; protected _withdrawDelegation: RequestWithdrawStakedSui; constructor(_coinConfig: Readonly) { @@ -81,15 +80,17 @@ export class StakingBuilder extends TransactionBuilder { + utils.validateAddress(req.validatorAddress, 'validatorAddress'); + assert(utils.isValidAmount(req.amount), 'Invalid recipient amount'); + + if (this._sender === req.validatorAddress) { + throw new BuildTransactionError('Sender address cannot be the same as the Staking address'); + } + }); this._addStakeTx = request; return this; @@ -149,19 +150,8 @@ export class StakingBuilder extends TransactionBuilder { + assert(req.validatorAddress, new BuildTransactionError('validator address is required before building')); + assert(req.amount, new BuildTransactionError('staking amount is required before building')); + }); assert(this._gasData, new BuildTransactionError('gasData is required before building')); this.validateGasData(this._gasData); } @@ -192,18 +181,20 @@ export class StakingBuilder extends TransactionBuilder { + const coin = programmableTxBuilder.splitCoins(programmableTxBuilder.gas, [ + programmableTxBuilder.pure(req.amount), + ]); + // Stake the split coin to a specific validator address. + programmableTxBuilder.moveCall({ + target: `${SUI_SYSTEM_ADDRESS}::${SUI_SYSTEM_MODULE_NAME}::${ADD_STAKE_FUN_NAME}`, + arguments: [ + programmableTxBuilder.object(Inputs.SharedObjectRef(SUI_SYSTEM_STATE_OBJECT)), + coin, + programmableTxBuilder.pure(Inputs.Pure(req.validatorAddress, BCS.ADDRESS)), + ], + } as MoveCallTransaction); + }); break; case SuiTransactionType.WithdrawStake: // Unstake staked object. diff --git a/modules/sdk-coin-sui/src/lib/stakingTransaction.ts b/modules/sdk-coin-sui/src/lib/stakingTransaction.ts index 68818e321a..072f452713 100644 --- a/modules/sdk-coin-sui/src/lib/stakingTransaction.ts +++ b/modules/sdk-coin-sui/src/lib/stakingTransaction.ts @@ -127,30 +127,19 @@ export class StakingTransaction extends Transaction { + return { + address: request.validatorAddress, + value: request.amount.toString(), coin: this._coinConfig.name, - }, - ]; + }; + }); + this._inputs = [ { address: this.suiTransaction.sender, - value: Number(amount).toString(), + value: this._outputs.reduce((acc, output) => acc + Number(output.value), 0).toString(), coin: this._coinConfig.name, }, ]; diff --git a/modules/sdk-coin-sui/src/lib/utils.ts b/modules/sdk-coin-sui/src/lib/utils.ts index a62fd6c538..20808aadf9 100644 --- a/modules/sdk-coin-sui/src/lib/utils.ts +++ b/modules/sdk-coin-sui/src/lib/utils.ts @@ -17,6 +17,7 @@ import { SuiTransactionType, TransferProgrammableTransaction, StakingProgrammableTransaction, + RequestAddStake, } from './iface'; import { Buffer } from 'buffer'; import { @@ -28,7 +29,9 @@ import { } from './mystenlab/types'; import { builder, + MoveCallTransaction, ObjectCallArg, + SplitCoinsTransaction, TransactionBlockInput, TransactionType as TransactionCommandType, } from './mystenlab/builder'; @@ -236,6 +239,35 @@ export class Utils implements BaseUtils { }); } + /** + * Get add staking requests + * + * @param {StakingProgrammableTransaction} tx: staking transaction object + * @return {RequestAddStake[]} add staking requests + */ + getStakeRequests(tx: StakingProgrammableTransaction): RequestAddStake[] { + const amounts: number[] = []; + const addresses: string[] = []; + tx.transactions.forEach((transaction, i) => { + if (transaction.kind === 'SplitCoins') { + const amountInputIdx = ((transaction as SplitCoinsTransaction).amounts[0] as TransactionBlockInput).index; + amounts.push(utils.getAmount(tx.inputs[amountInputIdx] as TransactionBlockInput)); + } + if (transaction.kind === 'MoveCall') { + const validatorAddressInputIdx = ((transaction as MoveCallTransaction).arguments[2] as TransactionBlockInput) + .index; + const validatorAddress = utils.getAddress(tx.inputs[validatorAddressInputIdx] as TransactionBlockInput); + addresses.push(validatorAddress); + } + }); + return addresses.map((address, index) => { + return { + validatorAddress: address, + amount: amounts[index], + } as RequestAddStake; + }); + } + getAmount(input: SuiJsonValue | TransactionBlockInput): number { return isPureArg(input) ? builder.de(BCS.U64, Buffer.from(input.Pure).toString('base64'), 'base64') diff --git a/modules/sdk-coin-sui/test/local_fullnode/transactions.ts b/modules/sdk-coin-sui/test/local_fullnode/transactions.ts index 801eba101a..c805b2d27b 100644 --- a/modules/sdk-coin-sui/test/local_fullnode/transactions.ts +++ b/modules/sdk-coin-sui/test/local_fullnode/transactions.ts @@ -194,7 +194,7 @@ describe('Sui Transaction Types', function () { const txb = builder .type(SuiTransactionType.AddStake) .sender(keyPair.getAddress()) - .stake({ amount, validatorAddress: validator }) + .stake([{ amount, validatorAddress: validator }]) .gasData(await getDefaultGasData(keyPair)); await signAndSubmit(conn, keyPair, txb); diff --git a/modules/sdk-coin-sui/test/resources/sui.ts b/modules/sdk-coin-sui/test/resources/sui.ts index 4197f13859..556f492d8c 100644 --- a/modules/sdk-coin-sui/test/resources/sui.ts +++ b/modules/sdk-coin-sui/test/resources/sui.ts @@ -360,6 +360,8 @@ export const INVALID_RAW_TX = 'AAAAAAAAAAAAA6e7361637469bc4a58e500b9e64cb6547ee9b403000000000000002064ba1fb2f2fbd2938a350015d601f4db89cd7e8e2370d0dd9ae3ac4f635c1581111b8a49f67370bc4a58e500b9e64cb6462e39b802000000000000002064ba1fb2f2fbd2938a350015d601f4db89cd7e8e2370d0dd9ae3ac47aa1ff81f01c4173a804406a365e69dfb297d4eaaf002546ebd016400000000000000cba4a48bb0f8b586c167e5dcefaa1c5e96ab3f0836d6ca08f2081732944d1e5b6b406a4a462e39b8030000000000000020b9490ede63215262c434e03f606d9799f3ba704523ceda184b386d47aa1ff81f01000000000000006400000000000000'; export const ADD_STAKE = 'AAADAAgALTEBAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUBAAAAAAAAAAEAIESxsxniNJWZX8g32v0o/Gr4tkXt3f8PwUZ/GtYxNiwjAgIAAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwpzdWlfc3lzdGVtEXJlcXVlc3RfYWRkX3N0YWtlAAMBAQACAAABAgCYghiLo+gHCpuwaulEbPYHkU7o7ljtgwaj46//Whu7cQIJxAUirtVLzs+kg2BcXaWCGxcawaobYVlx+43+J+0T/VEEAAAAAAAAILZEZ8lcLtxXB9dIMp0rBAH+s8LT/e12XMNKiaW4bnuyJ90A5/zNyHtNlbY4S3ORGbkfKoGha67ep/TgBo5SlDfZAAAAAAAAACC+KT7TKlmOYLySRsTgG25Ck38WiZCIOmogUHrCwU0nJ5iCGIuj6AcKm7Bq6URs9geRTujuWO2DBqPjr/9aG7tx6AMAAAAAAAAALTEBAAAAAAA='; +export const STAKE_MANY = + 'AAAJAAgALTEBAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUBAAAAAAAAAAEAIESxsxniNJWZX8g32v0o/Gr4tkXt3f8PwUZ/GtYxNiwjAAgArCP8BgAAAAAgRLGzGeI0lZlfyDfa/Sj8avi2Re3d/w/BRn8a1jE2LCYACAAtMQEAAAAAACBEsbMZ4jSVmV/IN9r9KPxq+LZF7d3/D8FGfxrWMTYsJgAIAKwj/AYAAAAAIESxsxniNJWZX8g32v0o/Gr4tkXt3f8PwUZ/GtYxNiwjCAIAAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwpzdWlfc3lzdGVtEXJlcXVlc3RfYWRkX3N0YWtlAAMBAQACAAABAgACAAEBAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMKc3VpX3N5c3RlbRFyZXF1ZXN0X2FkZF9zdGFrZQADAQEAAgIAAQQAAgABAQUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADCnN1aV9zeXN0ZW0RcmVxdWVzdF9hZGRfc3Rha2UAAwEBAAIEAAEGAAIAAQEHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwpzdWlfc3lzdGVtEXJlcXVlc3RfYWRkX3N0YWtlAAMBAQACBgABCACYghiLo+gHCpuwaulEbPYHkU7o7ljtgwaj46//Whu7cQIJxAUirtVLzs+kg2BcXaWCGxcawaobYVlx+43+J+0T/VEEAAAAAAAAILZEZ8lcLtxXB9dIMp0rBAH+s8LT/e12XMNKiaW4bnuyJ90A5/zNyHtNlbY4S3ORGbkfKoGha67ep/TgBo5SlDfZAAAAAAAAACC+KT7TKlmOYLySRsTgG25Ck38WiZCIOmogUHrCwU0nJ5iCGIuj6AcKm7Bq6URs9geRTujuWO2DBqPjr/9aG7tx6AMAAAAAAAAALTEBAAAAAAA='; export const WITHDRAW_STAKED_SUI = 'AAACAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQEAAAAAAAAAAQEA7m38PaMuIVQaKurfzSUPigoju3q9qciYhAf8MgaMN0ZhBAAAAAAAACDJYCWUFis6HawzxGyErvRT03pYayRliLki0kYsV0XCBAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMKc3VpX3N5c3RlbRZyZXF1ZXN0X3dpdGhkcmF3X3N0YWtlAAIBAAABAQCYghiLo+gHCpuwaulEbPYHkU7o7ljtgwaj46//Whu7cQIJxAUirtVLzs+kg2BcXaWCGxcawaobYVlx+43+J+0T/VEEAAAAAAAAILZEZ8lcLtxXB9dIMp0rBAH+s8LT/e12XMNKiaW4bnuyJ90A5/zNyHtNlbY4S3ORGbkfKoGha67ep/TgBo5SlDfZAAAAAAAAACC+KT7TKlmOYLySRsTgG25Ck38WiZCIOmogUHrCwU0nJ5iCGIuj6AcKm7Bq6URs9geRTujuWO2DBqPjr/9aG7tx6AMAAAAAAAAALTEBAAAAAAA='; @@ -381,11 +383,34 @@ export const STAKING_AMOUNT = 20000000; export const VALIDATOR_ADDRESS = '0x44b1b319e23495995fc837dafd28fc6af8b645edddff0fc1467f1ad631362c23'; +export const STAKING_AMOUNT_2 = 30000000000; + +export const VALIDATOR_ADDRESS_2 = '0x44b1b319e23495995fc837dafd28fc6af8b645edddff0fc1467f1ad631362c26'; + export const requestAddStake: RequestAddStake = { amount: STAKING_AMOUNT, validatorAddress: VALIDATOR_ADDRESS, }; +export const requestAddStakeMany: RequestAddStake[] = [ + { + amount: STAKING_AMOUNT, + validatorAddress: VALIDATOR_ADDRESS, + }, + { + amount: STAKING_AMOUNT_2, + validatorAddress: VALIDATOR_ADDRESS_2, + }, + { + amount: STAKING_AMOUNT, + validatorAddress: VALIDATOR_ADDRESS_2, + }, + { + amount: STAKING_AMOUNT_2, + validatorAddress: VALIDATOR_ADDRESS, + }, +]; + export const requestWithdrawStakedSui: RequestWithdrawStakedSui = { stakedSui: { objectId: '0xee6dfc3da32e21541a2aeadfcd250f8a0a23bb7abda9c8988407fc32068c3746', diff --git a/modules/sdk-coin-sui/test/unit/transactionBuilder/stakingBuilder.ts b/modules/sdk-coin-sui/test/unit/transactionBuilder/stakingBuilder.ts index a5cdc31453..0e37f90bfc 100644 --- a/modules/sdk-coin-sui/test/unit/transactionBuilder/stakingBuilder.ts +++ b/modules/sdk-coin-sui/test/unit/transactionBuilder/stakingBuilder.ts @@ -14,7 +14,7 @@ describe('Sui Staking Builder', () => { const txBuilder = factory.getStakingBuilder(); txBuilder.type(SuiTransactionType.AddStake); txBuilder.sender(testData.sender.address); - txBuilder.stake(testData.requestAddStake); + txBuilder.stake([testData.requestAddStake]); txBuilder.gasData(testData.gasData); const tx = await txBuilder.build(); should.equal(tx.type, TransactionType.StakingAdd); diff --git a/modules/sdk-coin-sui/test/unit/transactionBuilder/transactionBuilder.ts b/modules/sdk-coin-sui/test/unit/transactionBuilder/transactionBuilder.ts index 2d3c61d8d3..3e9f7060cf 100644 --- a/modules/sdk-coin-sui/test/unit/transactionBuilder/transactionBuilder.ts +++ b/modules/sdk-coin-sui/test/unit/transactionBuilder/transactionBuilder.ts @@ -270,13 +270,27 @@ describe('Sui Transaction Builder', async () => { const txBuilder = factory.getStakingBuilder(); txBuilder.type(SuiTransactionType.AddStake); txBuilder.sender(testData.sender.address); - txBuilder.stake(testData.requestAddStake); + txBuilder.stake([testData.requestAddStake]); txBuilder.gasData(testData.gasData); const tx = await txBuilder.build(); should.equal(tx.type, TransactionType.StakingAdd); const rawTx = tx.toBroadcastFormat(); should.equal(rawTx, testData.ADD_STAKE); const reserialized = await factory.from(rawTx).build(); + reserialized.toBroadcastFormat().should.equal(rawTx); + }); + + it('should build an stakeMany transaction and serialize it and deserialize it', async function () { + const txBuilder = factory.getStakingBuilder(); + txBuilder.type(SuiTransactionType.AddStake); + txBuilder.sender(testData.sender.address); + txBuilder.stake(testData.requestAddStakeMany); + txBuilder.gasData(testData.gasData); + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.StakingAdd); + const rawTx = tx.toBroadcastFormat(); + should.equal(rawTx, testData.STAKE_MANY); + const reserialized = await factory.from(rawTx).build(); // reserialized.should.be.deepEqual(tx); reserialized.toBroadcastFormat().should.equal(rawTx); }); @@ -285,7 +299,7 @@ describe('Sui Transaction Builder', async () => { const txBuilder = factory.getStakingBuilder(); txBuilder.type(SuiTransactionType.AddStake); txBuilder.sender(testData.sender.address); - txBuilder.stake(testData.requestAddStake); + txBuilder.stake([testData.requestAddStake]); txBuilder.gasData(testData.gasData); const tx = await txBuilder.build(); should.equal(tx.id, 'bP78boZ48sDdJsg2V1tJahpGyBwaC9GSTL2rvyADnsh'); @@ -312,9 +326,9 @@ describe('Sui Transaction Builder', async () => { const keyPairSender = new KeyPair({ prv: testData.privateKeys.prvKey1 }); const senderAddress = keyPairSender.getAddress(); const expectedStakingTxSig = - 'AIyRgcm//edb10JKGtf0LdgR6AlFesXycRAGhMZHm1cisZSAijp/5n3yxuJ/GHOWj9TbamznigxLfPMVPZh9pQ2lzaq1j4wMuCiXuFW4ojFfuoBhEiBy/K4eB5BkHZ+eZw=='; + 'AD8755e+kA3/Iy+3oRxBbQiK0Iz4qmD4sZcpoQN0UMPxIXv7Qx4twvuAiZf9H2nHYa/Ae0asM4Rkz1SCP0dhXgqlzaq1j4wMuCiXuFW4ojFfuoBhEiBy/K4eB5BkHZ+eZw=='; const expectedStakingTxHex = - 'AAADAAgALTEBAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUBAAAAAAAAAAEAIESxsxniNJWZX8g32v0o/Gr4tkXt3f8PwUZ/GtYxNiwjAgIAAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwpzdWlfc3lzdGVtEXJlcXVlc3RfYWRkX3N0YWtlAAMBAQACAAABAgCQB0K7kj6pBqVvMloXWXdS7NOsMCa7qW4O3/Rd6NszqwJ3AbiIwWM5ms1bgEYzwdDlMrwxQ8/vNMo2C+YHxo3N72YEAAAAAAAAIDrMcEOTidjOdp7a1J/jjJ9tOjb6P2WTyBSwQqAHiA1/yfVla+cYIwE9k34GVOs+3LJhla/SMAm+mrlufz8twgNmBAAAAAAAACAhXnkXobS2E/RZ/cLDQ/n3BH/TxAjKv5VxsbLEZCUxu5AHQruSPqkGpW8yWhdZd1Ls06wwJrupbg7f9F3o2zOr6AMAAAAAAAAALTEBAAAAAAA='; + 'AAAJAAgALTEBAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUBAAAAAAAAAAEAIESxsxniNJWZX8g32v0o/Gr4tkXt3f8PwUZ/GtYxNiwjAAgArCP8BgAAAAAgRLGzGeI0lZlfyDfa/Sj8avi2Re3d/w/BRn8a1jE2LCYACAAtMQEAAAAAACBEsbMZ4jSVmV/IN9r9KPxq+LZF7d3/D8FGfxrWMTYsJgAIAKwj/AYAAAAAIESxsxniNJWZX8g32v0o/Gr4tkXt3f8PwUZ/GtYxNiwjCAIAAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwpzdWlfc3lzdGVtEXJlcXVlc3RfYWRkX3N0YWtlAAMBAQACAAABAgACAAEBAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMKc3VpX3N5c3RlbRFyZXF1ZXN0X2FkZF9zdGFrZQADAQEAAgIAAQQAAgABAQUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADCnN1aV9zeXN0ZW0RcmVxdWVzdF9hZGRfc3Rha2UAAwEBAAIEAAEGAAIAAQEHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwpzdWlfc3lzdGVtEXJlcXVlc3RfYWRkX3N0YWtlAAMBAQACBgABCACQB0K7kj6pBqVvMloXWXdS7NOsMCa7qW4O3/Rd6NszqwJ3AbiIwWM5ms1bgEYzwdDlMrwxQ8/vNMo2C+YHxo3N72YEAAAAAAAAIDrMcEOTidjOdp7a1J/jjJ9tOjb6P2WTyBSwQqAHiA1/yfVla+cYIwE9k34GVOs+3LJhla/SMAm+mrlufz8twgNmBAAAAAAAACAhXnkXobS2E/RZ/cLDQ/n3BH/TxAjKv5VxsbLEZCUxu5AHQruSPqkGpW8yWhdZd1Ls06wwJrupbg7f9F3o2zOr6AMAAAAAAAAALTEBAAAAAAA='; const coins = [ { @@ -331,7 +345,7 @@ describe('Sui Transaction Builder', async () => { const txBuilder_1 = factory.getStakingBuilder(); txBuilder_1.type(SuiTransactionType.AddStake); txBuilder_1.sender(senderAddress); - txBuilder_1.stake(testData.requestAddStake); + txBuilder_1.stake(testData.requestAddStakeMany); const gasData: GasData = { payment: coins, owner: senderAddress, @@ -368,7 +382,7 @@ describe('Sui Transaction Builder', async () => { it('should fail to build if missing type', async function () { for (const txBuilder of builders) { txBuilder.sender(testData.sender.address); - txBuilder.stake(testData.requestAddStake); + txBuilder.stake([testData.requestAddStake]); txBuilder.gasData(testData.gasData); await txBuilder.build().should.rejectedWith('type is required before building'); } @@ -377,7 +391,7 @@ describe('Sui Transaction Builder', async () => { it('should fail to build if missing sender', async function () { for (const txBuilder of builders) { txBuilder.type(SuiTransactionType.AddStake); - txBuilder.stake(testData.requestAddStake); + txBuilder.stake([testData.requestAddStake]); txBuilder.gasData(testData.gasData); await txBuilder.build().should.rejectedWith('sender is required before building'); } @@ -387,7 +401,7 @@ describe('Sui Transaction Builder', async () => { for (const txBuilder of builders) { txBuilder.sender(testData.sender.address); txBuilder.type(SuiTransactionType.AddStake); - txBuilder.stake(testData.requestAddStake); + txBuilder.stake([testData.requestAddStake]); await txBuilder.build().should.rejectedWith('gasData is required before building'); } }); @@ -396,7 +410,7 @@ describe('Sui Transaction Builder', async () => { for (const txBuilder of builders) { txBuilder.sender(testData.sender.address); txBuilder.type(SuiTransactionType.AddStake); - txBuilder.stake(testData.requestAddStake); + txBuilder.stake([testData.requestAddStake]); should(() => txBuilder.gasData(testData.gasDataWithoutGasPayment)).throwError( `gas payment is required before building` );