diff --git a/__tests__/integration/template/transaction/template.test.ts b/__tests__/integration/template/transaction/template.test.ts index effff885f..9a9018176 100644 --- a/__tests__/integration/template/transaction/template.test.ts +++ b/__tests__/integration/template/transaction/template.test.ts @@ -11,7 +11,13 @@ import SendTransaction from '../../../../src/new/sendTransaction'; import transactionUtils from '../../../../src/utils/transaction'; import { TransactionTemplateBuilder } from '../../../../src/template/transaction/builder'; import { WalletTxTemplateInterpreter } from '../../../../src/template/transaction/interpreter'; -import { TOKEN_AUTHORITY_MASK, TOKEN_MELT_MASK, TOKEN_MINT_MASK } from '../../../../src/constants'; +import { + NATIVE_TOKEN_UID, + TOKEN_AUTHORITY_MASK, + TOKEN_MELT_MASK, + TOKEN_MINT_MASK, +} from '../../../../src/constants'; +import { TokenVersion } from '../../../../src/types'; const DEBUG = true; @@ -438,3 +444,176 @@ describe('Template execution', () => { ); }); }); + +describe('Template execution with fee tokens', () => { + let hWallet: HathorWallet; + let interpreter: WalletTxTemplateInterpreter; + + beforeAll(async () => { + hWallet = await generateWalletHelper(null); + interpreter = new WalletTxTemplateInterpreter(hWallet); + const address = await hWallet.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(hWallet, address, 10n, {}); + }); + + afterAll(async () => { + await hWallet.stop(); + await stopAllWallets(); + await GenesisWalletHelper.clearListeners(); + }); + + it('should be not able to create a custom fee token without providing a fee header', async () => { + const template = new TransactionTemplateBuilder() + .addConfigAction({ + createToken: true, + tokenName: 'Tmpl Test Fee Token 01', + tokenSymbol: 'TTT01', + tokenVersion: TokenVersion.FEE, + }) + .addSetVarAction({ name: 'addr', call: { method: 'get_wallet_address' } }) + .addUtxoSelect({ fill: 5 }) + .addTokenOutput({ address: '{addr}', amount: 100, useCreatedToken: true }) + .addTokenOutput({ address: '{addr}', amount: 100, useCreatedToken: true }) + .addTokenOutput({ address: '{addr}', amount: 100, useCreatedToken: true }) + .addTokenOutput({ address: '{addr}', amount: 100, useCreatedToken: true }) + .build(); + + const tx = await interpreter.build(template, DEBUG); + + expect(tx.outputs).toHaveLength(5); + + // HTR change + expect(tx.outputs[0].tokenData).toBe(0); + expect(tx.outputs[0].value).toBe(5n); + + // Created token + expect(tx.outputs[1].tokenData).toBe(1); + expect(tx.outputs[1].value).toBe(100n); + + expect(tx.outputs[2].tokenData).toBe(1); + expect(tx.outputs[2].value).toBe(100n); + + expect(tx.outputs[3].tokenData).toBe(1); + expect(tx.outputs[3].value).toBe(100n); + + expect(tx.outputs[4].tokenData).toBe(1); + expect(tx.outputs[4].value).toBe(100n); + + // Not have a fee header + expect(tx.headers).toHaveLength(0); + + await transactionUtils.signTransaction(tx, hWallet.storage, DEFAULT_PIN_CODE); + tx.prepareToSend(); + const sendTx = new SendTransaction({ storage: hWallet.storage, transaction: tx }); + + // Expecting the transaction to fail validation with negative HTR balance + await expect(sendTx.runFromMining()).rejects.toThrow( + 'full validation failed: HTR balance is different than expected. (amount=-5, expected=0)' + ); + + // Check the wallet balance to verify nothing was spent from the wallet + const htrBalance = await hWallet.getBalance(NATIVE_TOKEN_UID); + expect(htrBalance[0]).toHaveProperty('balance.unlocked', 10n); + }); + + it('should be able to create a custom fee token providing a fee header', async () => { + const mintAmount = 8582n; + const template = new TransactionTemplateBuilder() + .addConfigAction({ + createToken: true, + tokenName: 'Tmpl Test Fee Token 01', + tokenSymbol: 'TTT01', + tokenVersion: TokenVersion.FEE, + }) + .addSetVarAction({ name: 'addr', call: { method: 'get_wallet_address' } }) + .addUtxoSelect({ fill: 1 }) + .addTokenOutput({ address: '{addr}', amount: mintAmount, useCreatedToken: true }) + .addFee({ amount: 1 }) + .build(); + + const tx = await interpreter.build(template, DEBUG); + + expect(tx.outputs).toHaveLength(2); + + // HTR change + expect(tx.outputs[0].tokenData).toBe(0); + expect(tx.outputs[0].value).toBe(9n); + + // Created token + expect(tx.outputs[1].tokenData).toBe(1); + expect(tx.outputs[1].value).toBe(mintAmount); + + // Have a fee header + expect(tx.headers).toHaveLength(1); + expect(tx.getFeeHeader()).not.toBeNull(); + expect(tx.getFeeHeader()!.entries).toHaveLength(1); + expect(tx.getFeeHeader()!.entries[0].tokenIndex).toBe(0); + expect(tx.getFeeHeader()!.entries[0].amount).toBe(1n); + + // after validate send the tx to the mining service + await transactionUtils.signTransaction(tx, hWallet.storage, DEFAULT_PIN_CODE); + tx.prepareToSend(); + const sendTx = new SendTransaction({ storage: hWallet.storage, transaction: tx }); + await sendTx.runFromMining(); + expect(tx.hash).not.toBeNull(); + if (tx.hash === null) { + throw new Error('Transaction does not have a hash'); + } + await waitForTxReceived(hWallet, tx.hash, undefined); + + const htrBalance = await hWallet.getBalance(NATIVE_TOKEN_UID); + expect(htrBalance[0]).toHaveProperty('balance.unlocked', 9n); + }); + it('should be able to complete the fees of a transaction', async () => { + const template = new TransactionTemplateBuilder() + .addConfigAction({ + createToken: true, + tokenName: 'Tmpl Test Fee Token 01', + tokenSymbol: 'TTT01', + tokenVersion: TokenVersion.FEE, + }) + .addSetVarAction({ name: 'addr', call: { method: 'get_wallet_address' } }) + .addUtxoSelect({ fill: 5 }) + .addTokenOutput({ address: '{addr}', amount: 9999999, useCreatedToken: true }) + .addTokenOutput({ address: '{addr}', amount: 9999999, useCreatedToken: true }) + .addTokenOutput({ address: '{addr}', amount: 9999999, useCreatedToken: true }) + .addTokenOutput({ address: '{addr}', amount: 9999999, useCreatedToken: true }) + .addCompleteAction({ calculateFee: true, token: '00' }) + .build(); + + const tx = await interpreter.build(template, DEBUG); + + // check the outputs + expect(tx.outputs).toHaveLength(6); + + // HTR change + expect(tx.outputs[0].tokenData).toBe(0); + expect(tx.outputs[0].value).toBe(4n); + + // Created token + expect(tx.outputs[1].tokenData).toBe(1); + expect(tx.outputs[1].value).toBe(9999999n); + + // Have a fee header + expect(tx.headers).toHaveLength(1); + expect(tx.getFeeHeader()).not.toBeNull(); + expect(tx.getFeeHeader()!.entries).toHaveLength(1); + expect(tx.getFeeHeader()!.entries[0].tokenIndex).toBe(0); + expect(tx.getFeeHeader()!.entries[0].amount).toBe(4n); + + // after validate send the tx to the mining service + await transactionUtils.signTransaction(tx, hWallet.storage, DEFAULT_PIN_CODE); + tx.prepareToSend(); + const sendTx = new SendTransaction({ storage: hWallet.storage, transaction: tx }); + await sendTx.runFromMining(); + expect(tx.hash).not.toBeNull(); + if (tx.hash === null) { + throw new Error('Transaction does not have a hash'); + } + + await waitForTxReceived(hWallet, tx.hash, undefined); + + const htrBalance = await hWallet.getBalance(NATIVE_TOKEN_UID); + expect(htrBalance[0]).toHaveProperty('balance.unlocked', 5n); + }); +}); diff --git a/__tests__/template/transaction/executor.test.ts b/__tests__/template/transaction/executor.test.ts index 08b0c91a4..1410fa42d 100644 --- a/__tests__/template/transaction/executor.test.ts +++ b/__tests__/template/transaction/executor.test.ts @@ -12,6 +12,7 @@ import { execCompleteTxInstruction, execConfigInstruction, execDataOutputInstruction, + execFeeInstruction, execRawInputInstruction, execRawOutputInstruction, execSetVarInstruction, @@ -27,6 +28,7 @@ import { AuthorityOutputInstruction, AuthoritySelectInstruction, DataOutputInstruction, + FeeInstruction, RawInputInstruction, RawOutputInstruction, ShuffleInstruction, @@ -47,6 +49,34 @@ const address = 'WYiD1E8n5oB9weZ8NMyM3KoCjKf1KCjWAZ'; const token = '0000000110eb9ec96e255a09d6ae7d856bff53453773bae5500cee2905db670e'; const txId = '0000000110eb9ec96e255a09d6ae7d856bff53453773bae5500cee2905db670e'; +const mockTokenDetails = { + totalSupply: 1000n, + totalTransactions: 1, + tokenInfo: { + name: 'DBT', + symbol: 'DBT', + version: 1, // TokenVersion.DEPOSIT + }, + authorities: { + mint: true, + melt: true, + }, +}; + +const mockFeeTokenDetails = { + totalSupply: 1000n, + totalTransactions: 1, + tokenInfo: { + name: 'FeeBasedToken', + symbol: 'FBT', + version: 2, // TokenVersion.FEE + }, + authorities: { + mint: true, + melt: true, + }, +}; + describe('findInstructionExecution', () => { it('should find the correct executor', () => { expect( @@ -133,6 +163,13 @@ describe('findInstructionExecution', () => { value: 'bar', }) ).toBe(execSetVarInstruction); + + expect( + findInstructionExecution({ + type: 'action/fee', + amount: 100, + }) + ).toBe(execFeeInstruction); }); it('should throw with an invalid instruction', () => { @@ -213,8 +250,8 @@ describe('findInstructionExecution', () => { }); const RawInputExecutorTest = async executor => { - const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); const interpreter = { + getTokenDetails: jest.fn().mockResolvedValue(mockTokenDetails), getTx: jest.fn().mockReturnValue( Promise.resolve({ outputs: [ @@ -227,6 +264,7 @@ const RawInputExecutorTest = async executor => { }) ), }; + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); const ins = RawInputInstruction.parse({ type: 'input/raw', index: 0, txId }); await executor(interpreter, ctx, ins); @@ -239,13 +277,15 @@ const RawInputExecutorTest = async executor => { tokens: 123n, mint_authorities: 0, melt_authorities: 0, + chargeableOutputs: 0, + chargeableInputs: 0, }); }; const UtxoSelectExecutorTest = async executor => { - const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); const interpreter = { getNetwork: jest.fn().mockReturnValue(new Network('testnet')), + getTokenDetails: jest.fn().mockResolvedValue(mockTokenDetails), getChangeAddress: jest.fn().mockResolvedValue(address), getTx: jest.fn().mockResolvedValue({ outputs: [ @@ -283,6 +323,7 @@ const UtxoSelectExecutorTest = async executor => { ], }), }; + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); const insData = { type: 'input/utxo', fill: 30, token }; const ins = UtxoSelectInstruction.parse(insData); @@ -317,8 +358,8 @@ const UtxoSelectExecutorTest = async executor => { }; const AuthoritySelectExecutorTest = async executor => { - const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); const interpreter = { + getTokenDetails: jest.fn().mockResolvedValue(mockTokenDetails), getTx: jest.fn().mockResolvedValue({ outputs: [ { @@ -339,6 +380,7 @@ const AuthoritySelectExecutorTest = async executor => { }, ]), }; + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); const inputIns = { type: 'input/authority', token, authority: 'mint' }; const ins = AuthoritySelectInstruction.parse(inputIns); await executor(interpreter, ctx, ins); @@ -351,7 +393,6 @@ const AuthoritySelectExecutorTest = async executor => { expect(ctx.inputs[0].index).toStrictEqual(0); expect(ctx.outputs).toHaveLength(0); - expect(ctx.tokens).toHaveLength(0); expect(Object.keys(ctx.balance.balance)).toHaveLength(1); expect(ctx.balance.balance[token]).toMatchObject({ @@ -362,8 +403,10 @@ const AuthoritySelectExecutorTest = async executor => { }; const RawOutputExecutorTest = async executor => { + const interpreter = { + getTokenDetails: jest.fn().mockResolvedValue(mockTokenDetails), + }; const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); - const interpreter = {}; // interpreter is not used on raw output instruction const ins = RawOutputInstruction.parse({ type: 'output/raw', script: 'cafe', @@ -388,8 +431,10 @@ const RawOutputExecutorTest = async executor => { }; const RawOutputExecutorTestForAuthority = async executor => { + const interpreter = { + getTokenDetails: jest.fn().mockResolvedValue(mockTokenDetails), + }; const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); - const interpreter = {}; // interpreter is not used on raw output instruction const ins = RawOutputInstruction.parse({ type: 'output/raw', script: 'cafe', @@ -415,8 +460,10 @@ const RawOutputExecutorTestForAuthority = async executor => { }; const DataOutputExecutorTest = async executor => { + const interpreter = { + getTokenDetails: jest.fn().mockResolvedValue(mockTokenDetails), + }; // interpreter is not used on data output instruction const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); - const interpreter = {}; // interpreter is not used on data output instruction const ins = DataOutputInstruction.parse({ type: 'output/data', data: 'foobar', @@ -447,10 +494,11 @@ const DataOutputExecutorTest = async executor => { }; const TokenOutputExecutorTest = async executor => { - const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); const interpreter = { getNetwork: jest.fn().mockReturnValue(new Network('testnet')), + getTokenDetails: jest.fn().mockResolvedValue(mockTokenDetails), }; + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); const ins = TokenOutputInstruction.parse({ type: 'output/token', amount: 23, @@ -483,10 +531,11 @@ const TokenOutputExecutorTest = async executor => { }; const AuthorityOutputExecutorTest = async executor => { - const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); const interpreter = { getNetwork: jest.fn().mockReturnValue(new Network('testnet')), + getTokenDetails: jest.fn().mockResolvedValue(mockTokenDetails), }; + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); const ins = AuthorityOutputInstruction.parse({ type: 'output/authority', authority: 'melt', @@ -519,8 +568,10 @@ const AuthorityOutputExecutorTest = async executor => { }; const ShuffleExecutorTest = async executor => { + const interpreter = { + getTokenDetails: jest.fn().mockResolvedValue(mockTokenDetails), + }; const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); - const interpreter = {}; const ins = ShuffleInstruction.parse({ type: 'action/shuffle', target: 'all', @@ -549,6 +600,88 @@ const ShuffleExecutorTest = async executor => { expect(ctx.inputs.length).toEqual(inputsBefore.length); }; +const ChargeableInputsTest = async executor => { + const interpreter = { + getTokenDetails: jest.fn().mockResolvedValue(mockFeeTokenDetails), + getTx: jest.fn().mockReturnValue( + Promise.resolve({ + outputs: [ + { + value: 100n, + token, + token_data: 1, + }, + { + value: 50n, + token, + token_data: 1, + }, + ], + }) + ), + }; + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + + // Add two non-authority inputs from the same transaction + const ins1 = RawInputInstruction.parse({ type: 'input/raw', index: 0, txId }); + await executor(interpreter, ctx, ins1); + + const ins2 = RawInputInstruction.parse({ type: 'input/raw', index: 1, txId }); + await executor(interpreter, ctx, ins2); + + expect(interpreter.getTx).toHaveBeenCalledTimes(2); + expect(ctx.inputs).toHaveLength(2); + expect(ctx.balance.balance[token]).toMatchObject({ + tokens: 150n, + mint_authorities: 0, + melt_authorities: 0, + chargeableInputs: 2, + chargeableOutputs: 0, + }); +}; + +const ChargeableOutputsTest = async executor => { + const interpreter = { + getNetwork: jest.fn().mockReturnValue(new Network('testnet')), + getTokenDetails: jest.fn().mockResolvedValue(mockFeeTokenDetails), + }; + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + + // Add three token outputs + const ins1 = TokenOutputInstruction.parse({ + type: 'output/token', + amount: 30, + address, + token, + }); + await executor(interpreter, ctx, ins1); + + const ins2 = TokenOutputInstruction.parse({ + type: 'output/token', + amount: 20, + address, + token, + }); + await executor(interpreter, ctx, ins2); + + const ins3 = TokenOutputInstruction.parse({ + type: 'output/token', + amount: 10, + address, + token, + }); + await executor(interpreter, ctx, ins3); + + expect(ctx.outputs).toHaveLength(3); + expect(ctx.balance.balance[token]).toMatchObject({ + tokens: -60n, + mint_authorities: 0, + melt_authorities: 0, + chargeableInputs: 0, + chargeableOutputs: 3, + }); +}; + /* eslint-disable jest/expect-expect */ describe('execute instruction from executor', () => { it('should execute RawInputInstruction', async () => { @@ -613,5 +746,222 @@ describe('execute instruction from executor', () => { // Using runInstruction await ShuffleExecutorTest(runInstruction); }); + + it('should track chargeable inputs when using addBalanceFromUtxo with fee tokens', async () => { + // Using the executor + await ChargeableInputsTest(execRawInputInstruction); + // Using runInstruction + await ChargeableInputsTest(runInstruction); + }); + + it('should track chargeable outputs when using addOutput with fee tokens', async () => { + // Using the executor + await ChargeableOutputsTest(execTokenOutputInstruction); + // Using runInstruction + await ChargeableOutputsTest(runInstruction); + }); }); /* eslint-enable jest/expect-expect */ + +describe('tokenVersion validation in output instructions', () => { + it('should throw error in RawOutputInstruction when useCreatedToken is true but tokenVersion is not set', async () => { + const interpreter = { + getTokenDetails: jest.fn().mockResolvedValue(mockTokenDetails), + }; + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + // Enable createTokenTxContext but don't set tokenVersion + ctx.useCreateTokenTxContext(); + + const ins = RawOutputInstruction.parse({ + type: 'output/raw', + script: 'cafe', + amount: 100, + useCreatedToken: true, + }); + + await expect(execRawOutputInstruction(interpreter, ctx, ins)).rejects.toThrow( + 'Current transaction does not have a token version' + ); + }); + + it('should throw error in DataOutputInstruction when useCreatedToken is true but tokenVersion is not set', async () => { + const interpreter = { + getTokenDetails: jest.fn().mockResolvedValue(mockTokenDetails), + }; + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + // Enable createTokenTxContext but don't set tokenVersion + ctx.useCreateTokenTxContext(); + + const ins = DataOutputInstruction.parse({ + type: 'output/data', + data: 'foobar', + useCreatedToken: true, + }); + + await expect(execDataOutputInstruction(interpreter, ctx, ins)).rejects.toThrow( + 'Current transaction does not have a token version' + ); + }); + + it('should throw error in TokenOutputInstruction when useCreatedToken is true but tokenVersion is not set', async () => { + const interpreter = { + getNetwork: jest.fn().mockReturnValue(new Network('testnet')), + getTokenDetails: jest.fn().mockResolvedValue(mockTokenDetails), + }; + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + // Enable createTokenTxContext but don't set tokenVersion + ctx.useCreateTokenTxContext(); + + const ins = TokenOutputInstruction.parse({ + type: 'output/token', + amount: 50, + address, + useCreatedToken: true, + }); + + await expect(execTokenOutputInstruction(interpreter, ctx, ins)).rejects.toThrow( + 'Current transaction does not have a token version' + ); + }); + + it('should throw error in AuthorityOutputInstruction when useCreatedToken is true but tokenVersion is not set', async () => { + const interpreter = { + getNetwork: jest.fn().mockReturnValue(new Network('testnet')), + getTokenDetails: jest.fn().mockResolvedValue(mockTokenDetails), + }; + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + // Enable createTokenTxContext but don't set tokenVersion + ctx.useCreateTokenTxContext(); + + const ins = AuthorityOutputInstruction.parse({ + type: 'output/authority', + authority: 'mint', + address, + useCreatedToken: true, + }); + + await expect(execAuthorityOutputInstruction(interpreter, ctx, ins)).rejects.toThrow( + 'Current transaction does not have a token version' + ); + }); +}); + +describe('execFeeInstruction', () => { + it('should add fee for a new token', async () => { + const interpreter = { + getTokenDetails: jest.fn().mockResolvedValue(mockTokenDetails), + }; + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + + const ins = FeeInstruction.parse({ + type: 'action/fee', + token, + amount: 100, + }); + + await execFeeInstruction(interpreter, ctx, ins); + + expect(ctx.fees.has(token)).toBe(true); + expect(ctx.fees.get(token)).toBe(100n); + }); + + it('should add fee for HTR token when token is not specified', async () => { + const interpreter = { + getTokenDetails: jest.fn().mockResolvedValue(mockTokenDetails), + }; + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + + const ins = FeeInstruction.parse({ + type: 'action/fee', + amount: 50, + }); + + await execFeeInstruction(interpreter, ctx, ins); + + expect(ctx.fees.has('00')).toBe(true); + expect(ctx.fees.get('00')).toBe(50n); + }); + + it('should sum fees when adding to an existing token', async () => { + const interpreter = { + getTokenDetails: jest.fn().mockResolvedValue(mockTokenDetails), + }; + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + + // Add first fee + const ins1 = FeeInstruction.parse({ + type: 'action/fee', + token, + amount: 100, + }); + await execFeeInstruction(interpreter, ctx, ins1); + + // Add second fee for the same token + const ins2 = FeeInstruction.parse({ + type: 'action/fee', + token, + amount: 50, + }); + await execFeeInstruction(interpreter, ctx, ins2); + + expect(ctx.fees.has(token)).toBe(true); + expect(ctx.fees.get(token)).toBe(150n); + }); + + it('should use variables for token and amount', async () => { + const interpreter = { + getTokenDetails: jest.fn().mockResolvedValue(mockTokenDetails), + }; + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + + // Set variables + ctx.vars.myToken = token; + ctx.vars.myAmount = 200n; + + const ins = FeeInstruction.parse({ + type: 'action/fee', + token: '{myToken}', + amount: '{myAmount}', + }); + + await execFeeInstruction(interpreter, ctx, ins); + + expect(ctx.fees.has(token)).toBe(true); + expect(ctx.fees.get(token)).toBe(200n); + }); + + it('should handle multiple tokens independently', async () => { + const interpreter = { + getTokenDetails: jest.fn().mockResolvedValue(mockTokenDetails), + }; + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + const token2 = '0000000220eb9ec96e255a09d6ae7d856bff53453773bae5500cee2905db670e'; + + // Add fee for first token + const ins1 = FeeInstruction.parse({ + type: 'action/fee', + token, + amount: 100, + }); + await execFeeInstruction(interpreter, ctx, ins1); + + // Add fee for second token + const ins2 = FeeInstruction.parse({ + type: 'action/fee', + token: token2, + amount: 200, + }); + await execFeeInstruction(interpreter, ctx, ins2); + + // Add more to first token + const ins3 = FeeInstruction.parse({ + type: 'action/fee', + token, + amount: 50, + }); + await execFeeInstruction(interpreter, ctx, ins3); + + expect(ctx.fees.get(token)).toBe(150n); + expect(ctx.fees.get(token2)).toBe(200n); + }); +}); diff --git a/__tests__/template/transaction/interpreter.test.ts b/__tests__/template/transaction/interpreter.test.ts index 5b3a537f2..61e3bc41c 100644 --- a/__tests__/template/transaction/interpreter.test.ts +++ b/__tests__/template/transaction/interpreter.test.ts @@ -8,6 +8,9 @@ import HathorWallet from '../../../src/new/wallet'; import { TxTemplateContext } from '../../../src/template/transaction/context'; import { WalletTxTemplateInterpreter } from '../../../src/template/transaction/interpreter'; +import FeeHeader from '../../../src/headers/fee'; +import { NATIVE_TOKEN_UID } from '../../../src/constants'; +import { getDefaultLogger } from '../../../src/types'; describe('Wallet tx-template interpreter', () => { it('should get an address from the wallet', async () => { @@ -47,4 +50,165 @@ describe('Wallet tx-template interpreter', () => { await expect(interpreter.getChangeAddress(ctx)).resolves.toStrictEqual('mocked-address'); expect(wallet.getCurrentAddress).toHaveBeenCalledTimes(1); }); + + it('should get token details from the wallet api', async () => { + const mockValue = { + totalSupply: 1000n, + totalTransactions: 1, + tokenInfo: { + name: 'FeeBasedToken', + symbol: 'FBT', + version: 2, // TokenVersion.FEE + }, + authorities: { + mint: true, + melt: true, + }, + }; + const wallet = { + getTokenDetails: jest.fn().mockResolvedValue(mockValue), + } as unknown as HathorWallet; + const interpreter = new WalletTxTemplateInterpreter(wallet); + await expect(interpreter.getTokenDetails('fbt-token-uid')).resolves.toStrictEqual(mockValue); + expect(wallet.getTokenDetails).toHaveBeenCalledTimes(1); + }); + + describe('buildFeeHeader', () => { + const token1 = '0000000110eb9ec96e255a09d6ae7d856bff53453773bae5500cee2905db670e'; + const token2 = '0000000220eb9ec96e255a09d6ae7d856bff53453773bae5500cee2905db670f'; + + it('should build a FeeHeader with HTR fee entry', async () => { + const wallet = { + logger: getDefaultLogger(), + } as unknown as HathorWallet; + + const interpreter = new WalletTxTemplateInterpreter(wallet); + const tx = await interpreter.build([ + { type: 'action/fee', token: NATIVE_TOKEN_UID, amount: 100n }, + ]); + + expect(tx.headers).toHaveLength(1); + expect(tx.headers[0]).toBeInstanceOf(FeeHeader); + + const feeHeader = tx.getFeeHeader(); + expect(feeHeader).not.toBeNull(); + expect(feeHeader!.entries).toHaveLength(1); + expect(feeHeader!.entries[0].tokenIndex).toBe(0); // HTR is always index 0 + expect(feeHeader!.entries[0].amount).toBe(100n); + }); + + it('should build a FeeHeader with custom token fee entry', async () => { + const wallet = { + logger: getDefaultLogger(), + getTokenDetails: jest.fn().mockResolvedValue({ + totalSupply: 1000n, + totalTransactions: 1, + tokenInfo: { name: 'Token1', symbol: 'TK1', version: 2 }, + authorities: { mint: true, melt: true }, + }), + } as unknown as HathorWallet; + + const interpreter = new WalletTxTemplateInterpreter(wallet); + const tx = await interpreter.build([ + // First add the token to the context so it gets a token index + { type: 'output/raw', script: 'cafe', amount: 10n, token: token1 }, + // Then add the fee + { type: 'action/fee', token: token1, amount: 500n }, + ]); + + expect(tx.headers).toHaveLength(1); + const feeHeader = tx.getFeeHeader(); + expect(feeHeader).not.toBeNull(); + expect(feeHeader!.entries).toHaveLength(1); + expect(feeHeader!.entries[0].tokenIndex).toBe(1); // Custom token is index 1 + expect(feeHeader!.entries[0].amount).toBe(500n); + }); + + it('should build a FeeHeader with multiple fee entries', async () => { + const wallet = { + logger: getDefaultLogger(), + getTokenDetails: jest.fn().mockImplementation(token => { + if (token === token1) { + return Promise.resolve({ + totalSupply: 1000n, + totalTransactions: 1, + tokenInfo: { name: 'Token1', symbol: 'TK1', version: 2 }, + authorities: { mint: true, melt: true }, + }); + } + if (token === token2) { + return Promise.resolve({ + totalSupply: 2000n, + totalTransactions: 2, + tokenInfo: { name: 'Token2', symbol: 'TK2', version: 2 }, + authorities: { mint: true, melt: true }, + }); + } + return Promise.reject(new Error('Unknown token')); + }), + } as unknown as HathorWallet; + + const interpreter = new WalletTxTemplateInterpreter(wallet); + const tx = await interpreter.build([ + // Add tokens to the context + { type: 'output/raw', script: 'cafe', amount: 10n, token: token1 }, + { type: 'output/raw', script: 'cafe', amount: 20n, token: token2 }, + // Add fees for multiple tokens + { type: 'action/fee', token: NATIVE_TOKEN_UID, amount: 100n }, + { type: 'action/fee', token: token1, amount: 200n }, + { type: 'action/fee', token: token2, amount: 300n }, + ]); + + expect(tx.headers).toHaveLength(1); + const feeHeader = tx.getFeeHeader(); + expect(feeHeader).not.toBeNull(); + expect(feeHeader!.entries).toHaveLength(3); + + // Find entries by tokenIndex + const htrEntry = feeHeader!.entries.find(e => e.tokenIndex === 0); + const token1Entry = feeHeader!.entries.find(e => e.tokenIndex === 1); + const token2Entry = feeHeader!.entries.find(e => e.tokenIndex === 2); + + expect(htrEntry).toBeDefined(); + expect(htrEntry!.amount).toBe(100n); + + expect(token1Entry).toBeDefined(); + expect(token1Entry!.amount).toBe(200n); + + expect(token2Entry).toBeDefined(); + expect(token2Entry!.amount).toBe(300n); + }); + + it('should accumulate fees for the same token', async () => { + const wallet = { + logger: getDefaultLogger(), + } as unknown as HathorWallet; + + const interpreter = new WalletTxTemplateInterpreter(wallet); + const tx = await interpreter.build([ + { type: 'action/fee', token: NATIVE_TOKEN_UID, amount: 50n }, + { type: 'action/fee', token: NATIVE_TOKEN_UID, amount: 30n }, + { type: 'action/fee', token: NATIVE_TOKEN_UID, amount: 20n }, + ]); + + expect(tx.headers).toHaveLength(1); + const feeHeader = tx.getFeeHeader(); + expect(feeHeader).not.toBeNull(); + expect(feeHeader!.entries).toHaveLength(1); + expect(feeHeader!.entries[0].tokenIndex).toBe(0); + expect(feeHeader!.entries[0].amount).toBe(100n); // 50 + 30 + 20 + }); + + it('should not add FeeHeader when no fees are present', async () => { + const wallet = { + logger: getDefaultLogger(), + } as unknown as HathorWallet; + + const interpreter = new WalletTxTemplateInterpreter(wallet); + const tx = await interpreter.build([]); + + expect(tx.headers).toHaveLength(0); + expect(tx.getFeeHeader()).toBeNull(); + }); + }); }); diff --git a/__tests__/template/transaction/utils.test.ts b/__tests__/template/transaction/utils.test.ts new file mode 100644 index 000000000..44b6ff39e --- /dev/null +++ b/__tests__/template/transaction/utils.test.ts @@ -0,0 +1,243 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { selectTokens, selectAuthorities } from '../../../src/template/transaction/utils'; +import { TxTemplateContext } from '../../../src/template/transaction/context'; +import { getDefaultLogger } from '../../../src/types'; +import { NATIVE_TOKEN_UID } from '../../../src/constants'; +import Network from '../../../src/models/network'; +import { ITxTemplateInterpreter } from '../../../src/template/transaction/types'; + +const DEBUG = false; + +const address = 'WYiD1E8n5oB9weZ8NMyM3KoCjKf1KCjWAZ'; +const token = '0000000110eb9ec96e255a09d6ae7d856bff53453773bae5500cee2905db670e'; +const txId = '0000000110eb9ec96e255a09d6ae7d856bff53453773bae5500cee2905db670e'; + +const mockTokenDetails = { + totalSupply: 1000n, + totalTransactions: 1, + tokenInfo: { + name: 'TestToken', + symbol: 'TST', + version: 1, + }, + authorities: { + mint: true, + melt: true, + }, +}; + +const createMockInterpreter = (changeAmount: bigint, utxos: unknown[]) => ({ + getNetwork: jest.fn().mockReturnValue(new Network('testnet')), + getTokenDetails: jest.fn().mockResolvedValue(mockTokenDetails), + getTx: jest.fn().mockResolvedValue({ + outputs: [ + { + value: 100n, + token, + token_data: 1, + }, + ], + }), + getUtxos: jest.fn().mockResolvedValue({ + changeAmount, + utxos, + }), + getAuthorities: jest.fn().mockResolvedValue(utxos), +}); + +describe('selectTokens', () => { + describe('token array behavior - tokens should only be in array when outputs are created', () => { + it('should add token to array when autoChange=true and changeAmount > 0', async () => { + const interpreter = createMockInterpreter(10n, [ + { txId, index: 0, tokenId: token, address, value: 100n, authorities: 0n }, + ]); + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + + await selectTokens( + interpreter, + ctx, + 90n, + { token }, + true, // autoChange + address + ); + + // Token should be in array because a change output was created + expect(ctx.tokens).toHaveLength(1); + expect(ctx.tokens[0]).toBe(token); + expect(ctx.outputs).toHaveLength(1); + expect(ctx.outputs[0].value).toBe(10n); + }); + + it('should NOT add token to array when autoChange=false (even with changeAmount)', async () => { + const interpreter = createMockInterpreter(10n, [ + { txId, index: 0, tokenId: token, address, value: 100n, authorities: 0n }, + ]); + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + + await selectTokens( + interpreter, + ctx, + 90n, + { token }, + false, // autoChange = false + address + ); + + // Token should NOT be in array because no output was created + expect(ctx.tokens).toHaveLength(0); + expect(ctx.outputs).toHaveLength(0); + // But token details should be cached + expect(ctx.getTokenVersion(token)).toBe(1); + }); + + it('should NOT add token to array when changeAmount is 0', async () => { + const interpreter = createMockInterpreter(0n, [ + { txId, index: 0, tokenId: token, address, value: 100n, authorities: 0n }, + ]); + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + + await selectTokens( + interpreter, + ctx, + 100n, // exact amount, no change needed + { token }, + true, // autoChange + address + ); + + // Token should NOT be in array because no change output was created + expect(ctx.tokens).toHaveLength(0); + expect(ctx.outputs).toHaveLength(0); + // But token details should be cached + expect(ctx.getTokenVersion(token)).toBe(1); + }); + + it('should NOT add token to array when no UTXOs are found', async () => { + const interpreter = createMockInterpreter(0n, []); // No UTXOs + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + + await selectTokens(interpreter, ctx, 100n, { token }, true, address); + + // Token should NOT be in array because no inputs/outputs were created + expect(ctx.tokens).toHaveLength(0); + expect(ctx.inputs).toHaveLength(0); + expect(ctx.outputs).toHaveLength(0); + // But token details should be cached + expect(ctx.getTokenVersion(token)).toBe(1); + }); + + it('should cache token details even when token is not added to array', async () => { + const interpreter = createMockInterpreter(0n, [ + { txId, index: 0, tokenId: token, address, value: 100n, authorities: 0n }, + ]); + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + + await selectTokens(interpreter, ctx, 100n, { token }, false, address); + + // Token not in array + expect(ctx.tokens).toHaveLength(0); + // But getTokenVersion should work (details are cached) + expect(() => ctx.getTokenVersion(token)).not.toThrow(); + expect(ctx.getTokenVersion(token)).toBe(1); + }); + }); + + describe('HTR (native token) behavior', () => { + it('should handle HTR correctly - never added to tokens array', async () => { + const interpreter = createMockInterpreter(10n, [ + { txId, index: 0, tokenId: NATIVE_TOKEN_UID, address, value: 100n, authorities: 0n }, + ]) as unknown as ITxTemplateInterpreter; + // Override getTx for HTR + interpreter.getTx = jest.fn().mockResolvedValue({ + outputs: [{ value: 100n, token: NATIVE_TOKEN_UID, token_data: 0 }], + }); + + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + + await selectTokens(interpreter, ctx, 90n, { token: NATIVE_TOKEN_UID }, true, address); + + // HTR should never be in the tokens array (token_data=0 is implicit) + expect(ctx.tokens).toHaveLength(0); + // But an output should be created + expect(ctx.outputs).toHaveLength(1); + expect(ctx.outputs[0].tokenData).toBe(0); + }); + }); + + describe('inputs are correctly added', () => { + it('should add inputs from UTXOs', async () => { + const interpreter = createMockInterpreter(0n, [ + { txId, index: 0, tokenId: token, address, value: 50n, authorities: 0n }, + { txId, index: 1, tokenId: token, address, value: 50n, authorities: 0n }, + ]); + // Override getTx to return two outputs + interpreter.getTx = jest.fn().mockResolvedValue({ + outputs: [ + { value: 50n, token, token_data: 1 }, + { value: 50n, token, token_data: 1 }, + ], + }); + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + + await selectTokens(interpreter, ctx, 100n, { token }, false, address); + + expect(ctx.inputs).toHaveLength(2); + expect(ctx.inputs[0].hash).toBe(txId); + expect(ctx.inputs[0].index).toBe(0); + expect(ctx.inputs[1].hash).toBe(txId); + expect(ctx.inputs[1].index).toBe(1); + }); + }); +}); + +describe('selectAuthorities', () => { + describe('token array behavior - authorities should NEVER add token to array', () => { + it('should NOT add token to array when selecting authorities', async () => { + const interpreter = createMockInterpreter(0n, [ + { txId, index: 0, tokenId: token, address, value: 1n, authorities: 1n }, + ]); + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + + await selectAuthorities(interpreter, ctx, { token, authorities: 1n }, 1); + + // Token should NOT be in array - selectAuthorities never creates outputs + expect(ctx.tokens).toHaveLength(0); + expect(ctx.outputs).toHaveLength(0); + // But token details should be cached + expect(ctx.getTokenVersion(token)).toBe(1); + }); + + it('should add inputs from authority UTXOs', async () => { + const interpreter = createMockInterpreter(0n, [ + { txId, index: 0, tokenId: token, address, value: 1n, authorities: 1n }, + ]); + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + + await selectAuthorities(interpreter, ctx, { token, authorities: 1n }, 1); + + expect(ctx.inputs).toHaveLength(1); + expect(ctx.inputs[0].hash).toBe(txId); + expect(ctx.inputs[0].index).toBe(0); + }); + + it('should cache token details even though token is not in array', async () => { + const interpreter = createMockInterpreter(0n, [ + { txId, index: 0, tokenId: token, address, value: 1n, authorities: 1n }, + ]); + const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG); + + await selectAuthorities(interpreter, ctx, { token, authorities: 1n }, 1); + + // getTokenVersion should work + expect(() => ctx.getTokenVersion(token)).not.toThrow(); + expect(ctx.getTokenVersion(token)).toBe(1); + }); + }); +}); diff --git a/src/constants.ts b/src/constants.ts index 501af0eac..8d53a963c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -295,6 +295,11 @@ export const DEFAULT_ADDRESS_SCANNING_POLICY: AddressScanPolicy = SCANNING_POLIC */ export const FEE_PER_OUTPUT: bigint = 1n; +/** + * Fee divisor + */ +export const FEE_DIVISOR: number = 1 / TOKEN_DEPOSIT_PERCENTAGE; + /** * Max argument length in bytes (64Kib) */ diff --git a/src/template/transaction/builder.ts b/src/template/transaction/builder.ts index 143d03631..d6b6257ce 100644 --- a/src/template/transaction/builder.ts +++ b/src/template/transaction/builder.ts @@ -22,6 +22,7 @@ import { ConfigInstruction, SetVarInstruction, NanoMethodInstruction, + FeeInstruction, } from './instructions'; // Helper schemas to validate the arguments of each command in the builder args @@ -37,7 +38,7 @@ const CompleteTxInsArgs = CompleteTxInstruction.omit({ type: true }); const ConfigInsArgs = ConfigInstruction.omit({ type: true }); const SetVarInsArgs = SetVarInstruction.omit({ type: true }); const NanoMethodInsArgs = NanoMethodInstruction.omit({ type: true }); - +const FeeInsArgs = FeeInstruction.omit({ type: true }); export class TransactionTemplateBuilder { template: z.infer; @@ -188,4 +189,17 @@ export class TransactionTemplateBuilder { return this; } + + /** + * FeeInstruction is used to set the fee header for a transaction. + */ + addFee(ins: z.input) { + const parsedIns = FeeInstruction.parse({ + type: 'action/fee', + ...ins, + }); + this.template.push(parsedIns); + + return this; + } } diff --git a/src/template/transaction/context.ts b/src/template/transaction/context.ts index 31445b663..9e147e855 100644 --- a/src/template/transaction/context.ts +++ b/src/template/transaction/context.ts @@ -11,37 +11,82 @@ import { TokenVersion, IHistoryTx, ILogger, OutputValueType, getDefaultLogger } import Input from '../../models/input'; import Output from '../../models/output'; import transactionUtils from '../../utils/transaction'; -import { CREATE_TOKEN_TX_VERSION, DEFAULT_TX_VERSION, NATIVE_TOKEN_UID } from '../../constants'; +import { + CREATE_TOKEN_TX_VERSION, + DEFAULT_TX_VERSION, + NATIVE_TOKEN_UID, + FEE_PER_OUTPUT, + FEE_DIVISOR, +} from '../../constants'; import { NanoAction } from './instructions'; +import { ITxTemplateInterpreter, IWalletTokenDetails } from './types'; export interface TokenBalance { tokens: OutputValueType; + tokenVersion?: TokenVersion; mint_authorities: number; melt_authorities: number; + /** + * Count of non-authority outputs that is used to calculate the fee + */ + chargeableOutputs: number; + /** + * Count of non-authority inputs that is used to calculate the fee + */ + chargeableInputs: number; } +/** + * Create a new TokenBalance with default values. + */ +function createTokenBalance(tokenVersion?: TokenVersion): TokenBalance { + return { + tokens: 0n, + mint_authorities: 0, + melt_authorities: 0, + chargeableOutputs: 0, + chargeableInputs: 0, + tokenVersion, + }; +} + +/** + * Calculate the fee for a single token balance. + */ +function calculateTokenFee(balance: TokenBalance): bigint { + let fee = 0n; + if (balance.chargeableOutputs > 0) { + fee += BigInt(balance.chargeableOutputs) * FEE_PER_OUTPUT; + } else if (balance.chargeableInputs > 0) { + fee += FEE_PER_OUTPUT; + } + return fee; +} + +type TokenVersionGetter = (token: string) => TokenVersion; + export class TxBalance { balance: Record; createdTokenBalance: null | TokenBalance; - constructor() { + private _getTokenVersion: TokenVersionGetter; + + constructor(getTokenVersion: TokenVersionGetter) { this.balance = {}; this.createdTokenBalance = null; + this._getTokenVersion = getTokenVersion; } /** * Get the current balance of the given token. + * @param token - The token UID */ getTokenBalance(token: string): TokenBalance { if (!this.balance[token]) { - this.balance[token] = { - tokens: 0n, - mint_authorities: 0, - melt_authorities: 0, - }; + const tokenVersion = this._getTokenVersion(token); + this.balance[token] = createTokenBalance(tokenVersion); } - return this.balance[token]; } @@ -49,13 +94,9 @@ export class TxBalance { * Get the current balance of the token being created. * Obs: only valid for create token transactions. */ - getCreatedTokenBalance(): TokenBalance { + getCreatedTokenBalance(tokenVersion: TokenVersion): TokenBalance { if (!this.createdTokenBalance) { - this.createdTokenBalance = { - tokens: 0n, - mint_authorities: 0, - melt_authorities: 0, - }; + this.createdTokenBalance = createTokenBalance(tokenVersion); } return this.createdTokenBalance; } @@ -76,6 +117,8 @@ export class TxBalance { /** * Add balance from utxo of the given transaction. + * @param tx - The transaction containing the UTXO + * @param index - The output index */ addBalanceFromUtxo(tx: IHistoryTx, index: number) { if (tx.outputs.length <= index) { @@ -95,6 +138,10 @@ export class TxBalance { } } else { balance.tokens += output.value; + + if (balance.tokenVersion === TokenVersion.FEE) { + balance.chargeableInputs += 1; + } } this.setTokenBalance(token, balance); @@ -102,24 +149,37 @@ export class TxBalance { /** * Remove the balance given from the token balance. + * @param amount - The amount to subtract + * @param token - The token UID */ addOutput(amount: OutputValueType, token: string) { const balance = this.getTokenBalance(token); balance.tokens -= amount; + + if (balance.tokenVersion === TokenVersion.FEE) { + balance.chargeableOutputs += 1; + } this.setTokenBalance(token, balance); } /** * Remove the balance from the token being created. */ - addCreatedTokenOutput(amount: OutputValueType) { - const balance = this.getCreatedTokenBalance(); + addCreatedTokenOutput(amount: OutputValueType, tokenVersion: TokenVersion) { + const balance = this.getCreatedTokenBalance(tokenVersion); balance.tokens -= amount; + + if (balance.tokenVersion === TokenVersion.FEE) { + balance.chargeableOutputs += 1; + } this.setCreatedTokenBalance(balance); } /** * Remove the specified authority from the balance of the given token. + * @param count - Number of authorities to remove + * @param token - The token UID + * @param authority - The authority type ('mint' or 'melt') */ addOutputAuthority(count: number, token: string, authority: 'mint' | 'melt') { const balance = this.getTokenBalance(token); @@ -135,8 +195,12 @@ export class TxBalance { /** * Remove the authority from the balance of the token being created. */ - addCreatedTokenOutputAuthority(count: number, authority: 'mint' | 'melt') { - const balance = this.getCreatedTokenBalance(); + addCreatedTokenOutputAuthority( + count: number, + authority: 'mint' | 'melt', + tokenVersion: TokenVersion + ) { + const balance = this.getCreatedTokenBalance(tokenVersion); if (authority === 'mint') { balance.mint_authorities -= count; } @@ -145,6 +209,29 @@ export class TxBalance { } this.setCreatedTokenBalance(balance); } + + /** + * Calculate the total fee based on the number of chargeable outputs and inputs for each token in the transaction. + * + * **This method should be used only after the balances has been calculated using the addBalanceFromUtxo, and addOutput methods.** + * + * The fee is determined using the following rules: + * - If a token has one or more chargeable outputs, the fee is calculated as `chargeable_outputs * FEE_PER_OUTPUT`. + * - If a token has zero chargeable outputs but one or more chargeable inputs, a flat fee of `FEE_PER_OUTPUT` is applied. + * @returns the total fee in HTR + */ + calculateFee(): bigint { + let fee = 0n; + + if (this.createdTokenBalance) { + fee += calculateTokenFee(this.createdTokenBalance); + } + + for (const token of Object.keys(this.balance)) { + fee += calculateTokenFee(this.balance[token]); + } + return fee; + } } export class NanoContractContext { @@ -194,6 +281,15 @@ export class TxTemplateContext { tokenVersion?: TokenVersion; + private _fees: Map; + + /** + * Cache of token details fetched during template execution. + * Note: `totalSupply` and `totalTransactions` values may become stale + * as they are cached at the time of the first fetch and not updated. + */ + private _tokenDetails: Map; + vars: Record; _logs: string[]; @@ -208,7 +304,9 @@ export class TxTemplateContext { this.tokens = []; this.version = DEFAULT_TX_VERSION; this.signalBits = 0; - this.balance = new TxBalance(); + this._fees = new Map(); + this._tokenDetails = new Map(); + this.balance = new TxBalance(this.getTokenVersion.bind(this)); this.vars = {}; this._logs = []; this._logger = logger ?? getDefaultLogger(); @@ -230,6 +328,10 @@ export class TxTemplateContext { return this._logs; } + get fees(): Map { + return this._fees; + } + /** * Change the current tx */ @@ -266,14 +368,19 @@ export class TxTemplateContext { /** * Add a token to the transaction and return its token_data. * The token array order will be preserved so the token_data is final. + * Also fetches and caches the token version for later use. * * If the transaction is a CREATE_TOKEN_TX it does not have a token array, * only HTR (token_data=0) and the created token(token_data=1) * + * @param interpreter The interpreter to fetch token details from. * @param token Token UID. * @returns token_data for the requested token. */ - addToken(token: string): number { + async addToken(interpreter: ITxTemplateInterpreter, token: string): Promise { + // Ensure token details are cached + await this.cacheTokenDetails(interpreter, token); + if (token === NATIVE_TOKEN_UID) { return 0; } @@ -290,6 +397,71 @@ export class TxTemplateContext { return this.tokens.length; } + /** + * Cache token details without adding to the tokens array. + * Use this when you need the token version but won't create an output. + * + * @param interpreter The interpreter to fetch token details from. + * @param token Token UID. + */ + async cacheTokenDetails(interpreter: ITxTemplateInterpreter, token: string): Promise { + if (this._tokenDetails.has(token)) { + return; + } + + if (token === NATIVE_TOKEN_UID) { + // Native token has a fixed version and doesn't need to be fetched + this._tokenDetails.set(token, { + totalSupply: 0n, + totalTransactions: 0, + tokenInfo: { + name: 'Hathor', + symbol: 'HTR', + version: TokenVersion.NATIVE, + }, + authorities: { + mint: false, + melt: false, + }, + }); + } else { + const tokenDetails = await interpreter.getTokenDetails(token); + if (tokenDetails.tokenInfo.version == null) { + throw new Error(`Token ${token} does not have version information`); + } + this._tokenDetails.set(token, tokenDetails); + } + } + + /** + * Check if token details are already cached. + * @param token Token UID. + * @returns True if the token details are cached, false otherwise. + */ + hasTokenDetails(token: string): boolean { + return this._tokenDetails.has(token); + } + + /** + * Get the cached token version for a token. + * The token version must have been previously fetched via cacheTokenDetails or addToken. + * @param token Token UID. + * @returns The token version. + * @throws Error if the token details are not cached. + */ + getTokenVersion(token: string): TokenVersion { + const tokenDetails = this._tokenDetails.get(token); + if (tokenDetails?.tokenInfo.version == null) { + const cachedTokens = Array.from(this._tokenDetails.keys()); + throw new Error( + `Token version not found for token ${token}. ` + + `Call cacheTokenDetails or addToken first. ` + + `Currently cached tokens: [${cachedTokens.join(', ') || 'none'}]` + ); + } + return tokenDetails.tokenInfo.version; + } + /** * Add inputs to the context. */ @@ -313,4 +485,19 @@ export class TxTemplateContext { this.outputs.splice(position, 0, ...outputs); } + + /** + * Add a token that will be used to pay fees when the transaction is built. + * @param token token uid + * @param amount amount of the fee in the smallest unit ("cents"). + */ + addFee(token: string, amount: bigint) { + if (token !== NATIVE_TOKEN_UID && amount % BigInt(FEE_DIVISOR)) { + throw new Error( + `Invalid fee amount for token ${token}: ${amount}. Must be a multiple of ${FEE_DIVISOR}` + ); + } + const fee = this._fees.get(token) || 0n; + this._fees.set(token, fee + amount); + } } diff --git a/src/template/transaction/executor.ts b/src/template/transaction/executor.ts index 6af7908ca..47c29ce3c 100644 --- a/src/template/transaction/executor.ts +++ b/src/template/transaction/executor.ts @@ -13,6 +13,7 @@ import { CompleteTxInstruction, ConfigInstruction, DataOutputInstruction, + FeeInstruction, NanoAcquireAuthorityAction, NanoAction, NanoDepositAction, @@ -100,11 +101,35 @@ export function findInstructionExecution( return execSetVarInstruction; case 'nano/execute': return execNanoMethodInstruction; + case 'action/fee': + return execFeeInstruction; default: throw new Error('Cannot determine the instruction to run'); } } +/** + * Execution for FeeInstruction + */ +export async function execFeeInstruction( + interpreter: ITxTemplateInterpreter, + ctx: TxTemplateContext, + ins: z.infer +) { + ctx.log(`Begin FeeInstruction: ${JSONBigInt.stringify(ins)}`); + const { token, amount } = ins; + const tokenUid = getVariable(token, ctx.vars, FeeInstruction.shape.token); + const amountValue = getVariable(amount, ctx.vars, FeeInstruction.shape.amount); + ctx.log(`tokenUid(${tokenUid}), amount(${amountValue})`); + + // If the tokenUid is already in the map, sum with the token amount + if (ctx.fees.has(tokenUid)) { + ctx.fees.set(tokenUid, ctx.fees.get(tokenUid)! + amountValue); + } else { + ctx.fees.set(tokenUid, amountValue); + } +} + /** * Execution for RawInputInstruction */ @@ -121,6 +146,9 @@ export async function execRawInputInstruction( // Find the original transaction from the input const origTx = await interpreter.getTx(txId); + // Cache the tokenVersion via addToken + const { token } = origTx.outputs[index]; + await ctx.cacheTokenDetails(interpreter, token); // Add balance to the ctx.balance ctx.balance.addBalanceFromUtxo(origTx, index); @@ -208,7 +236,7 @@ export async function execAuthoritySelectInstruction( * Execution for RawOutputInstruction */ export async function execRawOutputInstruction( - _interpreter: ITxTemplateInterpreter, + interpreter: ITxTemplateInterpreter, ctx: TxTemplateContext, ins: z.infer ) { @@ -239,19 +267,23 @@ export async function execRawOutputInstruction( ctx.log(`Current transaction is not creating a token.`); throw new Error('Current transaction is not creating a token.'); } + if (!ctx.tokenVersion) { + ctx.log(`Current transaction does not have a token version`); + throw new Error(`Current transaction does not have a token version`); + } tokenData = 1; if (authority) { ctx.log(`Creating authority output`); - ctx.balance.addCreatedTokenOutputAuthority(1, authority); + ctx.balance.addCreatedTokenOutputAuthority(1, authority, ctx.tokenVersion); } else { ctx.log(`Creating token output`); if (amount) { - ctx.balance.addCreatedTokenOutput(amount); + ctx.balance.addCreatedTokenOutput(amount, ctx.tokenVersion); } } } else { // Add token to tokens array - tokenData = ctx.addToken(token); + tokenData = await ctx.addToken(interpreter, token); if (authority) { ctx.log(`Creating authority output`); ctx.balance.addOutputAuthority(1, token, authority); @@ -287,7 +319,7 @@ export async function execRawOutputInstruction( * Execution for DataOutputInstruction */ export async function execDataOutputInstruction( - _interpreter: ITxTemplateInterpreter, + interpreter: ITxTemplateInterpreter, ctx: TxTemplateContext, ins: z.infer ) { @@ -303,13 +335,17 @@ export async function execDataOutputInstruction( ctx.log(`Current transaction is not creating a token.`); throw new Error('Current transaction is not creating a token.'); } + if (!ctx.tokenVersion) { + ctx.log(`Current transaction does not have a token version`); + throw new Error(`Current transaction does not have a token version`); + } ctx.log(`Using created token`); tokenData = 1; - ctx.balance.addCreatedTokenOutput(1n); + ctx.balance.addCreatedTokenOutput(1n, ctx.tokenVersion); } else { ctx.log(`Using token(${token})`); // Add token to tokens array - tokenData = ctx.addToken(token); + tokenData = await ctx.addToken(interpreter, token); ctx.balance.addOutput(1n, token); } @@ -345,13 +381,17 @@ export async function execTokenOutputInstruction( ctx.log(`Current transaction is not creating a token.`); throw new Error('Current transaction is not creating a token.'); } + if (!ctx.tokenVersion) { + ctx.log(`Current transaction does not have a token version`); + throw new Error(`Current transaction does not have a token version`); + } ctx.log(`Using created token`); tokenData = 1; - ctx.balance.addCreatedTokenOutput(amount); + ctx.balance.addCreatedTokenOutput(amount, ctx.tokenVersion); } else { ctx.log(`Using token(${token})`); // Add token to tokens array - tokenData = ctx.addToken(token); + tokenData = await ctx.addToken(interpreter, token); ctx.balance.addOutput(amount, token); } @@ -396,16 +436,20 @@ export async function execAuthorityOutputInstruction( ctx.log(`Current transaction is not creating a token.`); throw new Error('Current transaction is not creating a token.'); } + if (!ctx.tokenVersion) { + ctx.log(`Current transaction does not have a token version`); + throw new Error(`Current transaction does not have a token version`); + } ctx.log(`Using created token`); tokenData = 1; - ctx.balance.addCreatedTokenOutputAuthority(count, authority); + ctx.balance.addCreatedTokenOutputAuthority(count, authority, ctx.tokenVersion); } else { if (!token) { throw new Error(`token is required when trying to add an authority output`); } ctx.log(`Using token(${token})`); // Add token to tokens array - tokenData = ctx.addToken(token); + tokenData = await ctx.addToken(interpreter, token); // Add balance to the ctx.balance ctx.balance.addOutputAuthority(count, token, authority); } @@ -453,6 +497,80 @@ export async function execShuffleInstruction( } } +interface ICompleteTokenInputAndOutputsParams { + ctx: TxTemplateContext; + interpreter: ITxTemplateInterpreter; + tokenUid: string; + skipChange: boolean; + skipSelection: boolean; + changeAddress: string; + changeScript: Buffer; + timelock: number | undefined; + address: string | undefined; +} + +async function completeTokenInputAndOutputs(params: ICompleteTokenInputAndOutputsParams) { + const { + ctx, + interpreter, + tokenUid, + skipChange, + skipSelection, + changeAddress, + changeScript, + timelock, + address, + } = params; + ctx.log(`Completing tx for token ${tokenUid}`); + // Add token to cache version and get tokenData + const tokenData = await ctx.addToken(interpreter, tokenUid); + // Check balances for token. + const balance = ctx.balance.getTokenBalance(tokenUid); + if (balance.tokens > 0 && !skipChange) { + const value = balance.tokens; + // Surplus of token on the inputs, need to add a change output + ctx.log(`Creating a change output for ${value} / ${tokenUid}`); + // Add balance to the ctx.balance + ctx.balance.addOutput(value, tokenUid); + + // Creates an output with the value of the outstanding balance + const output = new Output(value, changeScript, { timelock, tokenData }); + ctx.addOutputs(-1, output); + } else if (balance.tokens < 0 && !skipSelection) { + const value = -balance.tokens; + ctx.log(`Finding inputs for ${value} / ${tokenUid}`); + // Surplus of tokens on the outputs, need to select tokens and add inputs + const options: IGetUtxosOptions = { token: tokenUid }; + if (address) { + options.filter_address = address; + } + const { changeAmount, utxos } = await interpreter.getUtxos(value, options); + + // Add utxos as inputs on the transaction + const inputs: Input[] = []; + for (const utxo of utxos) { + ctx.log(`Found utxo with ${utxo.value} of ${utxo.tokenId}`); + ctx.log(`Create input ${utxo.index} / ${utxo.txId}`); + inputs.push(new Input(utxo.txId, utxo.index)); + // Update balance + const origTx = await interpreter.getTx(utxo.txId); + ctx.balance.addBalanceFromUtxo(origTx, utxo.index); + } + + // Then add inputs to context + ctx.addInputs(-1, ...inputs); + + if (changeAmount) { + ctx.log(`Creating change with ${changeAmount} for address: ${changeAddress}`); + const output = new Output(changeAmount, changeScript, { tokenData }); + ctx.balance.addOutput(changeAmount, tokenUid); + ctx.addOutputs(-1, output); + } + } + + return { balance, tokenData }; +} + /** * Execution for CompleteTxInstruction */ @@ -488,83 +606,59 @@ export async function execCompleteTxInstruction( `changeAddress(${changeAddress}) address(${address}) timelock(${timelock}) token(${token}), calculateFee(${calculateFee}), skipSelection(${skipSelection}), skipChange(${skipChange}), skipAuthorities(${skipAuthorities})` ); - const tokensToCheck: string[] = []; + const tokensToCheck: Set = new Set(); if (token) { - tokensToCheck.push(token); + tokensToCheck.add(token); } else { // Check HTR and all tokens on the transaction - tokensToCheck.push(NATIVE_TOKEN_UID); + tokensToCheck.add(NATIVE_TOKEN_UID); ctx.tokens.forEach(tk => { - tokensToCheck.push(tk); + tokensToCheck.add(tk); }); } - // calculate token creation fee + // calculate token creation deposit if (calculateFee && ctx.isCreateTokenTxContext()) { - // INFO: Currently fees only make sense for create token transactions. + ctx.log(`Calculating token creation fee`); + let deposit = 0n; - const amount = ctx.balance.createdTokenBalance!.tokens; - const deposit = interpreter.getHTRDeposit(amount); + if (ctx.tokenVersion === TokenVersion.DEPOSIT) { + const amount = ctx.balance.createdTokenBalance!.tokens; + deposit = interpreter.getHTRDeposit(amount); + } // Add the required HTR to create the tokens + await ctx.addToken(interpreter, NATIVE_TOKEN_UID); const balance = ctx.balance.getTokenBalance(NATIVE_TOKEN_UID); balance.tokens += deposit; ctx.balance.setTokenBalance(NATIVE_TOKEN_UID, balance); // If we weren't going to check HTR, we need to include in the tokens to check - if (!tokensToCheck.includes(NATIVE_TOKEN_UID)) { - tokensToCheck.push(NATIVE_TOKEN_UID); + if (!tokensToCheck.has(NATIVE_TOKEN_UID)) { + tokensToCheck.add(NATIVE_TOKEN_UID); } } const changeScript = createOutputScriptFromAddress(changeAddress, interpreter.getNetwork()); - for (const tokenUid of tokensToCheck) { - ctx.log(`Completing tx for token ${tokenUid}`); - // Check balances for token. - const balance = ctx.balance.getTokenBalance(tokenUid); - const tokenData = ctx.addToken(tokenUid); - if (balance.tokens > 0 && !skipChange) { - const value = balance.tokens; - // Surplus of token on the inputs, need to add a change output - ctx.log(`Creating a change output for ${value} / ${tokenUid}`); - // Add balance to the ctx.balance - ctx.balance.addOutput(value, tokenUid); - - // Creates an output with the value of the outstanding balance - const output = new Output(value, changeScript, { timelock, tokenData }); - ctx.addOutputs(-1, output); - } else if (balance.tokens < 0 && !skipSelection) { - const value = -balance.tokens; - ctx.log(`Finding inputs for ${value} / ${tokenUid}`); - // Surplus of tokens on the outputs, need to select tokens and add inputs - const options: IGetUtxosOptions = { token: tokenUid }; - if (address) { - options.filter_address = address; - } - const { changeAmount, utxos } = await interpreter.getUtxos(value, options); - - // Add utxos as inputs on the transaction - const inputs: Input[] = []; - for (const utxo of utxos) { - ctx.log(`Found utxo with ${utxo.value} of ${utxo.tokenId}`); - ctx.log(`Create input ${utxo.index} / ${utxo.txId}`); - inputs.push(new Input(utxo.txId, utxo.index)); - // Update balance - const origTx = await interpreter.getTx(utxo.txId); - ctx.balance.addBalanceFromUtxo(origTx, utxo.index); - } - - // Then add inputs to context - ctx.addInputs(-1, ...inputs); + // We remove the HTR from the tokens to check to call the getUtxos for it only once. + const shouldCheckHTR = tokensToCheck.has(NATIVE_TOKEN_UID); + if (shouldCheckHTR) { + tokensToCheck.delete(NATIVE_TOKEN_UID); + } - if (changeAmount) { - ctx.log(`Creating change with ${changeAmount} for address: ${changeAddress}`); - const output = new Output(changeAmount, changeScript, { tokenData }); - ctx.balance.addOutput(changeAmount, tokenUid); - ctx.addOutputs(-1, output); - } - } + for (const tokenUid of tokensToCheck) { + const { balance, tokenData } = await completeTokenInputAndOutputs({ + ctx, + interpreter, + tokenUid, + skipChange, + skipSelection, + changeAddress, + changeScript, + timelock, + address, + }); // Skip authority blocks if we wish to not include authority completion. if (skipAuthorities) { @@ -649,6 +743,32 @@ export async function execCompleteTxInstruction( ctx.addInputs(-1, ...inputs); } } + + // fetch all the tokens data from the wallet-api + let fee = 0n; + if (calculateFee) { + fee = ctx.balance.calculateFee(); + if (fee > 0n) { + await ctx.addToken(interpreter, NATIVE_TOKEN_UID); + ctx.balance.addOutput(fee, NATIVE_TOKEN_UID); + ctx.addFee(NATIVE_TOKEN_UID, fee); + } + } + + // If we need to check HTR or the fee is greater than 0, we need to complete the token input and outputs + if (shouldCheckHTR || fee > 0n) { + await completeTokenInputAndOutputs({ + ctx, + interpreter, + tokenUid: NATIVE_TOKEN_UID, + skipChange, + skipSelection, + changeAddress, + changeScript, + timelock, + address, + }); + } } /** @@ -780,7 +900,7 @@ async function validateDepositNanoAction( action: z.infer ) { const token = getVariable(action.token, ctx.vars, NanoDepositAction.shape.token); - ctx.addToken(token); + await ctx.addToken(interpreter, token); const amount = getVariable( action.amount, ctx.vars, @@ -832,7 +952,7 @@ async function validateWithdrawalNanoAction( action: z.infer ) { const token = getVariable(action.token, ctx.vars, NanoWithdrawalAction.shape.token); - ctx.addToken(token); + await ctx.addToken(interpreter, token); const amount = getVariable( action.amount, ctx.vars, @@ -851,7 +971,7 @@ async function validateWithdrawalNanoAction( }; if (!action.skipOutputs) { - const tokenData = ctx.addToken(token); + const tokenData = await ctx.addToken(interpreter, token); const script = createOutputScriptFromAddress(address, interpreter.getNetwork()); const output = new Output(amount, script, { tokenData }); ctx.addOutputs(-1, output); @@ -869,7 +989,7 @@ async function validateGrantAuthorityNanoAction( action: z.infer ) { const token = getVariable(action.token, ctx.vars, NanoGrantAuthorityAction.shape.token); - ctx.addToken(token); + await ctx.addToken(interpreter, token); const { authority } = action; const address = getVariable( action.address, @@ -919,7 +1039,7 @@ async function validateAcquireAuthorityNanoAction( action: z.infer ) { const token = getVariable(action.token, ctx.vars, NanoAcquireAuthorityAction.shape.token); - ctx.addToken(token); + await ctx.addToken(interpreter, token); const address = getVariable( action.address, @@ -936,7 +1056,7 @@ async function validateAcquireAuthorityNanoAction( }; if (!action.skipOutputs) { - const tokenData = TOKEN_AUTHORITY_MASK | ctx.addToken(token); + const tokenData = TOKEN_AUTHORITY_MASK | (await ctx.addToken(interpreter, token)); let amount: OutputValueType; if (action.authority === 'mint') { amount = TOKEN_MINT_MASK; diff --git a/src/template/transaction/instructions.ts b/src/template/transaction/instructions.ts index 677d9b115..5a86dd25d 100644 --- a/src/template/transaction/instructions.ts +++ b/src/template/transaction/instructions.ts @@ -173,7 +173,7 @@ export const CompleteTxInstruction = z.object({ skipSelection: z.boolean().default(false), // do NOT add inputs to the tx skipChange: z.boolean().default(false), // do NOT add outputs from outstanding tokens. skipAuthorities: z.boolean().default(false), // Only select tokens - calculateFee: z.boolean().default(false), // For token creation + calculateFee: z.boolean().default(false), // For token creation and transactions with fee based tokens }); export const ConfigInstruction = z.object({ @@ -278,6 +278,12 @@ export const NanoMethodInstruction = z.object({ actions: NanoAction.array().default([]), }); +export const FeeInstruction = z.object({ + type: z.literal('action/fee'), + token: TemplateRef.or(TokenSchema.default(NATIVE_TOKEN_UID)), + amount: TemplateRef.or(AmountSchema), +}); + export const TxTemplateInstruction = z.discriminatedUnion('type', [ RawInputInstruction, UtxoSelectInstruction, @@ -291,6 +297,7 @@ export const TxTemplateInstruction = z.discriminatedUnion('type', [ ConfigInstruction, SetVarInstruction, NanoMethodInstruction, + FeeInstruction, ]); export const TransactionTemplate = z.array(TxTemplateInstruction); diff --git a/src/template/transaction/interpreter.ts b/src/template/transaction/interpreter.ts index 8d6094bb4..ec408ecb1 100644 --- a/src/template/transaction/interpreter.ts +++ b/src/template/transaction/interpreter.ts @@ -16,8 +16,9 @@ import { IGetUtxoResponse, IWalletBalanceData, TxInstance, + IWalletTokenDetails, } from './types'; -import { IHistoryTx, OutputValueType } from '../../types'; +import { IFeeEntry, IHistoryTx, OutputValueType } from '../../types'; import { IHathorWallet, Utxo } from '../../wallet/types'; import Transaction from '../../models/transaction'; import Address from '../../models/address'; @@ -38,6 +39,7 @@ import NanoContractHeader from '../../nano_contracts/header'; import { ActionTypeToActionHeaderType, NanoContractActionHeader } from '../../nano_contracts/types'; import { validateAndParseBlueprintMethodArgs } from '../../nano_contracts/utils'; import type Header from '../../headers/base'; +import FeeHeader from '../../headers/fee'; export class WalletTxTemplateInterpreter implements ITxTemplateInterpreter { wallet: HathorWallet; @@ -153,6 +155,17 @@ export class WalletTxTemplateInterpreter implements ITxTemplateInterpreter { ); } + private static buildFeeHeader(ctx: TxTemplateContext): FeeHeader { + const entries: IFeeEntry[] = Array.from(ctx.fees.entries()).map(([tokenUid, amount]) => ({ + tokenIndex: tokensUtils.getTokenIndex( + ctx.tokens.map(t => ({ uid: t })), + tokenUid + ), + amount, + })); + return new FeeHeader(entries); + } + async build( instructions: z.infer, debug: boolean = false @@ -169,6 +182,11 @@ export class WalletTxTemplateInterpreter implements ITxTemplateInterpreter { headers.push(nanoHeader); } + if (context.fees.size > 0) { + const feeHeader = WalletTxTemplateInterpreter.buildFeeHeader(context); + headers.push(feeHeader); + } + if (context.version === DEFAULT_TX_VERSION) { return new Transaction(context.inputs, context.outputs, { signalBits: context.signalBits, @@ -276,4 +294,8 @@ export class WalletTxTemplateInterpreter implements ITxTemplateInterpreter { getHTRDeposit(mintAmount: OutputValueType): OutputValueType { return tokensUtils.getMintDeposit(mintAmount, this.wallet.storage); } + + async getTokenDetails(token: string): Promise { + return this.wallet.getTokenDetails(token); + } } diff --git a/src/template/transaction/types.ts b/src/template/transaction/types.ts index 07ca30c0d..53e59e7c0 100644 --- a/src/template/transaction/types.ts +++ b/src/template/transaction/types.ts @@ -8,7 +8,7 @@ import { z } from 'zod'; import { TransactionTemplate } from './instructions'; import { TxTemplateContext } from './context'; -import { IHistoryTx, ITokenBalance, ITokenData, OutputValueType } from '../../types'; +import { IHistoryTx, ITokenBalance, ITokenData, OutputValueType, TokenVersion } from '../../types'; import Transaction from '../../models/transaction'; import Network from '../../models/network'; import { IHathorWallet, Utxo } from '../../wallet/types'; @@ -50,6 +50,20 @@ export interface IWalletBalanceData { }; } +export interface IWalletTokenDetails { + totalSupply: bigint; + totalTransactions: number; + tokenInfo: { + name: string; + symbol: string; + version?: TokenVersion; + }; + authorities: { + mint: boolean; + melt: boolean; + }; +} + export interface ITxTemplateInterpreter { build(instructions: z.infer, debug: boolean): Promise; getAddress(markAsUsed?: boolean): Promise; @@ -62,4 +76,5 @@ export interface ITxTemplateInterpreter { getNetwork(): Network; getWallet(): IHathorWallet; getHTRDeposit(mintAmount: OutputValueType): OutputValueType; + getTokenDetails(token: string): Promise; } diff --git a/src/template/transaction/utils.ts b/src/template/transaction/utils.ts index e59af456d..fcd3e83f1 100644 --- a/src/template/transaction/utils.ts +++ b/src/template/transaction/utils.ts @@ -26,6 +26,7 @@ export async function selectTokens( position: number = -1 ) { const token = options.token ?? NATIVE_TOKEN_UID; + await ctx.cacheTokenDetails(interpreter, token); const { changeAmount, utxos } = await interpreter.getUtxos(amount, options); // Add utxos as inputs on the transaction @@ -47,7 +48,7 @@ export async function selectTokens( if (autoChange && changeAmount) { ctx.log(`Creating change for address: ${changeAddress}`); // Token should only be on the array if present on the outputs - const tokenData = ctx.addToken(token); + const tokenData = await ctx.addToken(interpreter, token); const script = createOutputScriptFromAddress(changeAddress, interpreter.getNetwork()); const output = new Output(changeAmount, script, { tokenData }); ctx.balance.addOutput(changeAmount, token); @@ -66,6 +67,8 @@ export async function selectAuthorities( position: number = -1 ) { const token = options.token ?? NATIVE_TOKEN_UID; + // Only cache the token version (no outputs created here) + await ctx.cacheTokenDetails(interpreter, token); const utxos = await interpreter.getAuthorities(count, options); // Add utxos as inputs on the transaction