From 3f38c123a96a1a1579e6cb8b3c36e3fdb77f5102 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Sun, 15 Mar 2026 23:32:37 +0000 Subject: [PATCH 1/2] feat(sdk-coin-ton): add setFullWithdrawalMessage() to SingleNominatorWithdrawBuilder Implements the "w" comment withdrawal path for TON single nominator contracts (orbs-network/single-nominator). This allows draining the full balance minus gas and MIN_TON_FOR_STORAGE automatically, without needing to specify an amount. - Add SINGLE_NOMINATOR_WITHDRAW_ALL_COMMENT = 'w' constant - Add isFullUnstake?: boolean to TxData interface - Add isFullUnstake field to Transaction class, expose in toJson() - Add setFullWithdrawalMessage() to SingleNominatorWithdrawBuilder - Parser detects "w" comment transactions, sets SingleNominatorWithdraw type and isFullUnstake = true - Add unit tests for setFullWithdrawalMessage() flow Co-Authored-By: Claude Opus 4.6 --- modules/sdk-coin-ton/src/lib/constants.ts | 1 + modules/sdk-coin-ton/src/lib/iface.ts | 1 + .../src/lib/singleNominatorWithdrawBuilder.ts | 12 ++++ modules/sdk-coin-ton/src/lib/transaction.ts | 9 +++ .../unit/singleNominatorWithdrawBuilder.ts | 62 +++++++++++++++++++ 5 files changed, 85 insertions(+) diff --git a/modules/sdk-coin-ton/src/lib/constants.ts b/modules/sdk-coin-ton/src/lib/constants.ts index 1a87b5cc8b..7b10ae78b1 100644 --- a/modules/sdk-coin-ton/src/lib/constants.ts +++ b/modules/sdk-coin-ton/src/lib/constants.ts @@ -6,3 +6,4 @@ export const VESTING_CONTRACT_CODE_B64 = 'te6cckECHAEAA/sAART/APSkE/S88sgLAQIBIAISAgFIAwUDrNBsIiDXScFgkVvgAdDTAwFxsJFb4PpAMNs8AdMf0z/4S1JAxwUjghCnczrNurCOpGwS2zyCEPdzOs0BcIAYyMsFUATPFiP6AhPLassfyz/JgED7AOMOExQEAc74SlJAxwUDghByWKabuhOwjtGOLAH6QH/IygAC+kQByMoHy//J0PhEECOBAQj0QfhkINdKwgAglQHUMNAB3rMS5oIQ8limmzJwgBjIywVQBM8WI/oCE8tqyx/LP8mAQPsA2zySXwPiGwIBIAYPAgEgBwoCAW4ICQAZrc52omhAIGuQ64X/wAAZrx32omhAEGuQ64WPwAIBYgsMAUutNG2eNvwiRw1AgIR6STfSmRDOaQPp/5g3gSgBt4EBSJhxWfMYQBMCAWoNDgAPol+1E0NcLH4BL6LHbPPpEAcjKB8v/ydD4RIEBCPQKb6ExhMCASAQEQEpukYts8+EX4RvhH+Ej4SfhK+Ev4RIEwINuYRts82zyBMVA7jygwjXGCDTH9Mf0x8C+CO78mTtRNDTH9Mf0/8wWrryoVAzuvKiAvkBQDP5EPKj+ADbPCDXSsABjpntRO1F7UeRW+1n7WXtZI6C2zztQe3xAfL/kTDi+EGk+GHbPBMUGwB+7UTQ0x8B+GHTHwH4YtP/Afhj9AQB+GTUAdDTPwH4ZdMfAfhm0x8B+GfTHwH4aPoAAfhp+kAB+Gr6QAH4a9HRAlzTB9TR+CPbPCDCAI6bIsAD8uBkIdDTA/pAMfpA+EpSIMcFs5JfBOMNkTDiAfsAFRYAYPhF+EagUhC8kjBw4PhF+EigUhC5kzD4SeD4SfhJ+EUTofhHqQT4RvhHqQQQI6mEoQP6IfpEAcjKB8v/ydD4RIEBCPQKb6Exj18zAXKwwALy4GUB+gAxcdch+gAx+gAx0z8x0x8x0wABwADy4GbTAAGT1DDQ3iFx2zyOKjHTHzAgghBOc3RLuiGCEEdldCS6sSGCEFZ0Q3C6sQGCEFZvdGW6sfLgZ+MOcJJfA+IgwgAYFxoC6gFw2zyObSDXScIAjmPTHyHAACKDC7qxIoEQAbqxIoIQR9VDkbqxIoIQWV8HvLqxIoIQafswbLqxIoIQVm90ZbqxIoIQVnRDcLqx8uBnAcAAIddJwgCwjhXTBzAgwGQhwHexIcBEsQHAV7Hy4GiRMOKRMOLjDRgZAEQB+kQBw/+SW3DgAfgzIG6SW3Dg0CDXSYMHuZJbcODXC/+6ABrTHzCCEFZvdGW68uBnAA6TcvsCkTDiAGb4SPhH+Eb4RcjLP8sfyx/LH/hJ+gL4Ss8W+EvPFsn4RPhD+EL4QcjLH8sfy//0AMzJ7VSo1+S9'; export const TON_WHALES_DEPOSIT_OPCODE = '2077040623'; export const TON_WHALES_WITHDRAW_OPCODE = '3665837821'; +export const SINGLE_NOMINATOR_WITHDRAW_ALL_COMMENT = 'w'; diff --git a/modules/sdk-coin-ton/src/lib/iface.ts b/modules/sdk-coin-ton/src/lib/iface.ts index 7c24021ee5..b3b3aae460 100644 --- a/modules/sdk-coin-ton/src/lib/iface.ts +++ b/modules/sdk-coin-ton/src/lib/iface.ts @@ -17,6 +17,7 @@ export interface TxData { sub_wallet_id: number; signature: string; bounceable: boolean; + isFullUnstake?: boolean; } export type TransactionExplanation = ITransactionExplanation; diff --git a/modules/sdk-coin-ton/src/lib/singleNominatorWithdrawBuilder.ts b/modules/sdk-coin-ton/src/lib/singleNominatorWithdrawBuilder.ts index 6ecc38e68d..3333af431f 100644 --- a/modules/sdk-coin-ton/src/lib/singleNominatorWithdrawBuilder.ts +++ b/modules/sdk-coin-ton/src/lib/singleNominatorWithdrawBuilder.ts @@ -2,6 +2,7 @@ import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { Recipient, TransactionType } from '@bitgo/sdk-core'; import { TransactionBuilder } from './transactionBuilder'; import { Transaction } from './transaction'; +import { SINGLE_NOMINATOR_WITHDRAW_ALL_COMMENT } from './constants'; export class SingleNominatorWithdrawBuilder extends TransactionBuilder { constructor(_coinConfig: Readonly) { @@ -23,6 +24,17 @@ export class SingleNominatorWithdrawBuilder extends TransactionBuilder { return this; } + /** + * Sets the message to withdraw everything from the single nominator contract. + * Uses a plain transfer with text comment "w" which instructs the contract to + * drain balance - gas - MIN_TON_FOR_STORAGE automatically. + */ + setFullWithdrawalMessage(): SingleNominatorWithdrawBuilder { + this.transaction.isFullUnstake = true; + this.transaction.message = SINGLE_NOMINATOR_WITHDRAW_ALL_COMMENT; + return this; + } + setMessage(msg: string): SingleNominatorWithdrawBuilder { throw new Error('Method not implemented.'); } diff --git a/modules/sdk-coin-ton/src/lib/transaction.ts b/modules/sdk-coin-ton/src/lib/transaction.ts index 2c251a2adb..185b2835c2 100644 --- a/modules/sdk-coin-ton/src/lib/transaction.ts +++ b/modules/sdk-coin-ton/src/lib/transaction.ts @@ -12,6 +12,7 @@ import { VESTING_CONTRACT_WALLET_ID, TON_WHALES_DEPOSIT_OPCODE, TON_WHALES_WITHDRAW_OPCODE, + SINGLE_NOMINATOR_WITHDRAW_ALL_COMMENT, } from './constants'; export class Transaction extends BaseTransaction { @@ -21,6 +22,7 @@ export class Transaction extends BaseTransaction { public toAddressBounceable: boolean; public message: string; public withdrawAmount: string; + public isFullUnstake: boolean; seqno: number; expireTime: number; sender: string; @@ -65,6 +67,7 @@ export class Transaction extends BaseTransaction { publicKey: this.publicKey, signature: this._signatures[0], bounceable: this.bounceable, + isFullUnstake: this.isFullUnstake, }; } @@ -225,6 +228,7 @@ export class Transaction extends BaseTransaction { this._signatures.push(parsed.signature); this.bounceable = parsed.bounce; this.sub_wallet_id = parsed.walletId; + this.isFullUnstake = parsed.isFullUnstake; } catch (e) { throw new Error('invalid raw transaction'); } @@ -315,6 +319,7 @@ export class Transaction extends BaseTransaction { this.isV3ContractMessage = true; } + let isFullUnstake = false; let order = slice.loadRef(); if (order.loadBit()) throw Error('invalid internal header'); @@ -359,6 +364,9 @@ export class Transaction extends BaseTransaction { this.transactionType = TransactionType.TonWhalesVestingDeposit; } else if (payload === 'Withdraw') { this.transactionType = TransactionType.TonWhalesVestingWithdrawal; + } else if (payload === SINGLE_NOMINATOR_WITHDRAW_ALL_COMMENT) { + this.transactionType = TransactionType.SingleNominatorWithdraw; + isFullUnstake = true; } } else if (opcode === 4096) { const queryId = order.loadUint(64).toNumber(); @@ -434,6 +442,7 @@ export class Transaction extends BaseTransaction { payload, signature, walletId, + isFullUnstake, }; } diff --git a/modules/sdk-coin-ton/test/unit/singleNominatorWithdrawBuilder.ts b/modules/sdk-coin-ton/test/unit/singleNominatorWithdrawBuilder.ts index c387285985..0edc7ea0dd 100644 --- a/modules/sdk-coin-ton/test/unit/singleNominatorWithdrawBuilder.ts +++ b/modules/sdk-coin-ton/test/unit/singleNominatorWithdrawBuilder.ts @@ -132,6 +132,68 @@ describe('Ton Single Nominator Withdraw Builder', () => { should.equal(txBounceable.toJson().withdrawAmount, singleNominatorWithdrawAmount); }); + it('should build a full withdrawal tx using setFullWithdrawalMessage', async function () { + const txBuilder = factory.getSingleNominatorWithdrawBuilder(); + txBuilder.sender(testData.sender.address); + txBuilder.sequenceNumber(0); + txBuilder.publicKey(testData.sender.publicKey); + txBuilder.expireTime(1234567890); + txBuilder.send(testData.recipients[0]); + txBuilder.setFullWithdrawalMessage(); + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.SingleNominatorWithdraw); + should.equal(tx.toJson().bounceable, false); + should.equal(tx.toJson().isFullUnstake, true); + should.equal(tx.toJson().withdrawAmount, undefined); + tx.inputs.length.should.equal(1); + tx.inputs[0].should.deepEqual({ + address: testData.sender.address, + value: testData.recipients[0].amount, + coin: 'tton', + }); + tx.outputs.length.should.equal(1); + tx.outputs[0].should.deepEqual({ + address: testData.recipients[0].address, + value: testData.recipients[0].amount, + coin: 'tton', + }); + const rawTx = tx.toBroadcastFormat(); + // Verify the raw transaction can be parsed back as a full withdrawal + const txBuilder2 = factory.from(rawTx); + const tx2 = await txBuilder2.build(); + should.equal(tx2.type, TransactionType.SingleNominatorWithdraw); + should.equal(tx2.toJson().isFullUnstake, true); + should.equal(tx2.toBroadcastFormat(), rawTx); + }); + + it('should build a signed full withdrawal tx using add signature', async function () { + const keyPair = new KeyPair({ prv: testData.privateKeys.prvKey1 }); + const publicKey = keyPair.getKeys().pub; + const address = await utils.default.getAddressFromPublicKey(publicKey); + const txBuilder = factory.getSingleNominatorWithdrawBuilder(); + txBuilder.sender(address); + txBuilder.sequenceNumber(0); + txBuilder.publicKey(publicKey); + const expireAt = Math.floor(Date.now() / 1e3) + 60 * 60 * 24 * 7; + txBuilder.expireTime(expireAt); + txBuilder.send(testData.recipients[0]); + txBuilder.setFullWithdrawalMessage(); + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.SingleNominatorWithdraw); + should.equal(tx.toJson().isFullUnstake, true); + const signable = tx.signablePayload; + const signature = keyPair.signMessageinUint8Array(signable); + txBuilder.addSignature(keyPair.getKeys(), Buffer.from(signature)); + const signedTx = await txBuilder.build(); + const builder2 = factory.from(signedTx.toBroadcastFormat()); + const tx2 = await builder2.build(); + const signature2 = keyPair.signMessageinUint8Array(tx2.signablePayload); + should.equal(Buffer.from(signature).toString('hex'), Buffer.from(signature2).toString('hex')); + should.equal(tx.toBroadcastFormat(), tx2.toBroadcastFormat()); + should.equal(tx2.type, TransactionType.SingleNominatorWithdraw); + should.equal(tx2.toJson().isFullUnstake, true); + }); + xit('should build a signed withdraw tx and submit onchain', async function () { const tonweb = new TonWeb(new TonWeb.HttpProvider('https://testnet.toncenter.com/api/v2/jsonRPC')); const keyPair = new KeyPair({ prv: testData.privateKeys.prvKey1 }); From 6430d0dc575ba2548b87f3fd1b4b41fe34e88fc6 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Mon, 16 Mar 2026 12:51:51 +0000 Subject: [PATCH 2/2] refactor(sdk-coin-ton): remove isFullUnstake flag per updated ticket Full withdrawal is already unambiguous via transactionType === SingleNominatorWithdraw && !withdrawAmount, consistent with how TonWhales handles the equivalent case. No explicit isFullUnstake flag needed. Co-Authored-By: Claude Opus 4.6 --- modules/sdk-coin-ton/src/lib/iface.ts | 1 - .../src/lib/singleNominatorWithdrawBuilder.ts | 1 - modules/sdk-coin-ton/src/lib/transaction.ts | 6 ------ .../test/unit/singleNominatorWithdrawBuilder.ts | 8 ++++---- 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/modules/sdk-coin-ton/src/lib/iface.ts b/modules/sdk-coin-ton/src/lib/iface.ts index b3b3aae460..7c24021ee5 100644 --- a/modules/sdk-coin-ton/src/lib/iface.ts +++ b/modules/sdk-coin-ton/src/lib/iface.ts @@ -17,7 +17,6 @@ export interface TxData { sub_wallet_id: number; signature: string; bounceable: boolean; - isFullUnstake?: boolean; } export type TransactionExplanation = ITransactionExplanation; diff --git a/modules/sdk-coin-ton/src/lib/singleNominatorWithdrawBuilder.ts b/modules/sdk-coin-ton/src/lib/singleNominatorWithdrawBuilder.ts index 3333af431f..ee104de608 100644 --- a/modules/sdk-coin-ton/src/lib/singleNominatorWithdrawBuilder.ts +++ b/modules/sdk-coin-ton/src/lib/singleNominatorWithdrawBuilder.ts @@ -30,7 +30,6 @@ export class SingleNominatorWithdrawBuilder extends TransactionBuilder { * drain balance - gas - MIN_TON_FOR_STORAGE automatically. */ setFullWithdrawalMessage(): SingleNominatorWithdrawBuilder { - this.transaction.isFullUnstake = true; this.transaction.message = SINGLE_NOMINATOR_WITHDRAW_ALL_COMMENT; return this; } diff --git a/modules/sdk-coin-ton/src/lib/transaction.ts b/modules/sdk-coin-ton/src/lib/transaction.ts index 185b2835c2..38f51bcea7 100644 --- a/modules/sdk-coin-ton/src/lib/transaction.ts +++ b/modules/sdk-coin-ton/src/lib/transaction.ts @@ -22,7 +22,6 @@ export class Transaction extends BaseTransaction { public toAddressBounceable: boolean; public message: string; public withdrawAmount: string; - public isFullUnstake: boolean; seqno: number; expireTime: number; sender: string; @@ -67,7 +66,6 @@ export class Transaction extends BaseTransaction { publicKey: this.publicKey, signature: this._signatures[0], bounceable: this.bounceable, - isFullUnstake: this.isFullUnstake, }; } @@ -228,7 +226,6 @@ export class Transaction extends BaseTransaction { this._signatures.push(parsed.signature); this.bounceable = parsed.bounce; this.sub_wallet_id = parsed.walletId; - this.isFullUnstake = parsed.isFullUnstake; } catch (e) { throw new Error('invalid raw transaction'); } @@ -319,7 +316,6 @@ export class Transaction extends BaseTransaction { this.isV3ContractMessage = true; } - let isFullUnstake = false; let order = slice.loadRef(); if (order.loadBit()) throw Error('invalid internal header'); @@ -366,7 +362,6 @@ export class Transaction extends BaseTransaction { this.transactionType = TransactionType.TonWhalesVestingWithdrawal; } else if (payload === SINGLE_NOMINATOR_WITHDRAW_ALL_COMMENT) { this.transactionType = TransactionType.SingleNominatorWithdraw; - isFullUnstake = true; } } else if (opcode === 4096) { const queryId = order.loadUint(64).toNumber(); @@ -442,7 +437,6 @@ export class Transaction extends BaseTransaction { payload, signature, walletId, - isFullUnstake, }; } diff --git a/modules/sdk-coin-ton/test/unit/singleNominatorWithdrawBuilder.ts b/modules/sdk-coin-ton/test/unit/singleNominatorWithdrawBuilder.ts index 0edc7ea0dd..e0fa773420 100644 --- a/modules/sdk-coin-ton/test/unit/singleNominatorWithdrawBuilder.ts +++ b/modules/sdk-coin-ton/test/unit/singleNominatorWithdrawBuilder.ts @@ -143,7 +143,6 @@ describe('Ton Single Nominator Withdraw Builder', () => { const tx = await txBuilder.build(); should.equal(tx.type, TransactionType.SingleNominatorWithdraw); should.equal(tx.toJson().bounceable, false); - should.equal(tx.toJson().isFullUnstake, true); should.equal(tx.toJson().withdrawAmount, undefined); tx.inputs.length.should.equal(1); tx.inputs[0].should.deepEqual({ @@ -159,10 +158,11 @@ describe('Ton Single Nominator Withdraw Builder', () => { }); const rawTx = tx.toBroadcastFormat(); // Verify the raw transaction can be parsed back as a full withdrawal + // Full withdrawal is inferred by: transactionType === SingleNominatorWithdraw && !withdrawAmount const txBuilder2 = factory.from(rawTx); const tx2 = await txBuilder2.build(); should.equal(tx2.type, TransactionType.SingleNominatorWithdraw); - should.equal(tx2.toJson().isFullUnstake, true); + should.equal(tx2.toJson().withdrawAmount, undefined); should.equal(tx2.toBroadcastFormat(), rawTx); }); @@ -180,7 +180,7 @@ describe('Ton Single Nominator Withdraw Builder', () => { txBuilder.setFullWithdrawalMessage(); const tx = await txBuilder.build(); should.equal(tx.type, TransactionType.SingleNominatorWithdraw); - should.equal(tx.toJson().isFullUnstake, true); + should.equal(tx.toJson().withdrawAmount, undefined); const signable = tx.signablePayload; const signature = keyPair.signMessageinUint8Array(signable); txBuilder.addSignature(keyPair.getKeys(), Buffer.from(signature)); @@ -191,7 +191,7 @@ describe('Ton Single Nominator Withdraw Builder', () => { should.equal(Buffer.from(signature).toString('hex'), Buffer.from(signature2).toString('hex')); should.equal(tx.toBroadcastFormat(), tx2.toBroadcastFormat()); should.equal(tx2.type, TransactionType.SingleNominatorWithdraw); - should.equal(tx2.toJson().isFullUnstake, true); + should.equal(tx2.toJson().withdrawAmount, undefined); }); xit('should build a signed withdraw tx and submit onchain', async function () {