diff --git a/modules/sdk-coin-ton/src/lib/constants.ts b/modules/sdk-coin-ton/src/lib/constants.ts index 753d600e43..1a87b5cc8b 100644 --- a/modules/sdk-coin-ton/src/lib/constants.ts +++ b/modules/sdk-coin-ton/src/lib/constants.ts @@ -5,3 +5,4 @@ export const WITHDRAW_OPCODE = '00001000'; 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'; diff --git a/modules/sdk-coin-ton/src/lib/tonWhalesWithdrawalBuilder.ts b/modules/sdk-coin-ton/src/lib/tonWhalesWithdrawalBuilder.ts new file mode 100644 index 0000000000..f17b621fc1 --- /dev/null +++ b/modules/sdk-coin-ton/src/lib/tonWhalesWithdrawalBuilder.ts @@ -0,0 +1,62 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { Recipient, TransactionType } from '@bitgo/sdk-core'; +import { TransactionBuilder } from './transactionBuilder'; +import { Transaction } from './transaction'; +import { TON_WHALES_WITHDRAW_OPCODE } from './constants'; + +export class TonWhalesWithdrawalBuilder extends TransactionBuilder { + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._transaction = new Transaction(_coinConfig); + } + + protected get transactionType(): TransactionType { + return TransactionType.TonWhalesWithdrawal; + } + + /** + * Sets the payload for the withdrawal request. + * Structure: OpCode (32) + QueryId (64) + GasLimit (Coins) + UnstakeAmount (Coins) + * * @param unstakeAmount The amount of NanoTON to unstake (inside payload) + * @param unstakeAmount The amount to unstake + * @param queryId Optional custom query ID + */ + setWithdrawalMessage(unstakeAmount: string, queryId?: string): TonWhalesWithdrawalBuilder { + const qId = queryId || '0000000000000000'; + + this.transaction.message = TON_WHALES_WITHDRAW_OPCODE + qId + unstakeAmount; + return this; + } + + /** + * Sets the message to withdraw EVERYTHING from the pool. + * This sets the unstakeAmount to "0", which is the specific signal for full withdrawal. + */ + setFullWithdrawalMessage(queryId?: string): TonWhalesWithdrawalBuilder { + return this.setWithdrawalMessage('0', queryId); + } + + /** + * Sets the value attached to the transaction (The Fees). + * NOTE: This is NOT the unstake amount. This is the fee paid to the pool + * to process the request (e.g. withdrawFee + receiptPrice). + * * @param amount NanoTON amount to attach to the message + */ + setForwardAmount(amount: string): TonWhalesWithdrawalBuilder { + if (!this.transaction.recipient) { + this.transaction.recipient = { address: '', amount: amount }; + } else { + this.transaction.recipient.amount = amount; + } + return this; + } + + send(recipient: Recipient): TonWhalesWithdrawalBuilder { + this.transaction.recipient = recipient; + return this; + } + + setMessage(msg: string): TonWhalesWithdrawalBuilder { + throw new Error('Use setWithdrawalMessage for specific payload construction'); + } +} diff --git a/modules/sdk-coin-ton/src/lib/transaction.ts b/modules/sdk-coin-ton/src/lib/transaction.ts index 6e9f9609f7..19a6ca0f97 100644 --- a/modules/sdk-coin-ton/src/lib/transaction.ts +++ b/modules/sdk-coin-ton/src/lib/transaction.ts @@ -11,6 +11,7 @@ import { JETTON_TRANSFER_OPCODE, VESTING_CONTRACT_WALLET_ID, TON_WHALES_DEPOSIT_OPCODE, + TON_WHALES_WITHDRAW_OPCODE, } from './constants'; export class Transaction extends BaseTransaction { @@ -137,7 +138,21 @@ export class Transaction extends BaseTransaction { const queryId = payload.substring(10, 26); payloadCell.bits.writeUint(parseInt(TON_WHALES_DEPOSIT_OPCODE, 10), 32); payloadCell.bits.writeUint(parseInt(queryId, 16), 64); + // The Ton Whales protocol requires a specific 'gas limit' field in the payload + // structure (OpCode -> QueryId -> GasLimit -> Amount). payloadCell.bits.writeCoins(TonWeb.utils.toNano('1')); + } else if (payload.length >= 26 && payload.substring(0, 10) === TON_WHALES_WITHDRAW_OPCODE) { + const queryId = payload.substring(10, 26); + const amountStr = payload.substring(26); + + payloadCell.bits.writeUint(parseInt(TON_WHALES_WITHDRAW_OPCODE, 10), 32); + payloadCell.bits.writeUint(new BN(queryId, 16), 64); + // The Ton Whales protocol requires a specific 'gas limit' field in the payload + // structure (OpCode -> QueryId -> GasLimit -> Amount). + // We hardcode 1 TON here to match the Deposit implementation and ensure + // sufficient gas for the pool to process the request. + payloadCell.bits.writeCoins(TonWeb.utils.toNano('1')); + payloadCell.bits.writeCoins(new BN(amountStr)); } else { payloadCell.bits.writeUint(0, 32); payloadCell.bits.writeString(payload); @@ -387,6 +402,18 @@ export class Transaction extends BaseTransaction { // We do not need to store it order.loadCoins(); payload = TON_WHALES_DEPOSIT_OPCODE + queryId.toString(16).padStart(16, '0'); + } else if (opcode === parseInt(TON_WHALES_WITHDRAW_OPCODE, 10)) { + this.transactionType = TransactionType.TonWhalesWithdrawal; + + const queryId = order.loadUint(64).toNumber(); + order.loadCoins(); // Skip Gas (Hardcoded in builder) + const amount = order.loadCoins(); + + withdrawAmount = amount.toString(); // Decimal String + + // Reconstruct Payload: Decimal Op + Hex Query + Decimal Amount + const queryHex = new BN(queryId).toString(16).padStart(16, '0'); + payload = TON_WHALES_WITHDRAW_OPCODE + queryHex + withdrawAmount; } else { payload = ''; } diff --git a/modules/sdk-coin-ton/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-ton/src/lib/transactionBuilderFactory.ts index c6b354db2c..9867e6f3ff 100644 --- a/modules/sdk-coin-ton/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-ton/src/lib/transactionBuilderFactory.ts @@ -7,6 +7,7 @@ import { Transaction } from './transaction'; import { TokenTransferBuilder } from './tokenTransferBuilder'; import { TokenTransaction } from './tokenTransaction'; import { TonWhalesDepositBuilder } from './tonWhalesDepositBuilder'; +import { TonWhalesWithdrawalBuilder } from './tonWhalesWithdrawalBuilder'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(_coinConfig: Readonly) { @@ -41,6 +42,9 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { case TransactionType.TonWhalesDeposit: builder = this.getTonWhalesDepositBuilder(); break; + case TransactionType.TonWhalesWithdrawal: + builder = this.getTonWhalesWithdrawalBuilder(); + break; default: throw new InvalidTransactionError('unsupported transaction'); } @@ -78,4 +82,8 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { getTonWhalesDepositBuilder(): TonWhalesDepositBuilder { return new TonWhalesDepositBuilder(this._coinConfig); } + + getTonWhalesWithdrawalBuilder(): TonWhalesWithdrawalBuilder { + return new TonWhalesWithdrawalBuilder(this._coinConfig); + } } diff --git a/modules/sdk-coin-ton/test/resources/ton.ts b/modules/sdk-coin-ton/test/resources/ton.ts index bc82ecef89..0ac1f4a133 100644 --- a/modules/sdk-coin-ton/test/resources/ton.ts +++ b/modules/sdk-coin-ton/test/resources/ton.ts @@ -164,3 +164,39 @@ export const signedTonWhalesDepositTransaction = { 'aff471790c6587d07ae69e5a9519428ca6456eddb4cd1a6a8573b55f2cd6809309b57af7f50ebb160bee2a729b0d9d6336ea202312fea35325d33b02f1e9ff01', bounceable: true, }; + +export const signedTonWhalesWithdrawalTransaction = { + recipient: { + //https://testnet.tonscan.org/address/kQDr9Sq482A6ikIUh5mUUjJaBUUJBrye13CJiDB-R31_l7mg + address: 'EQDr9Sq482A6ikIUh5mUUjJaBUUJBrye13CJiDB-R31_lwIq', + amount: '200000000', // 10 TON + }, + withdrawAmount: '10000000000', + // This is the raw TX from sandboxing a withdrawal request to Ton Whales + tx: 'te6cckEBAgEAwAAB4YgAyB87FgBG4jQNUAYzcmw2aJG9QQeQmtOPGsRPvX+eMdwGzbdqzqRjzzou/GIUqqqdZn7Tevr+oSawF529ibEgSoxfcezGF5GW4oF6/Ws+4OanMgBwMVCe0GIEK3GSTzCIaU1NGLtKVSvAAAAC6AAcAQCUYgB1+pVcebAdRSEKQ8zKKRktAqKEg15Pa7hExBg/I76/y6BfXhAAAAAAAAAAAAAAAAAAANqAPv0AAAAAaUqlPEO5rKAFAlQL5ACKp3CI', + seqno: 93, + queryId: '00000000694aa53c', + expireTime: 1766499704, + sender: 'EQBkD52LACNxGgaoAxm5Nhs0SN6gg8hNaceNYifev88Y7qoZ', + publicKey: '9d6d3714aeb1f007f6e6aa728f79fdd005ea2c7ad459b2f54d73f9e672426230', + signature: + 'd9b6ed59d48c79e745df8c42955553accfda6f5f5fd424d602f3b7b1362409518bee3d98c2f232dc502f5fad67dc1cd4e6400e062a13da0c40856e3249e6110d', + bounceable: true, +}; + +export const signedTonWhalesFullWithdrawalTransaction = { + recipient: { + address: 'EQDr9Sq482A6ikIUh5mUUjJaBUUJBrye13CJiDB-R31_lwIq', + amount: '200000000', // 0.2 TON (Fee) + }, + withdrawAmount: '0', // 0 means Full Withdraw + tx: 'te6cckEBAgEAuwAB4YgAyB87FgBG4jQNUAYzcmw2aJG9QQeQmtOPGsRPvX+eMdwHSrLxEIwA9nyfxKqom8MsGbPCL5SfwqGDzHyYnKzJwU8ecNqb6xkB7u9gBwBrZdO3NvecF44nXe2Lm/+OL8Z4aU1NGLtKVgg4AAAC8AAcAQCKYgB1+pVcebAdRSEKQ8zKKRktAqKEg15Pa7hExBg/I76/y6BfXhAAAAAAAAAAAAAAAAAAANqAPv0AAAAAaUrAy0O5rKAAudrTIw==', + seqno: 94, + queryId: '00000000694ac0cb', + expireTime: 1766506759, + sender: 'EQBkD52LACNxGgaoAxm5Nhs0SN6gg8hNaceNYifev88Y7qoZ', + publicKey: '9d6d3714aeb1f007f6e6aa728f79fdd005ea2c7ad459b2f54d73f9e672426230', + signature: + 'e9565e2211801ecf93f8955513786583367845f293f85430798f931395993829e3ce1b537d63203dddec00e00d6cba76e6def382f1c4ebbdb1737ff1c5f8cf0d', + bounceable: true, +}; diff --git a/modules/sdk-coin-ton/test/unit/tonWhalesWithdrawalBuilder.ts b/modules/sdk-coin-ton/test/unit/tonWhalesWithdrawalBuilder.ts new file mode 100644 index 0000000000..be02cdab90 --- /dev/null +++ b/modules/sdk-coin-ton/test/unit/tonWhalesWithdrawalBuilder.ts @@ -0,0 +1,109 @@ +import should from 'should'; +import { TransactionType } from '@bitgo/sdk-core'; +import { TransactionBuilderFactory } from '../../src'; // Adjust path as needed +import { coins } from '@bitgo/statics'; +import * as testData from '../resources/ton'; +import { TON_WHALES_WITHDRAW_OPCODE } from '../../src/lib/constants'; + +describe('Ton Whales Withdrawal Builder', () => { + const factory = new TransactionBuilderFactory(coins.get('tton')); + + // Define the scenarios we want to test + const scenarios = [ + { + name: 'Partial Withdrawal (10 TON)', + fixture: testData.signedTonWhalesWithdrawalTransaction, + }, + { + name: 'Full Withdrawal (Amount 0)', + fixture: testData.signedTonWhalesFullWithdrawalTransaction, + }, + ]; + + scenarios.forEach((scenario) => { + describe(scenario.name, () => { + const fixture = scenario.fixture; + + it('should parse a raw transaction and extract correct parameters', async function () { + const txBuilder = factory.from(fixture.tx); + const builtTx = await txBuilder.build(); + const jsonTx = builtTx.toJson(); + + // Verify Business Logic Fields + should.equal(builtTx.type, TransactionType.TonWhalesWithdrawal); + + // NOTE: In withdrawals, recipient.amount is the FEE, withdrawAmount is the STAKE + should.equal(jsonTx.amount, fixture.recipient.amount); + should.equal(jsonTx.withdrawAmount, fixture.withdrawAmount); + + should.equal(jsonTx.destination, fixture.recipient.address); + should.equal(jsonTx.sender, fixture.sender); + + // Verify Network Constraints + should.equal(jsonTx.seqno, fixture.seqno); + should.equal(jsonTx.expirationTime, fixture.expireTime); + should.equal(jsonTx.bounceable, fixture.bounceable); + + // Verify Payload Structure + // Logic: DecimalOpCode + HexQueryId + DecimalAmount + const msg = builtTx['message'] || ''; + should.equal(msg.startsWith(TON_WHALES_WITHDRAW_OPCODE), true); + + // Ensure the payload ENDS with the decimal amount (either "1000..." or "0") + should.equal(msg.endsWith(fixture.withdrawAmount), true); + }); + + it('should parse and rebuild the transaction resulting in the same hex', async function () { + const txBuilder = factory.from(fixture.tx); + const builtTx = await txBuilder.build(); + + // Verify the parser extracted the signature + const signature = builtTx.signature[0]; + should.exist(signature); + signature.should.not.be.empty(); + + // Rebuild from the parsed object + const builder2 = factory.from(builtTx.toBroadcastFormat()); + const builtTx2 = await builder2.build(); + + // The output of the second build should match the original raw transaction + should.equal(builtTx2.toBroadcastFormat(), fixture.tx); + should.equal(builtTx2.type, TransactionType.TonWhalesWithdrawal); + }); + + it('should build a transaction from scratch that byte-for-byte matches the raw fixture', async function () { + // Get the specific Withdrawal Builder + const builder = factory.getTonWhalesWithdrawalBuilder(); + + // Set Header Info from Fixture + builder.sender(fixture.sender); + builder.publicKey(fixture.publicKey); + builder.sequenceNumber(fixture.seqno); + builder.expireTime(fixture.expireTime); + builder.bounceable(fixture.bounceable); + + // Set Destination and ATTACHED VALUE (The Fee) + builder.send({ + address: fixture.recipient.address, + amount: fixture.recipient.amount, + }); + + // Set Payload Data (The Unstake Amount) + // Note: This works for both partial (amount > 0) and full (amount = "0") + builder.setWithdrawalMessage(fixture.withdrawAmount, fixture.queryId); + + // Attach Signature from Fixture (Mocking the HSM signing process) + if (fixture.signature) { + builder.addSignature({ pub: fixture.publicKey }, Buffer.from(fixture.signature, 'hex')); + } + + // Build Signed Transaction + const signedBuiltTx = await builder.build(); + + // Byte-for-byte equality with the Sandbox output + should.equal(signedBuiltTx.toBroadcastFormat(), fixture.tx); + should.equal(signedBuiltTx.type, TransactionType.TonWhalesWithdrawal); + }); + }); + }); +}); diff --git a/modules/sdk-core/src/account-lib/baseCoin/enum.ts b/modules/sdk-core/src/account-lib/baseCoin/enum.ts index 7153f7195f..657d88167e 100644 --- a/modules/sdk-core/src/account-lib/baseCoin/enum.ts +++ b/modules/sdk-core/src/account-lib/baseCoin/enum.ts @@ -122,7 +122,7 @@ export enum TransactionType { // ton whales TonWhalesDeposit, - TonWhalesWithdraw, + TonWhalesWithdrawal, } /**