Skip to content

Commit 29238ac

Browse files
committed
fix: token details caching issues
1 parent 83e7b9d commit 29238ac

File tree

5 files changed

+302
-36
lines changed

5 files changed

+302
-36
lines changed

__tests__/template/transaction/executor.test.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -393,9 +393,6 @@ const AuthoritySelectExecutorTest = async executor => {
393393
expect(ctx.inputs[0].index).toStrictEqual(0);
394394

395395
expect(ctx.outputs).toHaveLength(0);
396-
// Token is now added to the list when addToken is called (to cache tokenVersion)
397-
expect(ctx.tokens).toHaveLength(1);
398-
expect(ctx.tokens[0]).toStrictEqual(token);
399396

400397
expect(Object.keys(ctx.balance.balance)).toHaveLength(1);
401398
expect(ctx.balance.balance[token]).toMatchObject({
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
/**
2+
* Copyright (c) Hathor Labs and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import { selectTokens, selectAuthorities } from '../../../src/template/transaction/utils';
9+
import { TxTemplateContext } from '../../../src/template/transaction/context';
10+
import { getDefaultLogger } from '../../../src/types';
11+
import { NATIVE_TOKEN_UID } from '../../../src/constants';
12+
import Network from '../../../src/models/network';
13+
import { ITxTemplateInterpreter } from '../../../src/template/transaction/types';
14+
15+
const DEBUG = false;
16+
17+
const address = 'WYiD1E8n5oB9weZ8NMyM3KoCjKf1KCjWAZ';
18+
const token = '0000000110eb9ec96e255a09d6ae7d856bff53453773bae5500cee2905db670e';
19+
const txId = '0000000110eb9ec96e255a09d6ae7d856bff53453773bae5500cee2905db670e';
20+
21+
const mockTokenDetails = {
22+
totalSupply: 1000n,
23+
totalTransactions: 1,
24+
tokenInfo: {
25+
name: 'TestToken',
26+
symbol: 'TST',
27+
version: 1,
28+
},
29+
authorities: {
30+
mint: true,
31+
melt: true,
32+
},
33+
};
34+
35+
const createMockInterpreter = (changeAmount: bigint, utxos: unknown[]) => ({
36+
getNetwork: jest.fn().mockReturnValue(new Network('testnet')),
37+
getTokenDetails: jest.fn().mockResolvedValue(mockTokenDetails),
38+
getTx: jest.fn().mockResolvedValue({
39+
outputs: [
40+
{
41+
value: 100n,
42+
token,
43+
token_data: 1,
44+
},
45+
],
46+
}),
47+
getUtxos: jest.fn().mockResolvedValue({
48+
changeAmount,
49+
utxos,
50+
}),
51+
getAuthorities: jest.fn().mockResolvedValue(utxos),
52+
});
53+
54+
describe('selectTokens', () => {
55+
describe('token array behavior - tokens should only be in array when outputs are created', () => {
56+
it('should add token to array when autoChange=true and changeAmount > 0', async () => {
57+
const interpreter = createMockInterpreter(10n, [
58+
{ txId, index: 0, tokenId: token, address, value: 100n, authorities: 0n },
59+
]);
60+
const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG);
61+
62+
await selectTokens(
63+
interpreter,
64+
ctx,
65+
90n,
66+
{ token },
67+
true, // autoChange
68+
address
69+
);
70+
71+
// Token should be in array because a change output was created
72+
expect(ctx.tokens).toHaveLength(1);
73+
expect(ctx.tokens[0]).toBe(token);
74+
expect(ctx.outputs).toHaveLength(1);
75+
expect(ctx.outputs[0].value).toBe(10n);
76+
});
77+
78+
it('should NOT add token to array when autoChange=false (even with changeAmount)', async () => {
79+
const interpreter = createMockInterpreter(10n, [
80+
{ txId, index: 0, tokenId: token, address, value: 100n, authorities: 0n },
81+
]);
82+
const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG);
83+
84+
await selectTokens(
85+
interpreter,
86+
ctx,
87+
90n,
88+
{ token },
89+
false, // autoChange = false
90+
address
91+
);
92+
93+
// Token should NOT be in array because no output was created
94+
expect(ctx.tokens).toHaveLength(0);
95+
expect(ctx.outputs).toHaveLength(0);
96+
// But token details should be cached
97+
expect(ctx.getTokenVersion(token)).toBe(1);
98+
});
99+
100+
it('should NOT add token to array when changeAmount is 0', async () => {
101+
const interpreter = createMockInterpreter(0n, [
102+
{ txId, index: 0, tokenId: token, address, value: 100n, authorities: 0n },
103+
]);
104+
const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG);
105+
106+
await selectTokens(
107+
interpreter,
108+
ctx,
109+
100n, // exact amount, no change needed
110+
{ token },
111+
true, // autoChange
112+
address
113+
);
114+
115+
// Token should NOT be in array because no change output was created
116+
expect(ctx.tokens).toHaveLength(0);
117+
expect(ctx.outputs).toHaveLength(0);
118+
// But token details should be cached
119+
expect(ctx.getTokenVersion(token)).toBe(1);
120+
});
121+
122+
it('should NOT add token to array when no UTXOs are found', async () => {
123+
const interpreter = createMockInterpreter(0n, []); // No UTXOs
124+
const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG);
125+
126+
await selectTokens(interpreter, ctx, 100n, { token }, true, address);
127+
128+
// Token should NOT be in array because no inputs/outputs were created
129+
expect(ctx.tokens).toHaveLength(0);
130+
expect(ctx.inputs).toHaveLength(0);
131+
expect(ctx.outputs).toHaveLength(0);
132+
// But token details should be cached
133+
expect(ctx.getTokenVersion(token)).toBe(1);
134+
});
135+
136+
it('should cache token details even when token is not added to array', async () => {
137+
const interpreter = createMockInterpreter(0n, [
138+
{ txId, index: 0, tokenId: token, address, value: 100n, authorities: 0n },
139+
]);
140+
const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG);
141+
142+
await selectTokens(interpreter, ctx, 100n, { token }, false, address);
143+
144+
// Token not in array
145+
expect(ctx.tokens).toHaveLength(0);
146+
// But getTokenVersion should work (details are cached)
147+
expect(() => ctx.getTokenVersion(token)).not.toThrow();
148+
expect(ctx.getTokenVersion(token)).toBe(1);
149+
});
150+
});
151+
152+
describe('HTR (native token) behavior', () => {
153+
it('should handle HTR correctly - never added to tokens array', async () => {
154+
const interpreter = createMockInterpreter(10n, [
155+
{ txId, index: 0, tokenId: NATIVE_TOKEN_UID, address, value: 100n, authorities: 0n },
156+
]) as unknown as ITxTemplateInterpreter;
157+
// Override getTx for HTR
158+
interpreter.getTx = jest.fn().mockResolvedValue({
159+
outputs: [{ value: 100n, token: NATIVE_TOKEN_UID, token_data: 0 }],
160+
});
161+
162+
const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG);
163+
164+
await selectTokens(interpreter, ctx, 90n, { token: NATIVE_TOKEN_UID }, true, address);
165+
166+
// HTR should never be in the tokens array (token_data=0 is implicit)
167+
expect(ctx.tokens).toHaveLength(0);
168+
// But an output should be created
169+
expect(ctx.outputs).toHaveLength(1);
170+
expect(ctx.outputs[0].tokenData).toBe(0);
171+
});
172+
});
173+
174+
describe('inputs are correctly added', () => {
175+
it('should add inputs from UTXOs', async () => {
176+
const interpreter = createMockInterpreter(0n, [
177+
{ txId, index: 0, tokenId: token, address, value: 50n, authorities: 0n },
178+
{ txId, index: 1, tokenId: token, address, value: 50n, authorities: 0n },
179+
]);
180+
// Override getTx to return two outputs
181+
interpreter.getTx = jest.fn().mockResolvedValue({
182+
outputs: [
183+
{ value: 50n, token, token_data: 1 },
184+
{ value: 50n, token, token_data: 1 },
185+
],
186+
});
187+
const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG);
188+
189+
await selectTokens(interpreter, ctx, 100n, { token }, false, address);
190+
191+
expect(ctx.inputs).toHaveLength(2);
192+
expect(ctx.inputs[0].hash).toBe(txId);
193+
expect(ctx.inputs[0].index).toBe(0);
194+
expect(ctx.inputs[1].hash).toBe(txId);
195+
expect(ctx.inputs[1].index).toBe(1);
196+
});
197+
});
198+
});
199+
200+
describe('selectAuthorities', () => {
201+
describe('token array behavior - authorities should NEVER add token to array', () => {
202+
it('should NOT add token to array when selecting authorities', async () => {
203+
const interpreter = createMockInterpreter(0n, [
204+
{ txId, index: 0, tokenId: token, address, value: 1n, authorities: 1n },
205+
]);
206+
const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG);
207+
208+
await selectAuthorities(interpreter, ctx, { token, authorities: 1n }, 1);
209+
210+
// Token should NOT be in array - selectAuthorities never creates outputs
211+
expect(ctx.tokens).toHaveLength(0);
212+
expect(ctx.outputs).toHaveLength(0);
213+
// But token details should be cached
214+
expect(ctx.getTokenVersion(token)).toBe(1);
215+
});
216+
217+
it('should add inputs from authority UTXOs', async () => {
218+
const interpreter = createMockInterpreter(0n, [
219+
{ txId, index: 0, tokenId: token, address, value: 1n, authorities: 1n },
220+
]);
221+
const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG);
222+
223+
await selectAuthorities(interpreter, ctx, { token, authorities: 1n }, 1);
224+
225+
expect(ctx.inputs).toHaveLength(1);
226+
expect(ctx.inputs[0].hash).toBe(txId);
227+
expect(ctx.inputs[0].index).toBe(0);
228+
});
229+
230+
it('should cache token details even though token is not in array', async () => {
231+
const interpreter = createMockInterpreter(0n, [
232+
{ txId, index: 0, tokenId: token, address, value: 1n, authorities: 1n },
233+
]);
234+
const ctx = new TxTemplateContext(getDefaultLogger(), DEBUG);
235+
236+
await selectAuthorities(interpreter, ctx, { token, authorities: 1n }, 1);
237+
238+
// getTokenVersion should work
239+
expect(() => ctx.getTokenVersion(token)).not.toThrow();
240+
expect(ctx.getTokenVersion(token)).toBe(1);
241+
});
242+
});
243+
});

src/template/transaction/context.ts

Lines changed: 55 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -378,31 +378,8 @@ export class TxTemplateContext {
378378
* @returns token_data for the requested token.
379379
*/
380380
async addToken(interpreter: ITxTemplateInterpreter, token: string): Promise<number> {
381-
// Fetch and cache the token details if not already cached
382-
if (!this._tokenDetails.has(token)) {
383-
if (token === NATIVE_TOKEN_UID) {
384-
// Native token has a fixed version and doesn't need to be fetched
385-
this._tokenDetails.set(token, {
386-
totalSupply: 0n,
387-
totalTransactions: 0,
388-
tokenInfo: {
389-
name: 'Hathor',
390-
symbol: 'HTR',
391-
version: TokenVersion.NATIVE,
392-
},
393-
authorities: {
394-
mint: false,
395-
melt: false,
396-
},
397-
});
398-
} else {
399-
const tokenDetails = await interpreter.getTokenDetails(token);
400-
if (tokenDetails.tokenInfo.version == null) {
401-
throw new Error(`Token ${token} does not have version information`);
402-
}
403-
this._tokenDetails.set(token, tokenDetails);
404-
}
405-
}
381+
// Ensure token details are cached
382+
await this.cacheTokenDetails(interpreter, token);
406383

407384
if (token === NATIVE_TOKEN_UID) {
408385
return 0;
@@ -420,17 +397,67 @@ export class TxTemplateContext {
420397
return this.tokens.length;
421398
}
422399

400+
/**
401+
* Cache token details without adding to the tokens array.
402+
* Use this when you need the token version but won't create an output.
403+
*
404+
* @param interpreter The interpreter to fetch token details from.
405+
* @param token Token UID.
406+
*/
407+
async cacheTokenDetails(interpreter: ITxTemplateInterpreter, token: string): Promise<void> {
408+
if (this._tokenDetails.has(token)) {
409+
return;
410+
}
411+
412+
if (token === NATIVE_TOKEN_UID) {
413+
// Native token has a fixed version and doesn't need to be fetched
414+
this._tokenDetails.set(token, {
415+
totalSupply: 0n,
416+
totalTransactions: 0,
417+
tokenInfo: {
418+
name: 'Hathor',
419+
symbol: 'HTR',
420+
version: TokenVersion.NATIVE,
421+
},
422+
authorities: {
423+
mint: false,
424+
melt: false,
425+
},
426+
});
427+
} else {
428+
const tokenDetails = await interpreter.getTokenDetails(token);
429+
if (tokenDetails.tokenInfo.version == null) {
430+
throw new Error(`Token ${token} does not have version information`);
431+
}
432+
this._tokenDetails.set(token, tokenDetails);
433+
}
434+
}
435+
436+
/**
437+
* Check if token details are already cached.
438+
* @param token Token UID.
439+
* @returns True if the token details are cached, false otherwise.
440+
*/
441+
hasTokenDetails(token: string): boolean {
442+
return this._tokenDetails.has(token);
443+
}
444+
423445
/**
424446
* Get the cached token version for a token.
425-
* The token version must have been previously fetched via addToken.
447+
* The token version must have been previously fetched via cacheTokenDetails or addToken.
426448
* @param token Token UID.
427449
* @returns The token version.
428-
* @throws Error if the token details are not cached (addToken was not called).
450+
* @throws Error if the token details are not cached.
429451
*/
430452
getTokenVersion(token: string): TokenVersion {
431453
const tokenDetails = this._tokenDetails.get(token);
432454
if (tokenDetails?.tokenInfo.version == null) {
433-
throw new Error(`Token version not found for token ${token}. Call addToken first.`);
455+
const cachedTokens = Array.from(this._tokenDetails.keys());
456+
throw new Error(
457+
`Token version not found for token ${token}. ` +
458+
`Call cacheTokenDetails or addToken first. ` +
459+
`Currently cached tokens: [${cachedTokens.join(', ') || 'none'}]`
460+
);
434461
}
435462
return tokenDetails.tokenInfo.version;
436463
}

src/template/transaction/executor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ export async function execRawInputInstruction(
148148
const origTx = await interpreter.getTx(txId);
149149
// Cache the tokenVersion via addToken
150150
const { token } = origTx.outputs[index];
151-
await ctx.addToken(interpreter, token);
151+
await ctx.cacheTokenDetails(interpreter, token);
152152
// Add balance to the ctx.balance
153153
ctx.balance.addBalanceFromUtxo(origTx, index);
154154

src/template/transaction/utils.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@ export async function selectTokens(
2626
position: number = -1
2727
) {
2828
const token = options.token ?? NATIVE_TOKEN_UID;
29-
// Cache the token version via addToken
30-
await ctx.addToken(interpreter, token);
29+
await ctx.cacheTokenDetails(interpreter, token);
3130
const { changeAmount, utxos } = await interpreter.getUtxos(amount, options);
3231

3332
// Add utxos as inputs on the transaction
@@ -68,8 +67,8 @@ export async function selectAuthorities(
6867
position: number = -1
6968
) {
7069
const token = options.token ?? NATIVE_TOKEN_UID;
71-
// Cache the token version via addToken
72-
await ctx.addToken(interpreter, token);
70+
// Only cache the token version (no outputs created here)
71+
await ctx.cacheTokenDetails(interpreter, token);
7372
const utxos = await interpreter.getAuthorities(count, options);
7473

7574
// Add utxos as inputs on the transaction

0 commit comments

Comments
 (0)