diff --git a/modules/sdk-coin-ton/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-ton/src/lib/transactionBuilderFactory.ts index aa15641e24..e470532dbd 100644 --- a/modules/sdk-coin-ton/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-ton/src/lib/transactionBuilderFactory.ts @@ -2,6 +2,7 @@ import { BaseTransactionBuilderFactory } from '@bitgo/sdk-core'; import { TransactionBuilder } from './transactionBuilder'; import { TransferBuilder } from './transferBuilder'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { WithdrawBuilder } from './withdrawBuilder'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(_coinConfig: Readonly) { @@ -19,6 +20,13 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return new TransferBuilder(this._coinConfig); } + /** + * Returns a specific builder to create a TON withdraw transaction + */ + getWithdrawBuilder(): WithdrawBuilder { + return new WithdrawBuilder(this._coinConfig); + } + /** @inheritdoc */ getWalletInitializationBuilder(): void { throw new Error('Method not implemented.'); diff --git a/modules/sdk-coin-ton/src/lib/withdrawBuilder.ts b/modules/sdk-coin-ton/src/lib/withdrawBuilder.ts new file mode 100644 index 0000000000..883501d4a2 --- /dev/null +++ b/modules/sdk-coin-ton/src/lib/withdrawBuilder.ts @@ -0,0 +1,31 @@ +import { TransactionBuilder } from './transactionBuilder'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { Recipient, TransactionType } from '@bitgo/sdk-core'; +import TonWeb from 'tonweb'; + +export class WithdrawBuilder extends TransactionBuilder { + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + protected get transactionType(): TransactionType { + return TransactionType.Send; + } + + send(recipient: Recipient): WithdrawBuilder { + this.transaction.recipient = recipient; + return this; + } + + private createMessage(): string { + const opcode = '00001000'; + const query_id = '0000000000000000'; + const amount = TonWeb.utils.toNano(this.transaction.recipient.amount); + return opcode.concat(query_id).concat(amount.toString('hex')); + } + + setMessage(): WithdrawBuilder { + this.transaction.message = this.createMessage(); + return this; + } +} diff --git a/modules/sdk-coin-ton/test/unit/withdrawBuilder.ts b/modules/sdk-coin-ton/test/unit/withdrawBuilder.ts new file mode 100644 index 0000000000..1f49433e65 --- /dev/null +++ b/modules/sdk-coin-ton/test/unit/withdrawBuilder.ts @@ -0,0 +1,216 @@ +import should from 'should'; +import { TransactionType } from '@bitgo/sdk-core'; +import { TransactionBuilderFactory } from '../../src/lib/transactionBuilderFactory'; +import { coins } from '@bitgo/statics'; +import * as testData from '../resources/ton'; +import { KeyPair } from '../../src/lib/keyPair'; +import * as utils from '../../src/lib/utils'; +import TonWeb from 'tonweb'; + +describe('Ton Withdraw Builder', () => { + const factory = new TransactionBuilderFactory(coins.get('tton')); + it('should build a unsigned withdraw tx', async function () { + const txId = 'CfCL3fPQVRn90snJI_Al4zPIeLzGHsNN0W9NoQttXQo='.replace(/\//g, '_').replace(/\+/g, '-'); + const txBuilder = factory.getWithdrawBuilder(); + txBuilder.sender(testData.sender.address); + txBuilder.sequenceNumber(0); + txBuilder.publicKey(testData.sender.publicKey); + txBuilder.expireTime(1234567890); + txBuilder.send(testData.recipients[0]); + txBuilder.setMessage(); + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + should.equal(tx.toJson().bounceable, false); + 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', + }); + tx.id.should.equal(txId); + const rawTx = tx.toBroadcastFormat(); + console.log(rawTx); + rawTx.should.equal( + 'te6cckECGAEAA9oAAuGIADZN0H0n1tz6xkYgWqJSRmkURKYajjEgXeawBo9cifPIGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF8DDudwJkyEh7jUbJEjFCjriVxsSlRJFyF872V1eegb4QAC+QgAaRefBOjTi/hwqDjv+7I6nGj9WEAe3ls/rFuBEQvggr6A613oAAAAAAAAAAAAAAAAAAAAAAAAwMDAwMTAwMDAwMDAwMDAwMDAwMDAwMDAxYjY2N2E1NmQ0ODgwMDC/jRLK' + ); + }); + + it('should build a unsigned withdraw tx with bounceable flag', async function () { + const txId = '1X_pl438wIz2kSAX_ek7aYCIgqn9RslTLKRIczXfoUU='.replace(/\//g, '_').replace(/\+/g, '-'); + const txBuilder = factory.getWithdrawBuilder(); + txBuilder.sender(testData.sender.address); + txBuilder.sequenceNumber(0); + txBuilder.publicKey(testData.sender.publicKey); + txBuilder.expireTime(1234567890); + txBuilder.send(testData.recipients[0]); + txBuilder.setMessage(); + txBuilder.bounceable(true); + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + should.equal(tx.toJson().bounceable, true); + 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', + }); + tx.id.should.equal(txId); + const rawTx = tx.toBroadcastFormat(); + console.log(rawTx); + rawTx.should.equal( + 'te6cckECGAEAA9oAAuGIADZN0H0n1tz6xkYgWqJSRmkURKYajjEgXeawBo9cifPIGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF8DDudwJkyEh7jUbJEjFCjriVxsSlRJFyF872V1eegb4QAC+YgAaRefBOjTi/hwqDjv+7I6nGj9WEAe3ls/rFuBEQvggr6A613oAAAAAAAAAAAAAAAAAAAAAAAAwMDAwMTAwMDAwMDAwMDAwMDAwMDAwMDAxYjY2N2E1NmQ0ODgwMDCumWKy' + ); + }); + + it('should build a send from rawTx', async function () { + const txBuilder = factory.from(testData.signedTransaction.tx); + const builtTx = await txBuilder.build(); + const jsonTx = builtTx.toJson(); + should.equal(builtTx.type, TransactionType.Send); + should.equal(builtTx.signablePayload.toString('base64'), testData.signedTransaction.signable); + should.equal(builtTx.id, testData.signedTransaction.txId); + const builder2 = factory.from(builtTx.toBroadcastFormat()); + const builtTx2 = await builder2.build(); + should.equal(builtTx2.type, TransactionType.Send); + should.equal(builtTx.toBroadcastFormat(), testData.signedTransaction.tx); + builtTx.inputs.length.should.equal(1); + builtTx.outputs.length.should.equal(1); + jsonTx.sender.should.equal('EQCSBjR3fUOL98WTw2F_IT4BrcqjZJWVLWUSz5WQDpaL9Jpl'); + jsonTx.destination.should.equal('EQA0i8-CdGnF_DhUHHf92R1ONH6sIA9vLZ_WLcCIhfBBXwtG'); + jsonTx.amount.should.equal('10000000'); + jsonTx.seqno.should.equal(6); + jsonTx.expirationTime.should.equal(1695997582); + + const builtTx3 = await txBuilder.bounceable(false).fromAddressBounceable(false).toAddressBounceable(false).build(); + txBuilder.from(testData.signedTransaction.tx); + const jsonTx3 = builtTx3.toJson(); + should.equal(jsonTx3.bounceable, false); + should.equal(builtTx3.signablePayload.toString('base64'), testData.signedTransaction.bounceableSignable); + should.equal(builtTx3.id, testData.signedTransaction.txIdBounceable); + should.equal(builtTx3.toBroadcastFormat(), testData.signedTransaction.tx); + jsonTx3.sender.should.equal('UQCSBjR3fUOL98WTw2F_IT4BrcqjZJWVLWUSz5WQDpaL9Meg'); + jsonTx3.destination.should.equal('UQA0i8-CdGnF_DhUHHf92R1ONH6sIA9vLZ_WLcCIhfBBX1aD'); + jsonTx3.amount.should.equal('10000000'); + jsonTx3.seqno.should.equal(6); + jsonTx3.expirationTime.should.equal(1695997582); + }); + + it('should parse a raw transaction and set flags', async function () { + const factory = new TransactionBuilderFactory(coins.get('tton')); + const txBuilder = factory.from(testData.signedTransaction.tx); + const txBuilderBounceable = factory.from(testData.signedTransaction.txBounceable); + + const tx = await txBuilder.build(); + const txBounceable = await txBuilderBounceable.build(); + tx.toJson().bounceable.should.equal(false); + txBounceable.toJson().bounceable.should.equal(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 }); + const publicKey = keyPair.getKeys().pub; + const address = await utils.default.getAddressFromPublicKey(publicKey); + const txBuilder = factory.getWithdrawBuilder(); + txBuilder.sender(address); + + const WalletClass = tonweb.wallet.all['v4R2']; + const wallet = new WalletClass(tonweb.provider, { + publicKey: tonweb.utils.hexToBytes(publicKey), + wc: 0, + }); + const seqno = await wallet.methods.seqno().call(); + txBuilder.sequenceNumber(seqno as number); + txBuilder.publicKey(publicKey); + const expireAt = Math.floor(Date.now() / 1e3) + 60 * 60 * 24 * 7; // 7 days + txBuilder.expireTime(expireAt); + txBuilder.send({ + address: 'EQD6Tm5rybJHqMflQkjpCRB7Lm5QpGqFoYwiLqMjPtaqPSdZ', + amount: '10000000', + }); + txBuilder.setMessage(); + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + const signable = tx.signablePayload; + const signature = keyPair.signMessageinUint8Array(signable); + const signedTx = await txBuilder.build(); + const builder2 = factory.from(signedTx.toBroadcastFormat()); + builder2.addSignature(keyPair.getKeys(), Buffer.from(signature)); + const tx2 = await builder2.build(); + const signature2 = keyPair.signMessageinUint8Array(tx2.signablePayload); + should.equal(Buffer.from(signature).toString('hex'), Buffer.from(signature2).toString('hex')); + await new Promise((resolve) => setTimeout(resolve, 2000)); + const result = await tonweb.provider.sendBoc(tx2.toBroadcastFormat()); + console.log(JSON.stringify(result)); + }); + + it('should build a signed withdraw 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.getWithdrawBuilder(); + 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.setMessage(); + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + 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()); + }); + + it('should build withdraw tx for non-bounceable address', async function () { + const txBuilder = factory.getWithdrawBuilder(); + txBuilder.sender(testData.sender.address); + txBuilder.sequenceNumber(0); + txBuilder.publicKey(testData.sender.publicKey); + txBuilder.expireTime(1234567890); + const address = 'EQAWzEKcdnykvXfUNouqdS62tvrp32bCxuKS6eQrS6ISgcLo'; + const otherFormat = 'UQAWzEKcdnykvXfUNouqdS62tvrp32bCxuKS6eQrS6ISgZ8t'; + const amount = '10000000'; + txBuilder.send({ address, amount }); + txBuilder.setMessage(); + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + tx.inputs.length.should.equal(1); + tx.inputs[0].should.deepEqual({ + address: testData.sender.address, + value: amount, + coin: 'tton', + }); + tx.outputs.length.should.equal(1); + tx.outputs[0].should.deepEqual({ + address, + value: amount, + coin: 'tton', + }); + const txJson = tx.toJson(); + txJson.destination.should.equal(address); + const builder2 = factory.from(tx.toBroadcastFormat()); + const tx2 = await builder2.build(); + const txJson2 = tx2.toJson(); + txJson2.destinationAlias.should.equal(otherFormat); + }); +});