diff --git a/modules/sdk-coin-sui/src/lib/iface.ts b/modules/sdk-coin-sui/src/lib/iface.ts index 6a76cd0982..6c14c57f8f 100644 --- a/modules/sdk-coin-sui/src/lib/iface.ts +++ b/modules/sdk-coin-sui/src/lib/iface.ts @@ -19,6 +19,7 @@ export enum SuiTransactionType { WithdrawStake = 'WithdrawStake', CustomTx = 'CustomTx', TokenTransfer = 'TokenTransfer', + WalrusStakeWithPool = 'WalrusStakeWithPool', } export interface TransactionExplanation extends BaseTransactionExplanation { @@ -30,7 +31,8 @@ export type SuiProgrammableTransaction = | StakingProgrammableTransaction | UnstakingProgrammableTransaction | CustomProgrammableTransaction - | TokenTransferProgrammableTransaction; + | TokenTransferProgrammableTransaction + | WalrusStakingProgrammableTransaction; export interface TxData { id?: string; @@ -78,6 +80,13 @@ export type TokenTransferProgrammableTransaction = transactions: TransactionType[]; }; +export type WalrusStakingProgrammableTransaction = + | ProgrammableTransaction + | { + inputs: CallArg[] | TransactionBlockInput[]; + transactions: TransactionType[]; + }; + export interface SuiTransaction { id?: string; type: SuiTransactionType; @@ -96,6 +105,11 @@ export interface RequestWithdrawStakedSui { stakedSui: SuiObjectRef; } +export interface RequestWalrusStakeWithPool { + amount: number; + validatorAddress: SuiAddress; +} + /** * Method names for the transaction method. Names change based on the type of transaction e.g 'request_add_delegation_mul_coin' for the staking transaction */ @@ -124,6 +138,12 @@ export enum MethodNames { * @see https://github.com/MystenLabs/sui/blob/main/crates/sui-framework/docs/transfer.md#function-public_transfer */ PublicTransfer = '::transfer::public_transfer', + /** + * Walrus stake with pool. + * + * @see https://github.com/MystenLabs/walrus-docs/blob/8ba15d67d7ed0e728077e1600866fddd46fd113b/contracts/walrus/sources/staking.move#L289 + */ + WalrusStakeWithPool = 'stake_with_pool', } export interface SuiObjectInfo extends SuiObjectRef { diff --git a/modules/sdk-coin-sui/src/lib/resources/walrusConfig.ts b/modules/sdk-coin-sui/src/lib/resources/walrusConfig.ts new file mode 100644 index 0000000000..329840a430 --- /dev/null +++ b/modules/sdk-coin-sui/src/lib/resources/walrusConfig.ts @@ -0,0 +1,42 @@ +import { SharedObjectRef } from '../mystenlab/types'; + +export const WALRUS_TESTNET_CONFIG = { + WALRUS_SYSTEM_OBJECT: { + objectId: '0x98ebc47370603fe81d9e15491b2f1443d619d1dab720d586e429ed233e1255c1', + initialSharedVersion: 1, + mutable: true, + } as SharedObjectRef, + + WALRUS_STAKING_OBJECT: { + objectId: '0x20266a17b4f1a216727f3eef5772f8d486a9e3b5e319af80a5b75809c035561d', + initialSharedVersion: 334023834, + mutable: true, + } as SharedObjectRef, + + WALRUS_PKG_ID: '0x795ddbc26b8cfff2551f45e198b87fc19473f2df50f995376b924ac80e56f88b', + WALRUS_STAKING_MODULE_NAME: 'staking', + WALRUS_STAKE_WITH_POOL_FUN_NAME: 'stake_with_pool', + + WAL_PKG_ID: '0x8190b041122eb492bf63cb464476bd68c6b7e570a4079645a8b28732b6197a82', + WAL_COIN_MODULE_NAME: 'wal', + WAL_COIN_NAME: 'WAL', +}; + +export const WALRUS_PROD_CONFIG = { + ...WALRUS_TESTNET_CONFIG, + + WALRUS_SYSTEM_OBJECT: { + objectId: 'TODO PROD CONFIG', + initialSharedVersion: 1, + mutable: true, + } as SharedObjectRef, + + WALRUS_STAKING_OBJECT: { + objectId: 'TODO PROD CONFIG', + initialSharedVersion: 0, // TODO PROD CONFIG + mutable: true, + } as SharedObjectRef, + + WALRUS_PKG_ID: 'TODO PROD CONFIG', + WAL_PKG_ID: 'TODO PROD CONFIG', +}; diff --git a/modules/sdk-coin-sui/src/lib/transaction.ts b/modules/sdk-coin-sui/src/lib/transaction.ts index 58223e6601..2fd8d9b208 100644 --- a/modules/sdk-coin-sui/src/lib/transaction.ts +++ b/modules/sdk-coin-sui/src/lib/transaction.ts @@ -187,6 +187,9 @@ export abstract class Transaction extends BaseTransaction { if (transactions.length == 1) { return utils.getSuiTransactionType(transactions[0]); } + if (transactions.some((tx) => utils.getSuiTransactionType(tx) === SuiTransactionType.WalrusStakeWithPool)) { + return SuiTransactionType.WalrusStakeWithPool; + } if (transactions.some((tx) => utils.getSuiTransactionType(tx) === SuiTransactionType.AddStake)) { return SuiTransactionType.AddStake; } diff --git a/modules/sdk-coin-sui/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-sui/src/lib/transactionBuilderFactory.ts index 1953ad1253..7cfce65261 100644 --- a/modules/sdk-coin-sui/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-sui/src/lib/transactionBuilderFactory.ts @@ -12,6 +12,7 @@ import { UnstakingProgrammableTransaction, SuiProgrammableTransaction, TokenTransferProgrammableTransaction, + WalrusStakingProgrammableTransaction, } from './iface'; import { StakingTransaction } from './stakingTransaction'; import { TransferTransaction } from './transferTransaction'; @@ -23,6 +24,8 @@ import { CustomTransaction } from './customTransaction'; import { CustomTransactionBuilder } from './customTransactionBuilder'; import { TokenTransferBuilder } from './tokenTransferBuilder'; import { TokenTransferTransaction } from './tokenTransferTransaction'; +import { WalrusStakingBuilder } from './walrusStakingBuilder'; +import { WalrusStakingTransaction } from './walrusStakingTransaction'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(_coinConfig: Readonly) { @@ -55,6 +58,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { const tokenTransferTx = new TokenTransferTransaction(this._coinConfig); tokenTransferTx.fromRawTransaction(raw); return this.getTokenTransferBuilder(tokenTransferTx); + case SuiTransactionType.WalrusStakeWithPool: + const walrusStakeTx = new WalrusStakingTransaction(this._coinConfig); + walrusStakeTx.fromRawTransaction(raw); + return this.getWalrusStakingBuilder(walrusStakeTx); default: throw new InvalidTransactionError('Invalid transaction'); } @@ -88,6 +95,11 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.initializeBuilder(tx, new TokenTransferBuilder(this._coinConfig)); } + /** @inheritdoc */ + getWalrusStakingBuilder(tx?: Transaction): WalrusStakingBuilder { + return this.initializeBuilder(tx, new WalrusStakingBuilder(this._coinConfig)); + } + /** @inheritdoc */ getWalletInitializationBuilder(): void { throw new Error('Method not implemented.'); diff --git a/modules/sdk-coin-sui/src/lib/utils.ts b/modules/sdk-coin-sui/src/lib/utils.ts index 62b88820ea..3100e21440 100644 --- a/modules/sdk-coin-sui/src/lib/utils.ts +++ b/modules/sdk-coin-sui/src/lib/utils.ts @@ -14,7 +14,9 @@ import { BCS, fromB64 } from '@mysten/bcs'; import { MethodNames, RequestAddStake, + RequestWalrusStakeWithPool, StakingProgrammableTransaction, + WalrusStakingProgrammableTransaction, SuiObjectInfo, SuiProgrammableTransaction, SuiTransaction, @@ -195,6 +197,7 @@ export class Utils implements BaseUtils { case SuiTransactionType.TokenTransfer: return TransactionType.Send; case SuiTransactionType.AddStake: + case SuiTransactionType.WalrusStakeWithPool: return TransactionType.StakingAdd; case SuiTransactionType.WithdrawStake: return TransactionType.StakingWithdraw; @@ -233,6 +236,8 @@ export class Utils implements BaseUtils { command.target.endsWith(MethodNames.PublicTransfer) ) { return SuiTransactionType.CustomTx; + } else if (command.target.endsWith(MethodNames.WalrusStakeWithPool)) { + return SuiTransactionType.WalrusStakeWithPool; } else { throw new InvalidTransactionError(`unsupported target method ${command.target}`); } @@ -327,6 +332,29 @@ export class Utils implements BaseUtils { }); } + getWalrusStakeWithPoolRequests(tx: WalrusStakingProgrammableTransaction): RequestWalrusStakeWithPool[] { + 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 RequestWalrusStakeWithPool; + }); + } + getAmount(input: SuiJsonValue | TransactionBlockInput): number { return isPureArg(input) ? builder.de(BCS.U64, Buffer.from(new Uint16Array(input.Pure)).toString('base64'), 'base64') diff --git a/modules/sdk-coin-sui/src/lib/walrusStakingBuilder.ts b/modules/sdk-coin-sui/src/lib/walrusStakingBuilder.ts new file mode 100644 index 0000000000..83268cda5f --- /dev/null +++ b/modules/sdk-coin-sui/src/lib/walrusStakingBuilder.ts @@ -0,0 +1,241 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { BaseKey, BuildTransactionError, InvalidTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { + SuiTransaction, + RequestWalrusStakeWithPool, + SuiTransactionType, + WalrusStakingProgrammableTransaction, +} from './iface'; +import { TransactionBuilder } from './transactionBuilder'; +import { Transaction } from './transaction'; +import utils from './utils'; +import assert from 'assert'; +import { TransferTransaction } from './transferTransaction'; +import { StakingTransaction } from './stakingTransaction'; +import { + TransactionBlock as ProgrammingTransactionBlockBuilder, + MoveCallTransaction, + Inputs, + TransactionArgument, +} from './mystenlab/builder'; +import { MAX_GAS_OBJECTS } from './constants'; +import { WALRUS_TESTNET_CONFIG, WALRUS_PROD_CONFIG } from './resources/walrusConfig'; +import { SuiObjectRef } from './mystenlab/types'; + +export class WalrusStakingBuilder extends TransactionBuilder { + protected _stakeWithPoolTx: RequestWalrusStakeWithPool[]; + protected _inputObjects: SuiObjectRef[]; + + private walrusConfig: any; // TODO improve + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._transaction = new StakingTransaction(_coinConfig); + + // TODO improve mainnet vs. testnet configuration + this.walrusConfig = _coinConfig.network.name === 'Mainnet' ? WALRUS_PROD_CONFIG : WALRUS_TESTNET_CONFIG; + } + + /** + * Build a MoveCall transaction ready to be signed and executed. + * + * @returns {BitGoSuiTransaction} an unsigned Sui transaction + */ + protected buildStakeTransaction(): SuiTransaction { + return { + type: SuiTransactionType.WalrusStakeWithPool, + sender: this._sender, + tx: { + inputs: [], + transactions: [], + }, + gasData: this._gasData, + }; + } + + /** + * Get staking transaction type + * + * @return {TransactionType} + * @protected + */ + protected get transactionType(): TransactionType { + return TransactionType.StakingAdd; + } + + /** @inheritdoc */ + validateTransaction(transaction: TransferTransaction): void { + if (!transaction.suiTransaction) { + return; + } + this.validateTransactionFields(); + } + + /** @inheritdoc */ + sign(key: BaseKey) { + this.transaction.setSuiTransaction(this.buildSuiTransaction()); + super.sign(key); + } + + /** + * Create a new transaction for staking coins ready to be signed and executed. + * + * @param {RequestWalrusStakeWithPool[]} request: a list of staking request + */ + stake(request: RequestWalrusStakeWithPool[]): this { + request.forEach((req) => { + 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._stakeWithPoolTx = request; + return this; + } + + /** + * Set the $WAL objects to be used for staking. + * + * @param tokens The WAL objects to be used + * @returns this + */ + inputObjects(inputObjects: SuiObjectRef[]): this { + this.validateInputObjects(inputObjects); + this._inputObjects = inputObjects; + return this; + } + + private validateInputObjects(inputObjects: SuiObjectRef[]): void { + assert( + inputObjects && inputObjects.length > 0, + new BuildTransactionError('input objects required before building') + ); + inputObjects.forEach((inputObject) => { + this.validateSuiObjectRef(inputObject, 'input object'); + }); + } + + /** @inheritdoc */ + protected fromImplementation(rawTransaction: string): Transaction { + const tx = new StakingTransaction(this._coinConfig); + this.validateRawTransaction(rawTransaction); + tx.fromRawTransaction(rawTransaction); + this.initBuilder(tx); + return this.transaction; + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise> { + this.transaction.setSuiTransaction(this.buildSuiTransaction()); + this.transaction.transactionType(this.transactionType); + + if (this._signer) { + this.transaction.sign(this._signer); + } + + this._signatures.forEach((signature) => { + this.transaction.addSignature(signature.publicKey, signature.signature); + }); + + this.transaction.loadInputsAndOutputs(); + return this.transaction; + } + + /** + * Initialize the transaction builder fields using the decoded transaction data + * + * @param {StakingTransaction} tx the transaction data + */ + initBuilder(tx: Transaction): void { + this._transaction = tx; + + if (tx.signature && tx.signature.length > 0) { + this._signatures = [tx.suiSignature]; + } + + const txData = tx.toJson(); + this.type(SuiTransactionType.WalrusStakeWithPool); + this.sender(txData.sender); + this.gasData(txData.gasData); + + const requests = utils.getWalrusStakeWithPoolRequests(tx.suiTransaction.tx); + this.stake(requests); + + assert(txData.inputObjects); + this.inputObjects(txData.inputObjects); + } + + /** + * Validates all fields are defined + */ + private validateTransactionFields(): void { + assert(this._type, new BuildTransactionError('type is required before building')); + assert(this._sender, new BuildTransactionError('sender is required before building')); + this._stakeWithPoolTx.forEach((req) => { + 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); + this.validateInputObjects(this._inputObjects); + } + + /** + * Build SuiTransaction + * + * @return {BitGoSuiTransaction} + * @protected + */ + protected buildSuiTransaction(): SuiTransaction { + this.validateTransactionFields(); + + const programmableTxBuilder = new ProgrammingTransactionBlockBuilder(); + switch (this._type) { + case SuiTransactionType.WalrusStakeWithPool: + const inputObjects = this._inputObjects.map((token) => programmableTxBuilder.object(Inputs.ObjectRef(token))); + const mergedObject = inputObjects.shift() as TransactionArgument; + + if (inputObjects.length > 0) { + programmableTxBuilder.mergeCoins(mergedObject, inputObjects); + } + + // Create a new coin with staking balance, based on the coins used as gas payment. + const stakedWals = this._stakeWithPoolTx.map((req) => { + const splitObject = programmableTxBuilder.splitCoins(mergedObject, [ + 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: [ + programmableTxBuilder.object(Inputs.SharedObjectRef(this.walrusConfig.WALRUS_STAKING_OBJECT)), + splitObject, + programmableTxBuilder.object(req.validatorAddress), + ], + } as unknown as MoveCallTransaction); + }); + + programmableTxBuilder.transferObjects(stakedWals, programmableTxBuilder.object(this._sender)); + break; + default: + throw new InvalidTransactionError(`unsupported target method`); + } + + const txData = programmableTxBuilder.blockData; + return { + type: this._type, + sender: this._sender, + tx: { + inputs: [...txData.inputs], + transactions: [...txData.transactions], + }, + gasData: { + ...this._gasData, + payment: this._gasData.payment.slice(0, MAX_GAS_OBJECTS - 1), + }, + }; + } +} diff --git a/modules/sdk-coin-sui/src/lib/walrusStakingTransaction.ts b/modules/sdk-coin-sui/src/lib/walrusStakingTransaction.ts new file mode 100644 index 0000000000..67e76cd4a2 --- /dev/null +++ b/modules/sdk-coin-sui/src/lib/walrusStakingTransaction.ts @@ -0,0 +1,283 @@ +import { + BaseKey, + InvalidTransactionError, + ParseTransactionError, + PublicKey as BasePublicKey, + Signature, + TransactionRecipient, + TransactionType, +} from '@bitgo/sdk-core'; +import { WalrusStakingProgrammableTransaction, SuiTransaction, TransactionExplanation, TxData } from './iface'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import utils, { isImmOrOwnedObj } from './utils'; +import { Buffer } from 'buffer'; +import { Transaction } from './transaction'; +import { + builder, + Inputs, + MoveCallTransaction, + SplitCoinsTransaction, + TransactionArgument, + TransactionBlockInput, + TransactionType as SuiTransactionBlockType, +} from './mystenlab/builder'; +import { CallArg, normalizeSuiAddress, SuiObjectRef } from './mystenlab/types'; +import { BCS } from '@mysten/bcs'; +import { SUI_ADDRESS_LENGTH } from './constants'; + +export class WalrusStakingTransaction extends Transaction { + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + get suiTransaction(): SuiTransaction { + return this._suiTransaction; + } + + setSuiTransaction(tx: SuiTransaction): void { + this._suiTransaction = tx; + } + + addSignature(publicKey: BasePublicKey, signature: Buffer): void { + this._signatures.push(signature.toString('hex')); + this._signature = { publicKey, signature }; + this.serialize(); + } + + get suiSignature(): Signature { + return this._signature; + } + + /** @inheritdoc */ + canSign(key: BaseKey): boolean { + return true; + } + + /** @inheritdoc */ + toBroadcastFormat(): string { + if (!this._suiTransaction) { + throw new InvalidTransactionError('Empty transaction'); + } + return this.serialize(); + } + + /** @inheritdoc */ + toJson(): TxData { + if (!this._suiTransaction) { + throw new ParseTransactionError('Empty transaction'); + } + + const tx = this._suiTransaction; + return { + id: this._id, + sender: tx.sender, + kind: { ProgrammableTransaction: tx.tx }, + gasData: tx.gasData, + expiration: { None: null }, + inputObjects: this.getInputObjectsFromTx(tx.tx), + }; + } + + /** @inheritDoc */ + explainTransaction(): TransactionExplanation { + const result = this.toJson(); + const displayOrder = [ + 'id', + 'outputs', + 'outputAmount', + 'changeOutputs', + 'changeAmount', + 'fee', + 'type', + 'module', + 'function', + 'validatorAddress', + ]; + const outputs: TransactionRecipient[] = []; + + const explanationResult: TransactionExplanation = { + displayOrder, + id: this.id, + outputs, + outputAmount: '0', + changeOutputs: [], + changeAmount: '0', + fee: { fee: this.suiTransaction.gasData.budget.toString() }, + type: this.type, + }; + + switch (this.type) { + case TransactionType.StakingAdd: + return this.explainAddDelegationTransaction(result, explanationResult); + default: + throw new InvalidTransactionError('Transaction type not supported'); + } + } + + /** + * Set the transaction type. + * + * @param {TransactionType} transactionType The transaction type to be set. + */ + transactionType(transactionType: TransactionType): void { + this._type = transactionType; + } + + /** + * Load the input and output data on this transaction. + */ + loadInputsAndOutputs(): void { + if (!this.suiTransaction) { + return; + } + const requests = utils.getWalrusStakeWithPoolRequests(this.suiTransaction.tx); + this._outputs = requests.map((request) => { + return { + address: request.validatorAddress, + value: request.amount.toString(), + coin: this._coinConfig.name, + }; + }); + + this._inputs = [ + { + address: this.suiTransaction.sender, + value: this._outputs.reduce((acc, output) => acc + Number(output.value), 0).toString(), + coin: this._coinConfig.name, + }, + ]; + } + + /** + * Sets this transaction payload + * + * @param {string} rawTransaction + */ + fromRawTransaction(rawTransaction: string): void { + try { + utils.isValidRawTransaction(rawTransaction); + this._suiTransaction = Transaction.deserializeSuiTransaction( + rawTransaction + ) as SuiTransaction; + this._type = TransactionType.StakingAdd; + this._id = this._suiTransaction.id; + this.loadInputsAndOutputs(); + } catch (e) { + throw e; + } + } + + /** + * Helper function for serialize() to get the correct txData with transaction type + * + * @return {TxData} + */ + getTxData(): TxData { + if (!this._suiTransaction) { + throw new InvalidTransactionError('empty transaction'); + } + const inputs: CallArg[] | TransactionBlockInput[] = this._suiTransaction.tx.inputs.map((input, index) => { + if (input.hasOwnProperty('Object')) { + return input; + } + if (input.hasOwnProperty('Pure')) { + if (input.Pure.length === SUI_ADDRESS_LENGTH) { + const address = normalizeSuiAddress( + builder.de(BCS.ADDRESS, Buffer.from(input.Pure).toString('base64'), 'base64') + ); + return Inputs.Pure(address, BCS.ADDRESS); + } else { + const amount = builder.de(BCS.U64, Buffer.from(input.Pure).toString('base64'), 'base64'); + return Inputs.Pure(amount, BCS.U64); + } + } + if (input.kind === 'Input' && (input.value.hasOwnProperty('Object') || input.value.hasOwnProperty('Pure'))) { + return input.value; + } + + // what's left is the pure number or address string + return Inputs.Pure(input.value, input.type === 'pure' ? BCS.U64 : BCS.ADDRESS); + }); + + const programmableTx = { + inputs: inputs, + transactions: this._suiTransaction.tx.transactions, + } as WalrusStakingProgrammableTransaction; + + return { + sender: this._suiTransaction.sender, + expiration: { None: null }, + gasData: this._suiTransaction.gasData, + kind: { + ProgrammableTransaction: programmableTx, + }, + }; + } + + /** + * Returns a complete explanation for a staking transaction + * + * @param {TxData} json The transaction data in json format + * @param {TransactionExplanation} explanationResult The transaction explanation to be completed + * @returns {TransactionExplanation} + */ + explainAddDelegationTransaction(json: TxData, explanationResult: TransactionExplanation): TransactionExplanation { + const outputs: TransactionRecipient[] = []; + this.suiTransaction.tx.transactions.forEach((transaction, txIndex) => { + if (SplitCoinsTransaction.is(transaction)) { + const amountInputIdx = (transaction.amounts[0] as TransactionBlockInput).index; + const amount = BigInt((this.suiTransaction.tx.inputs[amountInputIdx] as TransactionBlockInput).value); + + // For WalrusStake, every split is followed by a move call + const validatorAddressInputIdx = ( + (this.suiTransaction.tx.transactions[txIndex + 1] as MoveCallTransaction) + .arguments[2] as TransactionBlockInput + ).index; + const validatorAddress = utils.getAddress( + this.suiTransaction.tx.inputs[validatorAddressInputIdx] as TransactionBlockInput + ); + + outputs.push({ + address: validatorAddress, + amount: amount.toString(10), + }); + } + }); + + const outputAmount = outputs.reduce((sum, output) => sum + BigInt(output.amount), BigInt(0)).toString(10); + + return { + ...explanationResult, + outputAmount, + outputs, + }; + } + + private getInputObjectsFromTx(tx: WalrusStakingProgrammableTransaction): SuiObjectRef[] { + const inputs = tx.inputs; + const transaction = tx.transactions[0] as SuiTransactionBlockType; + + let args: TransactionArgument[] = []; + if (transaction.kind === 'MergeCoins') { + const { destination, sources } = transaction; + args = [destination, ...sources]; + } else if (transaction.kind === 'SplitCoins') { + args = [transaction.coin]; + } + + const inputObjects: SuiObjectRef[] = []; + 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); + } + } + }); + + return inputObjects; + } +} diff --git a/modules/sdk-coin-sui/test/resources/sui.ts b/modules/sdk-coin-sui/test/resources/sui.ts index 33ce7896af..1b4b247264 100644 --- a/modules/sdk-coin-sui/test/resources/sui.ts +++ b/modules/sdk-coin-sui/test/resources/sui.ts @@ -1,4 +1,4 @@ -import { RequestAddStake, RequestWithdrawStakedSui } from '../../src/lib/iface'; +import { RequestAddStake, RequestWalrusStakeWithPool, RequestWithdrawStakedSui } from '../../src/lib/iface'; import { DUMMY_SUI_GAS_PRICE } from '../../src/lib/constants'; import { Recipient } from '@bitgo/sdk-core'; import { SuiObjectRef } from '../../src/lib/mystenlab/types'; @@ -423,6 +423,17 @@ export const requestAddStakeMany: RequestAddStake[] = [ }, ]; +export const requestWalrusStakeWithPool: RequestWalrusStakeWithPool = { + amount: STAKING_AMOUNT, + validatorAddress: '0x255e8a2aeed1f9b9a7d15350af924038ea1e06e9a795fb2f43ec955e2dd19a9c', +}; + +export const walToken: SuiObjectRef = { + objectId: '0x3600ef1407c0be07fbd5d4f6e6c5162abaa4a7184876c98c19a4ffb514a1f380', + version: 335820047, + digest: '5gTbdQPyCptRHhRuqDeHAomz5dUcBUebWv2yZfHb3PEm', +}; + export const requestWithdrawStakedSui: RequestWithdrawStakedSui = { stakedSui: { objectId: '0xee6dfc3da32e21541a2aeadfcd250f8a0a23bb7abda9c8988407fc32068c3746', diff --git a/modules/sdk-coin-sui/test/unit/transactionBuilder/walrusStakingBuilder.ts b/modules/sdk-coin-sui/test/unit/transactionBuilder/walrusStakingBuilder.ts new file mode 100644 index 0000000000..38eb35aa5f --- /dev/null +++ b/modules/sdk-coin-sui/test/unit/transactionBuilder/walrusStakingBuilder.ts @@ -0,0 +1,141 @@ +import assert from 'assert'; +import { getBuilderFactory } from '../getBuilderFactory'; +import * as testData from '../../resources/sui'; +import should from 'should'; +import { TransactionType } from '@bitgo/sdk-core'; +import utils from '../../../src/lib/utils'; +import { Transaction as SuiTransaction } from '../../../src/lib/transaction'; +import { SuiTransactionType } from '../../../src/lib/iface'; + +describe('Walrus Staking Builder', () => { + const factory = getBuilderFactory('tsui:wal'); + + describe('Succeed', () => { + it('should build a staking tx', 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); + tx.type.should.equal(TransactionType.StakingAdd); + tx.inputs.length.should.equal(1); + tx.inputs[0].should.deepEqual({ + address: testData.sender.address, + value: testData.STAKING_AMOUNT.toString(), + coin: 'tsui:wal', + }); + tx.outputs.length.should.equal(1); + tx.outputs[0].should.deepEqual({ + address: testData.requestWalrusStakeWithPool.validatorAddress, + value: testData.requestWalrusStakeWithPool.amount.toString(), + coin: 'tsui:wal', + }); + + 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); + + tx.suiTransaction.gasData.owner.should.equal(testData.gasData.owner); + tx.suiTransaction.gasData.price.should.equal(testData.gasData.price); + tx.suiTransaction.gasData.budget.should.equal(testData.gasData.budget); + tx.suiTransaction.gasData.payment.length.should.equal(testData.gasData.payment.length); + + 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 + }); + + it('should build a staking tx for multiple gas objects and WAL tokens', async function () { + const numberOfInputObjects = 100; + const numberOfGasPaymentObjects = 10; + + const txBuilder = factory.getWalrusStakingBuilder(); + txBuilder.type(SuiTransactionType.WalrusStakeWithPool); + txBuilder.sender(testData.sender.address); + txBuilder.stake([testData.requestWalrusStakeWithPool]); + + const gasData = { + ...testData.gasData, + payment: testData.generateObjects(numberOfGasPaymentObjects), + }; + txBuilder.gasData(gasData); + txBuilder.inputObjects(testData.generateObjects(numberOfInputObjects)); + const tx = await txBuilder.build(); + + assert(tx instanceof SuiTransaction); + tx.type.should.equal(TransactionType.StakingAdd); + tx.inputs.length.should.equal(1); + tx.inputs[0].should.deepEqual({ + address: testData.sender.address, + value: testData.STAKING_AMOUNT.toString(), + coin: 'tsui:wal', + }); + tx.outputs.length.should.equal(1); + tx.outputs[0].should.deepEqual({ + address: testData.requestWalrusStakeWithPool.validatorAddress, + value: testData.requestWalrusStakeWithPool.amount.toString(), + coin: 'tsui:wal', + }); + tx.suiTransaction.gasData.owner.should.equal(gasData.owner); + tx.suiTransaction.gasData.price.should.equal(gasData.price); + tx.suiTransaction.gasData.budget.should.equal(gasData.budget); + tx.suiTransaction.gasData.payment.length.should.equal(10); + + const ptb = tx.suiTransaction.tx; + ptb.inputs.length.should.equal(numberOfInputObjects + 4); + ptb.transactions[0].kind.should.equal('MergeCoins'); // Merge all input objects provided + ptb.transactions[0].sources.length.should.equal(numberOfInputObjects - 1); + ptb.transactions[1].kind.should.equal('SplitCoins'); // Split the desired amount off of the input object + + 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); + rebuiltTx.toJson().gasData.payment.length.should.equal(numberOfGasPaymentObjects); + rebuiltTx.toJson().inputObjects.length.should.equal(numberOfInputObjects); + }); + }); + + describe('Fail', () => { + it('should fail for invalid sender', async function () { + const builder = factory.getWalrusStakingBuilder(); + should(() => builder.sender('randomString')).throwError('Invalid or missing sender, got: randomString'); + }); + + it('should fail for invalid gasData', function () { + const builder = factory.getWalrusStakingBuilder(); + should(() => builder.gasData(testData.invalidGasOwner)).throwError( + `Invalid gas address ${testData.invalidGasOwner.owner}` + ); + }); + + it('should fail for invalid gasBudget', function () { + const builder = factory.getWalrusStakingBuilder(); + should(() => builder.gasData(testData.invalidGasBudget)).throwError('Invalid gas budget -1'); + }); + + it('should fail for invalid gasPayment', function () { + const builder = factory.getWalrusStakingBuilder(); + const invalidGasPayment = { + ...testData.gasDataWithoutGasPayment, + payment: [ + { + objectId: '', + version: -1, + digest: '', + }, + ], + }; + should(() => builder.gasData(invalidGasPayment)).throwError('Invalid payment, invalid or missing version'); + }); + }); +});