Skip to content

Commit dfddf95

Browse files
committed
fix: prepareTx choose inputs with fee
1 parent a7f7e7d commit dfddf95

File tree

6 files changed

+125
-42
lines changed

6 files changed

+125
-42
lines changed

__tests__/integration/hathorwallet_facade.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1603,6 +1603,67 @@ describe('sendTransaction', () => {
16031603
).toHaveProperty('numTransactions', 1);
16041604
});
16051605

1606+
it('should send fee token with manually provided HTR input (no HTR output)', async () => {
1607+
const hWallet = await generateWalletHelper();
1608+
await GenesisWalletHelper.injectFunds(hWallet, await hWallet.getAddressAtIndex(0), 10n);
1609+
const { hash: tokenUid } = await createTokenHelper(
1610+
hWallet,
1611+
'FeeTokenManualInput',
1612+
'FTMI',
1613+
100n,
1614+
{
1615+
tokenVersion: TokenVersion.FEE,
1616+
}
1617+
);
1618+
1619+
// Get UTXOs for both HTR and the fee token
1620+
const { utxos: utxosHtr } = await hWallet.getUtxos({ token: NATIVE_TOKEN_UID });
1621+
const { utxos: utxosToken } = await hWallet.getUtxos({ token: tokenUid });
1622+
1623+
// Get the first UTXO of each token
1624+
const htrUtxo = utxosHtr[0];
1625+
const tokenUtxo = utxosToken[0];
1626+
1627+
// Send transaction with manually provided inputs (HTR + token) and only token output
1628+
// This tests the scenario where user provides HTR input to pay for fee
1629+
// but has no HTR output (only token output)
1630+
const tx = await hWallet.sendManyOutputsTransaction(
1631+
[
1632+
{
1633+
address: await hWallet.getAddressAtIndex(5),
1634+
value: 50n,
1635+
token: tokenUid,
1636+
},
1637+
],
1638+
{
1639+
inputs: [
1640+
{ txId: htrUtxo.tx_id, index: htrUtxo.index },
1641+
{ txId: tokenUtxo.tx_id, index: tokenUtxo.index },
1642+
],
1643+
}
1644+
);
1645+
1646+
validateFeeAmount(tx.headers, 2n);
1647+
await waitForTxReceived(hWallet, tx.hash);
1648+
1649+
// Validate the transaction was created correctly
1650+
const decodedTx = await hWallet.getTx(tx.hash);
1651+
1652+
// Should have 2 inputs (HTR + token)
1653+
expect(decodedTx.inputs).toHaveLength(2);
1654+
expect(decodedTx.inputs).toContainEqual(
1655+
expect.objectContaining({ tx_id: htrUtxo.tx_id, index: htrUtxo.index })
1656+
);
1657+
expect(decodedTx.inputs).toContainEqual(
1658+
expect.objectContaining({ tx_id: tokenUtxo.tx_id, index: tokenUtxo.index })
1659+
);
1660+
1661+
// Should have outputs: token output (50) + token change (50) + HTR change
1662+
expect(decodedTx.outputs).toContainEqual(
1663+
expect.objectContaining({ value: 50n, token: tokenUid })
1664+
);
1665+
});
1666+
16061667
it('should send a multisig transaction', async () => {
16071668
// Initialize 3 wallets from the same multisig and inject funds in them to test
16081669
const mhWallet1 = await generateMultisigWalletHelper({ walletIndex: 0 });

src/errors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ export class UtxoError extends WalletError {}
207207
export class SendTxError extends WalletError {
208208
// XXX: There are only two out of dozens of places where this object is used instead of a string.
209209
// This should be made consistently for strings
210-
errorData: string | { txId: string; index: number } = '';
210+
errorData: string | { txId: string; index: number } | { txId: string; index: number }[] = '';
211211
}
212212

213213
/**

src/new/sendTransaction.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,8 @@ export default class SendTransaction extends EventEmitter {
198198
}
199199
}
200200

201+
const requiresFees: { txId: string; index: number }[] = [];
202+
201203
for (const input of this.inputs) {
202204
const inputTx = await this.storage.getTx(input.txId);
203205
if (inputTx === null || !inputTx.outputs[input.index]) {
@@ -207,10 +209,15 @@ export default class SendTransaction extends EventEmitter {
207209
}
208210
const spentOut = inputTx.outputs[input.index];
209211
if (!tokenMap.has(spentOut.token)) {
210-
// The input select is from a token that is not in the outputs
211-
const err = new SendTxError(ErrorMessages.INVALID_INPUT);
212-
err.errorData = { txId: input.txId, index: input.index };
213-
throw err;
212+
// the inputs should be used to pay fees, otherwise it's an invalid input and it will raise an error after the fee is calculated
213+
if (HTR_UID === spentOut.token) {
214+
requiresFees.push({ txId: input.txId, index: input.index });
215+
} else {
216+
// The input select is from a token that is not in the outputs
217+
const err = new SendTxError(ErrorMessages.INVALID_INPUT);
218+
err.errorData = { txId: input.txId, index: input.index };
219+
throw err;
220+
}
214221
}
215222
tokenMap.set(spentOut.token, false);
216223
txData.inputs.push({
@@ -223,7 +230,12 @@ export default class SendTransaction extends EventEmitter {
223230
});
224231
}
225232

226-
const shouldChooseHTRInputs = tokenMap.get(HTR_UID) || false;
233+
// If the user provided HTR inputs, tokenMap.get(HTR_UID) will be false
234+
// In that case, we should NOT choose inputs automatically (accept what user provided)
235+
// Otherwise (true or undefined), we should choose HTR inputs if needed for fee
236+
const tokenMapHasHTR = tokenMap.has(HTR_UID);
237+
let shouldChooseHTRInputs = tokenMap.get(HTR_UID) || false;
238+
227239
// we remove HTR from the tokenMap since we will calculate the fee based on the inputs and outputs
228240
// and we don't want to select inputs for HTR before that
229241
tokenMap.delete(HTR_UID);
@@ -245,15 +257,25 @@ export default class SendTransaction extends EventEmitter {
245257
partialOutputs,
246258
await tokens.getTokensByManyIds(this.storage, new Set(tokenMap.keys()))
247259
);
260+
261+
if (requiresFees.length > 0 && fee === 0n) {
262+
const err = new SendTxError(ErrorMessages.INVALID_INPUT);
263+
err.errorData = requiresFees;
264+
throw err;
265+
}
266+
248267
const headers: Header[] = [];
249268
if (fee > 0) {
250269
headers.push(new FeeHeader([{ tokenIndex: 0, amount: fee }]));
270+
// if any HTR input or output was provided, we need to choose inputs for HTR
271+
if (!tokenMapHasHTR) {
272+
shouldChooseHTRInputs = true;
273+
}
251274
}
252275

253-
// We only need to grab HTR inputs if they weren't provided or tha tx has a fee
254276
const options: IUtxoSelectionOptions = {
255277
token: HTR_UID,
256-
chooseInputs: shouldChooseHTRInputs || fee > 0,
278+
chooseInputs: shouldChooseHTRInputs,
257279
};
258280

259281
if (this.changeAddress) {

src/storage/memory_store.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import {
2828
} from '../types';
2929
import { GAP_LIMIT, NATIVE_TOKEN_UID } from '../constants';
3030
import transactionUtils from '../utils/transaction';
31-
import tokens from '../utils/tokens';
3231

3332
const DEFAULT_ADDRESSES_WALLET_DATA = {
3433
lastLoadedAddressIndex: 0,

src/utils/fee.ts

Lines changed: 29 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { FEE_PER_OUTPUT, NATIVE_TOKEN_UID } from '../constants';
1010
import { IDataInput, IDataOutputWithToken, ITokenData, IUtxo, TokenVersion } from '../types';
1111
import Output from '../models/output';
1212

13-
type TokenUtxo = IDataInput | Utxo | IUtxo | IDataInput | IDataOutputWithToken | Output;
13+
type TokenElement = IDataInput | Utxo | IUtxo | IDataInput | IDataOutputWithToken | Output;
1414

1515
export class Fee {
1616
/**
@@ -31,8 +31,8 @@ export class Fee {
3131
outputs: (IDataOutputWithToken | Output)[],
3232
tokens: Map<string, ITokenData | TokenInfo>
3333
): Promise<bigint> {
34-
const nonAuthorityInputs = Fee.getNonAuthorityUtxoByTokenUid(inputs);
35-
const nonAuthorityOutputs = Fee.getNonAuthorityUtxoByTokenUid(outputs);
34+
const nonAuthorityInputs = Fee.groupTokenElementsByTokenUid(inputs);
35+
const nonAuthorityOutputs = Fee.groupTokenElementsByTokenUid(outputs);
3636

3737
const tokensSet = new Set([...nonAuthorityInputs.keys(), ...nonAuthorityOutputs.keys()]);
3838
tokensSet.delete(NATIVE_TOKEN_UID);
@@ -67,8 +67,8 @@ export class Fee {
6767
* @memberof Fee
6868
* @static
6969
*/
70-
static calculateTokenCreationTxFee(outputs: Omit<TokenUtxo, 'token'>[]): bigint {
71-
return BigInt(Fee.getNonAuthorityOutputs(outputs).length) * FEE_PER_OUTPUT;
70+
static calculateTokenCreationTxFee(outputs: Omit<TokenElement, 'token'>[]): bigint {
71+
return BigInt(Fee.getNonAuthorityTokenElement(outputs).length) * FEE_PER_OUTPUT;
7272
}
7373

7474
/**
@@ -78,52 +78,52 @@ export class Fee {
7878
* @memberof Fee
7979
* @static
8080
*/
81-
static getNonAuthorityOutputs(
82-
outputs: (TokenUtxo | Omit<TokenUtxo, 'token'>)[]
83-
): (TokenUtxo | Omit<TokenUtxo, 'token'>)[] {
84-
return outputs.filter(output => !Fee.isAuthorityUtxo(output as never)); // casting to never since we don't need the token property here.
81+
static getNonAuthorityTokenElement(
82+
outputs: (TokenElement | Omit<TokenElement, 'token'>)[]
83+
): (TokenElement | Omit<TokenElement, 'token'>)[] {
84+
return outputs.filter(output => !Fee.isAuthorityTokenElement(output as never)); // casting to never since we don't need the token property here.
8585
}
8686

8787
/**
88-
* Check if the utxo is an authority utxo by checking the `isAuthority` method or ther `authorities` property.
89-
* @param utxo utxo to check
90-
* @returns true if the utxo is an authority utxo, false otherwise
88+
* Check if the token element is an authority by checking the `isAuthority` method or the `authorities` property.
89+
* @param tokenElement token element to check
90+
* @returns true if the token element is an authority, false otherwise
9191
* @memberof Fee
9292
* @static
9393
*/
94-
static isAuthorityUtxo(utxo: TokenUtxo): boolean {
95-
if (utxo instanceof Output) {
96-
return utxo.isAuthority();
94+
static isAuthorityTokenElement(tokenElement: TokenElement): boolean {
95+
if (tokenElement instanceof Output) {
96+
return tokenElement.isAuthority();
9797
}
98-
return utxo.authorities !== 0n;
98+
return tokenElement.authorities !== 0n;
9999
}
100100

101101
/**
102-
* Check if the utxo is a non-authority utxo by checking the isAuthorityUtxo method, then grouping them by token UID.
103-
* @param utxos an array of utxos to check
104-
* @returns a map where the keys are the token UIDs and the values are arrays of non-authority utxos for that token
102+
* Check if the token element is a non-authority token element by checking the isAuthorityTokenElement method, then grouping them by token UID.
103+
* @param tokenElements an array of token elements to check
104+
* @returns a map where the keys are the token UIDs and the values are arrays of non-authority token elements for that token
105105
* @memberof Fee
106106
* @static
107107
*/
108-
static getNonAuthorityUtxoByTokenUid(utxos: TokenUtxo[]): Map<string, TokenUtxo[]> {
109-
const map = new Map<string, TokenUtxo[]>();
108+
static groupTokenElementsByTokenUid(tokenElements: TokenElement[]): Map<string, TokenElement[]> {
109+
const map = new Map<string, TokenElement[]>();
110110

111-
for (const utxo of utxos) {
112-
if (!Fee.isAuthorityUtxo(utxo)) {
111+
for (const tokenElement of tokenElements) {
112+
if (!Fee.isAuthorityTokenElement(tokenElement)) {
113113
let tokenUid: string = '';
114-
if ('token' in utxo) {
115-
tokenUid = utxo.token;
114+
if ('token' in tokenElement) {
115+
tokenUid = tokenElement.token;
116116
}
117-
if ('tokenId' in utxo) {
118-
tokenUid = utxo.tokenId;
117+
if ('tokenId' in tokenElement) {
118+
tokenUid = tokenElement.tokenId;
119119
}
120120
if (!tokenUid) {
121-
throw new Error('Token UID not found in utxo');
121+
throw new Error('Token UID not found in token element');
122122
}
123123
if (!map.has(tokenUid)) {
124124
map.set(tokenUid, []);
125125
}
126-
map.get(tokenUid)?.push(utxo);
126+
map.get(tokenUid)?.push(tokenElement);
127127
}
128128
}
129129
return map;

src/utils/tokens.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ const tokens = {
373373
* @param {boolean|null} [options.unshiftData=null] Whether to unshift the data script output.
374374
* @param {string[]|null} [options.data=null] list of data strings using utf8 encoding to add each as a data script output
375375
* @param {function} [options.utxoSelection=bestUtxoSelection] Algorithm to select utxos. Use the best method by default
376-
* @param {boolean} [options.skipDepositFee=false] if it should skip utxo selection for token deposit fee
376+
* @param {boolean} [options.skipDepositFee=false] if it should skip utxo selection for token fees
377377
* @param {TokenVersion} [options.tokenVersion=TokenVersion.DEPOSIT] Token version to be used for the transaction
378378
*
379379
* @returns {Promise<IDataTx>} The transaction data
@@ -468,7 +468,9 @@ const tokens = {
468468
break;
469469
case TokenVersion.FEE:
470470
// is creating a new token
471-
if (!isMintingToken) {
471+
if (skipDepositFee) {
472+
feeAmount = 0n;
473+
} else if (!isMintingToken) {
472474
feeAmount = Fee.calculateTokenCreationTxFee(outputs);
473475
} else {
474476
const mappedOutputs = outputs.map(
@@ -486,8 +488,7 @@ const tokens = {
486488
await tokens.getTokensByManyIds(storage, new Set(tokensArray))
487489
);
488490

489-
// TODO-RAUL: check the behaviour of the skipDepositFee and if we should skip the entire fee calculation
490-
if (!skipDepositFee && data) {
491+
if (data) {
491492
// The deposit amount will be the quantity of data strings in the array
492493
// multiplied by the fee (this fee is not related to the trasanction fee that is calculated based in the token version)
493494
depositAmount += this.getDataFee(data.length);

0 commit comments

Comments
 (0)