Skip to content

Commit 025dd39

Browse files
authored
Merge pull request #8199 from BitGo/SC-5580
feat(stnear): adding support for metapool withdraw_all txn builder
2 parents 6f55db3 + 7fc604f commit 025dd39

File tree

9 files changed

+260
-5
lines changed

9 files changed

+260
-5
lines changed

modules/sdk-coin-near/src/lib/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const StakingContractMethodNames = {
77
DepositAndStake: 'deposit_and_stake',
88
Unstake: 'unstake',
99
Withdraw: 'withdraw',
10+
WithdrawAll: 'withdraw_all',
1011
} as const;
1112

1213
export const FT_TRANSFER = 'ft_transfer';

modules/sdk-coin-near/src/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export { TransferBuilder } from './transferBuilder';
77
export { StakingActivateBuilder } from './stakingActivateBuilder';
88
export { StakingDeactivateBuilder } from './stakingDeactivateBuilder';
99
export { StakingWithdrawBuilder } from './stakingWithdrawBuilder';
10+
export { MetaPoolWithdrawBuilder } from './metaPoolWithdrawBuilder';
1011
export { FungibleTokenTransferBuilder } from './fungibleTokenTransferBuilder';
1112
export { StorageDepositTransferBuilder } from './storageDepositTransferBuilder';
1213

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { BaseCoin as CoinConfig } from '@bitgo/statics';
2+
import { BuildTransactionError } from '@bitgo/sdk-core';
3+
4+
import { StakingWithdrawBuilder } from './stakingWithdrawBuilder';
5+
import { StakingContractMethodNames } from './constants';
6+
7+
export class MetaPoolWithdrawBuilder extends StakingWithdrawBuilder {
8+
constructor(_coinConfig: Readonly<CoinConfig>) {
9+
super(_coinConfig);
10+
this.contractCallWrapper.methodName = StakingContractMethodNames.WithdrawAll;
11+
this.contractCallWrapper.args = {};
12+
}
13+
14+
/** @inheritdoc */
15+
public amount(_amount: string): this {
16+
throw new BuildTransactionError('amount is not applicable for withdraw_all');
17+
}
18+
19+
/** @inheritdoc */
20+
protected validateArgs(_args: Record<string, unknown>): void {
21+
// withdraw_all has no amount arg; amount is resolved on-chain
22+
}
23+
}

modules/sdk-coin-near/src/lib/stakingWithdrawBuilder.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { TransactionBuilder } from './transactionBuilder';
1010
import { StakingContractMethodNames } from './constants';
1111

1212
export class StakingWithdrawBuilder extends TransactionBuilder {
13-
private contractCallWrapper: ContractCallWrapper;
13+
protected contractCallWrapper: ContractCallWrapper;
1414

1515
constructor(_coinConfig: Readonly<CoinConfig>) {
1616
super(_coinConfig);
@@ -61,11 +61,19 @@ export class StakingWithdrawBuilder extends TransactionBuilder {
6161
return this;
6262
}
6363

64+
/**
65+
* Validates the contract call arguments before building.
66+
* Subclasses can override to change validation behavior.
67+
*/
68+
protected validateArgs(args: Record<string, unknown>): void {
69+
assert(args?.amount, new BuildTransactionError('amount is required before building staking withdraw'));
70+
}
71+
6472
/** @inheritdoc */
6573
protected async buildImplementation(): Promise<Transaction> {
6674
const { methodName, args, gas, deposit } = this.contractCallWrapper.getParams();
6775
assert(gas, new BuildTransactionError('gas is required before building staking withdraw'));
68-
assert(args?.amount, new BuildTransactionError('amount is required before building staking withdraw'));
76+
this.validateArgs(args);
6977

7078
super.actions([NearAPI.transactions.functionCall(methodName, args, BigInt(gas), BigInt(deposit))]);
7179
const tx = await super.buildImplementation();

modules/sdk-coin-near/src/lib/transaction.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ export class Transaction extends BaseTransaction {
237237
this.setTransactionType(TransactionType.StakingDeactivate);
238238
break;
239239
case StakingContractMethodNames.Withdraw:
240+
case StakingContractMethodNames.WithdrawAll:
240241
this.setTransactionType(TransactionType.StakingWithdraw);
241242
break;
242243
case FT_TRANSFER:
@@ -370,6 +371,11 @@ export class Transaction extends BaseTransaction {
370371
break;
371372
case TransactionType.StakingWithdraw:
372373
if (action.functionCall) {
374+
const methodName = action.functionCall.methodName;
375+
if (methodName === StakingContractMethodNames.WithdrawAll) {
376+
// withdraw_all has no amount arg; amount is determined on-chain
377+
break;
378+
}
373379
const stakingWithdrawAmount = JSON.parse(Buffer.from(action.functionCall.args).toString()).amount;
374380
inputs.push({
375381
address: this._nearTransaction.receiverId,
@@ -436,6 +442,20 @@ export class Transaction extends BaseTransaction {
436442
* @returns {TransactionExplanation}
437443
*/
438444
explainStakingWithdrawTransaction(json: TxData, explanationResult: TransactionExplanation): TransactionExplanation {
445+
const methodName = json.actions[0].functionCall?.methodName;
446+
if (methodName === StakingContractMethodNames.WithdrawAll) {
447+
// withdraw_all has no amount arg; amount is resolved on-chain
448+
return {
449+
...explanationResult,
450+
outputAmount: '0',
451+
outputs: [
452+
{
453+
address: json.signerId,
454+
amount: '0',
455+
},
456+
],
457+
};
458+
}
439459
const amount = json.actions[0].functionCall?.args.amount as string;
440460
return {
441461
...explanationResult,

modules/sdk-coin-near/src/lib/transactionBuilderFactory.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import { Transaction } from './transaction';
77
import { StakingActivateBuilder } from './stakingActivateBuilder';
88
import { StakingDeactivateBuilder } from './stakingDeactivateBuilder';
99
import { StakingWithdrawBuilder } from './stakingWithdrawBuilder';
10+
import { MetaPoolWithdrawBuilder } from './metaPoolWithdrawBuilder';
1011
import { FungibleTokenTransferBuilder } from './fungibleTokenTransferBuilder';
1112
import { StorageDepositTransferBuilder } from './storageDepositTransferBuilder';
13+
import { StakingContractMethodNames } from './constants';
1214

1315
export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
1416
constructor(_coinConfig: Readonly<CoinConfig>) {
@@ -32,8 +34,13 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
3234
return this.getStakingActivateBuilder(tx);
3335
case TransactionType.StakingDeactivate:
3436
return this.getStakingDeactivateBuilder(tx);
35-
case TransactionType.StakingWithdraw:
37+
case TransactionType.StakingWithdraw: {
38+
const methodName = tx.nearTransaction.actions[0]?.functionCall?.methodName;
39+
if (methodName === StakingContractMethodNames.WithdrawAll) {
40+
return this.getMetaPoolWithdrawBuilder(tx);
41+
}
3642
return this.getStakingWithdrawBuilder(tx);
43+
}
3744
case TransactionType.StorageDeposit:
3845
return this.getStorageDepositTransferBuilder(tx);
3946
default:
@@ -66,6 +73,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
6673
return TransactionBuilderFactory.initializeBuilder(tx, new StakingWithdrawBuilder(this._coinConfig));
6774
}
6875

76+
getMetaPoolWithdrawBuilder(tx?: Transaction): MetaPoolWithdrawBuilder {
77+
return TransactionBuilderFactory.initializeBuilder(tx, new MetaPoolWithdrawBuilder(this._coinConfig));
78+
}
79+
6980
getFungibleTokenTransferBuilder(tx?: Transaction): FungibleTokenTransferBuilder {
7081
return TransactionBuilderFactory.initializeBuilder(tx, new FungibleTokenTransferBuilder(this._coinConfig));
7182
}

modules/sdk-coin-near/test/resources/near.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,6 @@ export const rawTx = {
124124
},
125125
};
126126

127+
export const metaPoolContractAddress = 'meta-v2.pool.testnet';
128+
127129
export const AMOUNT = '1000000000000000000000000';
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import should from 'should';
2+
import * as testData from '../../resources/near';
3+
import { getBuilderFactory } from '../getBuilderFactory';
4+
import { TransactionType } from '@bitgo/sdk-core';
5+
import { metaPoolContractAddress } from '../../resources/near';
6+
7+
describe('Near Meta Pool Withdraw Builder', () => {
8+
const factory = getBuilderFactory('tnear');
9+
const gas = '125000000000000';
10+
11+
describe('Succeed', () => {
12+
it('build a meta pool withdraw_all signed tx', async () => {
13+
const txBuilder = factory.getMetaPoolWithdrawBuilder();
14+
txBuilder
15+
.gas(gas)
16+
.sender(testData.accounts.account1.address, testData.accounts.account1.publicKey)
17+
.receiverId(metaPoolContractAddress)
18+
.recentBlockHash(testData.blockHash.block1)
19+
.nonce(BigInt(1));
20+
txBuilder.sign({ key: testData.accounts.account1.secretKey });
21+
const tx = await txBuilder.build();
22+
tx.inputs.length.should.equal(0);
23+
tx.outputs.length.should.equal(0);
24+
should.equal(tx.type, TransactionType.StakingWithdraw);
25+
const txJson = tx.toJson();
26+
txJson.should.have.properties(['id', 'signerId', 'publicKey', 'nonce', 'actions', 'signature']);
27+
txJson.signerId.should.equal(testData.accounts.account1.address);
28+
txJson.publicKey.should.equal(testData.accounts.account1.publicKeyBase58);
29+
txJson.nonce.should.equal(BigInt(1));
30+
txJson.receiverId.should.equal(metaPoolContractAddress);
31+
txJson.actions.should.deepEqual([
32+
{
33+
functionCall: {
34+
methodName: 'withdraw_all',
35+
args: {},
36+
gas: '125000000000000',
37+
deposit: '0',
38+
},
39+
},
40+
]);
41+
});
42+
43+
it('build a meta pool withdraw_all unsigned tx', async () => {
44+
const txBuilder = factory.getMetaPoolWithdrawBuilder();
45+
txBuilder
46+
.gas(gas)
47+
.sender(testData.accounts.account1.address, testData.accounts.account1.publicKey)
48+
.receiverId(metaPoolContractAddress)
49+
.recentBlockHash(testData.blockHash.block1)
50+
.nonce(BigInt(1));
51+
const tx = await txBuilder.build();
52+
tx.inputs.length.should.equal(0);
53+
tx.outputs.length.should.equal(0);
54+
should.equal(tx.type, TransactionType.StakingWithdraw);
55+
const explainTx = tx.explainTransaction();
56+
explainTx.outputAmount.should.equal('0');
57+
explainTx.outputs[0].amount.should.equal('0');
58+
explainTx.outputs[0].address.should.equal(testData.accounts.account1.address);
59+
});
60+
61+
it('build from a raw unsigned meta pool withdraw_all tx (round-trip)', async () => {
62+
// Build the original transaction
63+
const txBuilder = factory.getMetaPoolWithdrawBuilder();
64+
txBuilder
65+
.gas(gas)
66+
.sender(testData.accounts.account1.address, testData.accounts.account1.publicKey)
67+
.receiverId(metaPoolContractAddress)
68+
.recentBlockHash(testData.blockHash.block1)
69+
.nonce(BigInt(1));
70+
const originalTx = await txBuilder.build();
71+
const rawTx = originalTx.toBroadcastFormat();
72+
73+
// Reconstruct from raw
74+
const rebuiltBuilder = factory.from(rawTx);
75+
const rebuiltTx = await rebuiltBuilder.build();
76+
const rebuiltJson = rebuiltTx.toJson();
77+
78+
rebuiltJson.signerId.should.equal(testData.accounts.account1.address);
79+
rebuiltJson.receiverId.should.equal(metaPoolContractAddress);
80+
rebuiltJson.actions.should.deepEqual([
81+
{
82+
functionCall: {
83+
methodName: 'withdraw_all',
84+
args: {},
85+
gas: '125000000000000',
86+
deposit: '0',
87+
},
88+
},
89+
]);
90+
should.equal(rebuiltTx.type, TransactionType.StakingWithdraw);
91+
rebuiltTx.id.should.equal(originalTx.id);
92+
});
93+
94+
it('build from a raw signed meta pool withdraw_all tx (round-trip)', async () => {
95+
// Build the original signed transaction
96+
const txBuilder = factory.getMetaPoolWithdrawBuilder();
97+
txBuilder
98+
.gas(gas)
99+
.sender(testData.accounts.account1.address, testData.accounts.account1.publicKey)
100+
.receiverId(metaPoolContractAddress)
101+
.recentBlockHash(testData.blockHash.block1)
102+
.nonce(BigInt(1));
103+
txBuilder.sign({ key: testData.accounts.account1.secretKey });
104+
const originalTx = await txBuilder.build();
105+
const rawTx = originalTx.toBroadcastFormat();
106+
107+
// Reconstruct from raw
108+
const rebuiltBuilder = factory.from(rawTx);
109+
const rebuiltTx = await rebuiltBuilder.build();
110+
const rebuiltJson = rebuiltTx.toJson();
111+
112+
rebuiltJson.signerId.should.equal(testData.accounts.account1.address);
113+
rebuiltJson.receiverId.should.equal(metaPoolContractAddress);
114+
rebuiltJson.actions.should.deepEqual([
115+
{
116+
functionCall: {
117+
methodName: 'withdraw_all',
118+
args: {},
119+
gas: '125000000000000',
120+
deposit: '0',
121+
},
122+
},
123+
]);
124+
should.equal(rebuiltTx.type, TransactionType.StakingWithdraw);
125+
rebuiltTx.id.should.equal(originalTx.id);
126+
rebuiltJson.should.have.property('signature');
127+
});
128+
});
129+
130+
describe('Fail', () => {
131+
it('meta pool withdraw with missing gas', async () => {
132+
const txBuilder = factory.getMetaPoolWithdrawBuilder();
133+
txBuilder
134+
.sender(testData.accounts.account1.address, testData.accounts.account1.publicKey)
135+
.receiverId(metaPoolContractAddress)
136+
.recentBlockHash(testData.blockHash.block1)
137+
.nonce(BigInt(1));
138+
await txBuilder.build().should.be.rejectedWith('gas is required before building staking withdraw');
139+
});
140+
141+
it('meta pool withdraw rejects amount()', () => {
142+
const txBuilder = factory.getMetaPoolWithdrawBuilder();
143+
should(() => txBuilder.amount('1000000')).throw('amount is not applicable for withdraw_all');
144+
});
145+
});
146+
147+
describe('Routing', () => {
148+
it('factory.from routes withdraw to StakingWithdrawBuilder', async () => {
149+
// Build a native withdraw tx
150+
const txBuilder = factory.getStakingWithdrawBuilder();
151+
txBuilder
152+
.amount('1000000')
153+
.gas(gas)
154+
.sender(testData.accounts.account1.address, testData.accounts.account1.publicKey)
155+
.receiverId(testData.validatorContractAddress)
156+
.recentBlockHash(testData.blockHash.block1)
157+
.nonce(BigInt(1));
158+
const tx = await txBuilder.build();
159+
const rawTx = tx.toBroadcastFormat();
160+
161+
// Verify from() routes to StakingWithdrawBuilder (not MetaPoolWithdrawBuilder)
162+
const rebuiltBuilder = factory.from(rawTx);
163+
const rebuiltTx = await rebuiltBuilder.build();
164+
const rebuiltJson = rebuiltTx.toJson();
165+
rebuiltJson.actions[0].functionCall!.methodName.should.equal('withdraw');
166+
should.equal(rebuiltTx.type, TransactionType.StakingWithdraw);
167+
});
168+
169+
it('factory.from routes withdraw_all to MetaPoolWithdrawBuilder', async () => {
170+
// Build a meta pool withdraw_all tx
171+
const txBuilder = factory.getMetaPoolWithdrawBuilder();
172+
txBuilder
173+
.gas(gas)
174+
.sender(testData.accounts.account1.address, testData.accounts.account1.publicKey)
175+
.receiverId(metaPoolContractAddress)
176+
.recentBlockHash(testData.blockHash.block1)
177+
.nonce(BigInt(1));
178+
const tx = await txBuilder.build();
179+
const rawTx = tx.toBroadcastFormat();
180+
181+
// Verify from() routes to MetaPoolWithdrawBuilder
182+
const rebuiltBuilder = factory.from(rawTx);
183+
const rebuiltTx = await rebuiltBuilder.build();
184+
const rebuiltJson = rebuiltTx.toJson();
185+
rebuiltJson.actions[0].functionCall!.methodName.should.equal('withdraw_all');
186+
should.equal(rebuiltTx.type, TransactionType.StakingWithdraw);
187+
});
188+
});
189+
});

modules/statics/src/coins/nep141Tokens.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export const nep141Tokens = [
4141
'meta-pool.near',
4242
'1250000000000000000000',
4343
UnderlyingAsset['near:stnear'],
44-
NEAR_TOKEN_FEATURES
44+
[...NEAR_TOKEN_FEATURES, CoinFeature.LIQUID_STAKING]
4545
),
4646

4747
// testnet tokens
@@ -73,6 +73,6 @@ export const nep141Tokens = [
7373
'meta-v2.pool.testnet',
7474
'1250000000000000000000',
7575
UnderlyingAsset['tnear:stnear'],
76-
NEAR_TOKEN_FEATURES
76+
[...NEAR_TOKEN_FEATURES, CoinFeature.LIQUID_STAKING]
7777
),
7878
];

0 commit comments

Comments
 (0)