Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions modules/sdk-coin-near/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-coin-near/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
23 changes: 23 additions & 0 deletions modules/sdk-coin-near/src/lib/metaPoolWithdrawBuilder.ts
Original file line number Diff line number Diff line change
@@ -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<CoinConfig>) {
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<string, unknown>): void {
// withdraw_all has no amount arg; amount is resolved on-chain
}
}
12 changes: 10 additions & 2 deletions modules/sdk-coin-near/src/lib/stakingWithdrawBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CoinConfig>) {
super(_coinConfig);
Expand Down Expand Up @@ -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<string, unknown>): void {
assert(args?.amount, new BuildTransactionError('amount is required before building staking withdraw'));
}

/** @inheritdoc */
protected async buildImplementation(): Promise<Transaction> {
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();
Expand Down
20 changes: 20 additions & 0 deletions modules/sdk-coin-near/src/lib/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 12 additions & 1 deletion modules/sdk-coin-near/src/lib/transactionBuilderFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CoinConfig>) {
Expand All @@ -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:
Expand Down Expand Up @@ -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));
}
Expand Down
2 changes: 2 additions & 0 deletions modules/sdk-coin-near/test/resources/near.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,6 @@ export const rawTx = {
},
};

export const metaPoolContractAddress = 'meta-v2.pool.testnet';

export const AMOUNT = '1000000000000000000000000';
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
4 changes: 2 additions & 2 deletions modules/statics/src/coins/nep141Tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -73,6 +73,6 @@ export const nep141Tokens = [
'meta-v2.pool.testnet',
'1250000000000000000000',
UnderlyingAsset['tnear:stnear'],
NEAR_TOKEN_FEATURES
[...NEAR_TOKEN_FEATURES, CoinFeature.LIQUID_STAKING]
),
];