diff --git a/modules/sdk-coin-near/src/lib/constants.ts b/modules/sdk-coin-near/src/lib/constants.ts index 3a49f3c8df..fb9153237f 100644 --- a/modules/sdk-coin-near/src/lib/constants.ts +++ b/modules/sdk-coin-near/src/lib/constants.ts @@ -7,6 +7,7 @@ export const StakingContractMethodNames = { DepositAndStake: 'deposit_and_stake', Unstake: 'unstake', Withdraw: 'withdraw', + WithdrawAll: 'withdraw_all', } as const; export const FT_TRANSFER = 'ft_transfer'; diff --git a/modules/sdk-coin-near/src/lib/index.ts b/modules/sdk-coin-near/src/lib/index.ts index e20011a4fd..34664cf845 100644 --- a/modules/sdk-coin-near/src/lib/index.ts +++ b/modules/sdk-coin-near/src/lib/index.ts @@ -7,6 +7,7 @@ export { TransferBuilder } from './transferBuilder'; export { StakingActivateBuilder } from './stakingActivateBuilder'; export { StakingDeactivateBuilder } from './stakingDeactivateBuilder'; export { StakingWithdrawBuilder } from './stakingWithdrawBuilder'; +export { MetaPoolWithdrawBuilder } from './metaPoolWithdrawBuilder'; export { FungibleTokenTransferBuilder } from './fungibleTokenTransferBuilder'; export { StorageDepositTransferBuilder } from './storageDepositTransferBuilder'; diff --git a/modules/sdk-coin-near/src/lib/metaPoolWithdrawBuilder.ts b/modules/sdk-coin-near/src/lib/metaPoolWithdrawBuilder.ts new file mode 100644 index 0000000000..d563d87df9 --- /dev/null +++ b/modules/sdk-coin-near/src/lib/metaPoolWithdrawBuilder.ts @@ -0,0 +1,23 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { BuildTransactionError } from '@bitgo/sdk-core'; + +import { StakingWithdrawBuilder } from './stakingWithdrawBuilder'; +import { StakingContractMethodNames } from './constants'; + +export class MetaPoolWithdrawBuilder extends StakingWithdrawBuilder { + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this.contractCallWrapper.methodName = StakingContractMethodNames.WithdrawAll; + this.contractCallWrapper.args = {}; + } + + /** @inheritdoc */ + public amount(_amount: string): this { + throw new BuildTransactionError('amount is not applicable for withdraw_all'); + } + + /** @inheritdoc */ + protected validateArgs(_args: Record): void { + // withdraw_all has no amount arg; amount is resolved on-chain + } +} diff --git a/modules/sdk-coin-near/src/lib/stakingWithdrawBuilder.ts b/modules/sdk-coin-near/src/lib/stakingWithdrawBuilder.ts index 149c5b9f33..9176e98e07 100644 --- a/modules/sdk-coin-near/src/lib/stakingWithdrawBuilder.ts +++ b/modules/sdk-coin-near/src/lib/stakingWithdrawBuilder.ts @@ -10,7 +10,7 @@ import { TransactionBuilder } from './transactionBuilder'; import { StakingContractMethodNames } from './constants'; export class StakingWithdrawBuilder extends TransactionBuilder { - private contractCallWrapper: ContractCallWrapper; + protected contractCallWrapper: ContractCallWrapper; constructor(_coinConfig: Readonly) { super(_coinConfig); @@ -61,11 +61,19 @@ export class StakingWithdrawBuilder extends TransactionBuilder { return this; } + /** + * Validates the contract call arguments before building. + * Subclasses can override to change validation behavior. + */ + protected validateArgs(args: Record): void { + assert(args?.amount, new BuildTransactionError('amount is required before building staking withdraw')); + } + /** @inheritdoc */ protected async buildImplementation(): Promise { const { methodName, args, gas, deposit } = this.contractCallWrapper.getParams(); assert(gas, new BuildTransactionError('gas is required before building staking withdraw')); - assert(args?.amount, new BuildTransactionError('amount is required before building staking withdraw')); + this.validateArgs(args); super.actions([NearAPI.transactions.functionCall(methodName, args, BigInt(gas), BigInt(deposit))]); const tx = await super.buildImplementation(); diff --git a/modules/sdk-coin-near/src/lib/transaction.ts b/modules/sdk-coin-near/src/lib/transaction.ts index 856d3c5cc3..ff53f094af 100644 --- a/modules/sdk-coin-near/src/lib/transaction.ts +++ b/modules/sdk-coin-near/src/lib/transaction.ts @@ -237,6 +237,7 @@ export class Transaction extends BaseTransaction { this.setTransactionType(TransactionType.StakingDeactivate); break; case StakingContractMethodNames.Withdraw: + case StakingContractMethodNames.WithdrawAll: this.setTransactionType(TransactionType.StakingWithdraw); break; case FT_TRANSFER: @@ -370,6 +371,11 @@ export class Transaction extends BaseTransaction { break; case TransactionType.StakingWithdraw: if (action.functionCall) { + const methodName = action.functionCall.methodName; + if (methodName === StakingContractMethodNames.WithdrawAll) { + // withdraw_all has no amount arg; amount is determined on-chain + break; + } const stakingWithdrawAmount = JSON.parse(Buffer.from(action.functionCall.args).toString()).amount; inputs.push({ address: this._nearTransaction.receiverId, @@ -436,6 +442,20 @@ export class Transaction extends BaseTransaction { * @returns {TransactionExplanation} */ explainStakingWithdrawTransaction(json: TxData, explanationResult: TransactionExplanation): TransactionExplanation { + const methodName = json.actions[0].functionCall?.methodName; + if (methodName === StakingContractMethodNames.WithdrawAll) { + // withdraw_all has no amount arg; amount is resolved on-chain + return { + ...explanationResult, + outputAmount: '0', + outputs: [ + { + address: json.signerId, + amount: '0', + }, + ], + }; + } const amount = json.actions[0].functionCall?.args.amount as string; return { ...explanationResult, diff --git a/modules/sdk-coin-near/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-near/src/lib/transactionBuilderFactory.ts index 1a36642e41..b38a804184 100644 --- a/modules/sdk-coin-near/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-near/src/lib/transactionBuilderFactory.ts @@ -7,8 +7,10 @@ import { Transaction } from './transaction'; import { StakingActivateBuilder } from './stakingActivateBuilder'; import { StakingDeactivateBuilder } from './stakingDeactivateBuilder'; import { StakingWithdrawBuilder } from './stakingWithdrawBuilder'; +import { MetaPoolWithdrawBuilder } from './metaPoolWithdrawBuilder'; import { FungibleTokenTransferBuilder } from './fungibleTokenTransferBuilder'; import { StorageDepositTransferBuilder } from './storageDepositTransferBuilder'; +import { StakingContractMethodNames } from './constants'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(_coinConfig: Readonly) { @@ -32,8 +34,13 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.getStakingActivateBuilder(tx); case TransactionType.StakingDeactivate: return this.getStakingDeactivateBuilder(tx); - case TransactionType.StakingWithdraw: + case TransactionType.StakingWithdraw: { + const methodName = tx.nearTransaction.actions[0]?.functionCall?.methodName; + if (methodName === StakingContractMethodNames.WithdrawAll) { + return this.getMetaPoolWithdrawBuilder(tx); + } return this.getStakingWithdrawBuilder(tx); + } case TransactionType.StorageDeposit: return this.getStorageDepositTransferBuilder(tx); default: @@ -66,6 +73,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return TransactionBuilderFactory.initializeBuilder(tx, new StakingWithdrawBuilder(this._coinConfig)); } + getMetaPoolWithdrawBuilder(tx?: Transaction): MetaPoolWithdrawBuilder { + return TransactionBuilderFactory.initializeBuilder(tx, new MetaPoolWithdrawBuilder(this._coinConfig)); + } + getFungibleTokenTransferBuilder(tx?: Transaction): FungibleTokenTransferBuilder { return TransactionBuilderFactory.initializeBuilder(tx, new FungibleTokenTransferBuilder(this._coinConfig)); } diff --git a/modules/sdk-coin-near/test/resources/near.ts b/modules/sdk-coin-near/test/resources/near.ts index 161cea4699..ca76fb7f2c 100644 --- a/modules/sdk-coin-near/test/resources/near.ts +++ b/modules/sdk-coin-near/test/resources/near.ts @@ -124,4 +124,6 @@ export const rawTx = { }, }; +export const metaPoolContractAddress = 'meta-v2.pool.testnet'; + export const AMOUNT = '1000000000000000000000000'; diff --git a/modules/sdk-coin-near/test/unit/transactionBuilder/metaPoolWithdrawBuilder.ts b/modules/sdk-coin-near/test/unit/transactionBuilder/metaPoolWithdrawBuilder.ts new file mode 100644 index 0000000000..84d5dbb713 --- /dev/null +++ b/modules/sdk-coin-near/test/unit/transactionBuilder/metaPoolWithdrawBuilder.ts @@ -0,0 +1,189 @@ +import should from 'should'; +import * as testData from '../../resources/near'; +import { getBuilderFactory } from '../getBuilderFactory'; +import { TransactionType } from '@bitgo/sdk-core'; +import { metaPoolContractAddress } from '../../resources/near'; + +describe('Near Meta Pool Withdraw Builder', () => { + const factory = getBuilderFactory('tnear'); + const gas = '125000000000000'; + + describe('Succeed', () => { + it('build a meta pool withdraw_all signed tx', async () => { + const txBuilder = factory.getMetaPoolWithdrawBuilder(); + txBuilder + .gas(gas) + .sender(testData.accounts.account1.address, testData.accounts.account1.publicKey) + .receiverId(metaPoolContractAddress) + .recentBlockHash(testData.blockHash.block1) + .nonce(BigInt(1)); + txBuilder.sign({ key: testData.accounts.account1.secretKey }); + const tx = await txBuilder.build(); + tx.inputs.length.should.equal(0); + tx.outputs.length.should.equal(0); + should.equal(tx.type, TransactionType.StakingWithdraw); + const txJson = tx.toJson(); + txJson.should.have.properties(['id', 'signerId', 'publicKey', 'nonce', 'actions', 'signature']); + txJson.signerId.should.equal(testData.accounts.account1.address); + txJson.publicKey.should.equal(testData.accounts.account1.publicKeyBase58); + txJson.nonce.should.equal(BigInt(1)); + txJson.receiverId.should.equal(metaPoolContractAddress); + txJson.actions.should.deepEqual([ + { + functionCall: { + methodName: 'withdraw_all', + args: {}, + gas: '125000000000000', + deposit: '0', + }, + }, + ]); + }); + + it('build a meta pool withdraw_all unsigned tx', async () => { + const txBuilder = factory.getMetaPoolWithdrawBuilder(); + txBuilder + .gas(gas) + .sender(testData.accounts.account1.address, testData.accounts.account1.publicKey) + .receiverId(metaPoolContractAddress) + .recentBlockHash(testData.blockHash.block1) + .nonce(BigInt(1)); + const tx = await txBuilder.build(); + tx.inputs.length.should.equal(0); + tx.outputs.length.should.equal(0); + should.equal(tx.type, TransactionType.StakingWithdraw); + const explainTx = tx.explainTransaction(); + explainTx.outputAmount.should.equal('0'); + explainTx.outputs[0].amount.should.equal('0'); + explainTx.outputs[0].address.should.equal(testData.accounts.account1.address); + }); + + it('build from a raw unsigned meta pool withdraw_all tx (round-trip)', async () => { + // Build the original transaction + const txBuilder = factory.getMetaPoolWithdrawBuilder(); + txBuilder + .gas(gas) + .sender(testData.accounts.account1.address, testData.accounts.account1.publicKey) + .receiverId(metaPoolContractAddress) + .recentBlockHash(testData.blockHash.block1) + .nonce(BigInt(1)); + const originalTx = await txBuilder.build(); + const rawTx = originalTx.toBroadcastFormat(); + + // Reconstruct from raw + const rebuiltBuilder = factory.from(rawTx); + const rebuiltTx = await rebuiltBuilder.build(); + const rebuiltJson = rebuiltTx.toJson(); + + rebuiltJson.signerId.should.equal(testData.accounts.account1.address); + rebuiltJson.receiverId.should.equal(metaPoolContractAddress); + rebuiltJson.actions.should.deepEqual([ + { + functionCall: { + methodName: 'withdraw_all', + args: {}, + gas: '125000000000000', + deposit: '0', + }, + }, + ]); + should.equal(rebuiltTx.type, TransactionType.StakingWithdraw); + rebuiltTx.id.should.equal(originalTx.id); + }); + + it('build from a raw signed meta pool withdraw_all tx (round-trip)', async () => { + // Build the original signed transaction + const txBuilder = factory.getMetaPoolWithdrawBuilder(); + txBuilder + .gas(gas) + .sender(testData.accounts.account1.address, testData.accounts.account1.publicKey) + .receiverId(metaPoolContractAddress) + .recentBlockHash(testData.blockHash.block1) + .nonce(BigInt(1)); + txBuilder.sign({ key: testData.accounts.account1.secretKey }); + const originalTx = await txBuilder.build(); + const rawTx = originalTx.toBroadcastFormat(); + + // Reconstruct from raw + const rebuiltBuilder = factory.from(rawTx); + const rebuiltTx = await rebuiltBuilder.build(); + const rebuiltJson = rebuiltTx.toJson(); + + rebuiltJson.signerId.should.equal(testData.accounts.account1.address); + rebuiltJson.receiverId.should.equal(metaPoolContractAddress); + rebuiltJson.actions.should.deepEqual([ + { + functionCall: { + methodName: 'withdraw_all', + args: {}, + gas: '125000000000000', + deposit: '0', + }, + }, + ]); + should.equal(rebuiltTx.type, TransactionType.StakingWithdraw); + rebuiltTx.id.should.equal(originalTx.id); + rebuiltJson.should.have.property('signature'); + }); + }); + + describe('Fail', () => { + it('meta pool withdraw with missing gas', async () => { + const txBuilder = factory.getMetaPoolWithdrawBuilder(); + txBuilder + .sender(testData.accounts.account1.address, testData.accounts.account1.publicKey) + .receiverId(metaPoolContractAddress) + .recentBlockHash(testData.blockHash.block1) + .nonce(BigInt(1)); + await txBuilder.build().should.be.rejectedWith('gas is required before building staking withdraw'); + }); + + it('meta pool withdraw rejects amount()', () => { + const txBuilder = factory.getMetaPoolWithdrawBuilder(); + should(() => txBuilder.amount('1000000')).throw('amount is not applicable for withdraw_all'); + }); + }); + + describe('Routing', () => { + it('factory.from routes withdraw to StakingWithdrawBuilder', async () => { + // Build a native withdraw tx + const txBuilder = factory.getStakingWithdrawBuilder(); + txBuilder + .amount('1000000') + .gas(gas) + .sender(testData.accounts.account1.address, testData.accounts.account1.publicKey) + .receiverId(testData.validatorContractAddress) + .recentBlockHash(testData.blockHash.block1) + .nonce(BigInt(1)); + const tx = await txBuilder.build(); + const rawTx = tx.toBroadcastFormat(); + + // Verify from() routes to StakingWithdrawBuilder (not MetaPoolWithdrawBuilder) + const rebuiltBuilder = factory.from(rawTx); + const rebuiltTx = await rebuiltBuilder.build(); + const rebuiltJson = rebuiltTx.toJson(); + rebuiltJson.actions[0].functionCall!.methodName.should.equal('withdraw'); + should.equal(rebuiltTx.type, TransactionType.StakingWithdraw); + }); + + it('factory.from routes withdraw_all to MetaPoolWithdrawBuilder', async () => { + // Build a meta pool withdraw_all tx + const txBuilder = factory.getMetaPoolWithdrawBuilder(); + txBuilder + .gas(gas) + .sender(testData.accounts.account1.address, testData.accounts.account1.publicKey) + .receiverId(metaPoolContractAddress) + .recentBlockHash(testData.blockHash.block1) + .nonce(BigInt(1)); + const tx = await txBuilder.build(); + const rawTx = tx.toBroadcastFormat(); + + // Verify from() routes to MetaPoolWithdrawBuilder + const rebuiltBuilder = factory.from(rawTx); + const rebuiltTx = await rebuiltBuilder.build(); + const rebuiltJson = rebuiltTx.toJson(); + rebuiltJson.actions[0].functionCall!.methodName.should.equal('withdraw_all'); + should.equal(rebuiltTx.type, TransactionType.StakingWithdraw); + }); + }); +}); diff --git a/modules/statics/src/coins/nep141Tokens.ts b/modules/statics/src/coins/nep141Tokens.ts index 5623bda6e8..e0ee50cc17 100644 --- a/modules/statics/src/coins/nep141Tokens.ts +++ b/modules/statics/src/coins/nep141Tokens.ts @@ -41,7 +41,7 @@ export const nep141Tokens = [ 'meta-pool.near', '1250000000000000000000', UnderlyingAsset['near:stnear'], - NEAR_TOKEN_FEATURES + [...NEAR_TOKEN_FEATURES, CoinFeature.LIQUID_STAKING] ), // testnet tokens @@ -73,6 +73,6 @@ export const nep141Tokens = [ 'meta-v2.pool.testnet', '1250000000000000000000', UnderlyingAsset['tnear:stnear'], - NEAR_TOKEN_FEATURES + [...NEAR_TOKEN_FEATURES, CoinFeature.LIQUID_STAKING] ), ];