From a1698c0e21b63a43c5831a0b2897ac2cc1c3d373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Carneiro?= Date: Mon, 23 Sep 2024 13:01:46 -0300 Subject: [PATCH 1/3] feat: mark inputs as used (#484) * feat: mark inputs as used --- .../mark_utxos_selected_as_input.test.js | 179 ++++++++++++++++++ __tests__/p2sh/tx-proposal-create.test.js | 26 +++ .../p2sh/tx-proposal-melt-tokens.test.js | 25 +++ .../p2sh/tx-proposal-mint-tokens.test.js | 30 +++ __tests__/p2sh/tx-proposal-new-token.test.js | 30 +++ src/api-docs.js | 147 +++++++++++++- .../wallet/p2sh/tx-proposal.controller.js | 48 ++++- src/controllers/wallet/wallet.controller.js | 38 +++- src/helpers/tx.helper.js | 17 +- src/routes/wallet/p2sh/tx-proposal.routes.js | 10 + src/routes/wallet/wallet.routes.js | 44 ++++- 11 files changed, 581 insertions(+), 13 deletions(-) create mode 100644 __tests__/mark_utxos_selected_as_input.test.js diff --git a/__tests__/mark_utxos_selected_as_input.test.js b/__tests__/mark_utxos_selected_as_input.test.js new file mode 100644 index 00000000..0a3379f8 --- /dev/null +++ b/__tests__/mark_utxos_selected_as_input.test.js @@ -0,0 +1,179 @@ +import { Storage, Transaction, Input, Output } from '@hathor/wallet-lib'; +import TestUtils from './test-utils'; + +const walletId = 'stub_mark_inputs_as_used'; + +function createCustomTxHex() { + const txId0 = '5db0a8c77f818c51cb107532fc1a36785adfa700d81d973fd1f23438b2f3dd74'; + const txId1 = 'fb2fbe0385bc0bc8e9a255a8d530f7b3bdcebcd5ccdae5e154e6c3d57cbcd143'; + const txId2 = '11835fae291c60fc58314c61d27dc644b9e029c363bbe458039b2b0186144275'; + const tx = new Transaction( + [new Input(txId0, 0), new Input(txId1, 1), new Input(txId2, 2)], + [new Output(100, Buffer.from('0463616665ac', 'hex'))], + { + timestamp: 123, + parents: ['f6c83e3641a08ec21aebc01296ff12f5a46780f0fbadb1c8101309123b95d2c6'], + }, + ); + + return tx.toHex(); +} + +describe('mark utxos selected_as_input api', () => { + let selectSpy; + const txHex = createCustomTxHex(); + + beforeAll(async () => { + selectSpy = jest.spyOn(Storage.prototype, 'utxoSelectAsInput'); + await TestUtils.startWallet({ walletId }); + }); + + beforeEach(() => { + selectSpy.mockReset(); + selectSpy.mockImplementation(jest.fn(async () => {})); + }); + + afterAll(async () => { + selectSpy.mockRestore(); + await TestUtils.stopWallet({ walletId }); + }); + + it('should fail if txHex is not a hex string', async () => { + let response = await TestUtils.request + .put('/wallet/utxos-selected-as-input') + .send({ txHex: 123 }) + .set({ 'x-wallet-id': walletId }); + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + + response = await TestUtils.request + .put('/wallet/utxos-selected-as-input') + .send({ txHex: '0123g' }) + .set({ 'x-wallet-id': walletId }); + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + expect(selectSpy).toHaveBeenCalledTimes(0); + }); + + it('should fail if mark is not a boolean', async () => { + let response = await TestUtils.request + .put('/wallet/utxos-selected-as-input') + .send({ txHex, mark_as_used: '123' }) + .set({ 'x-wallet-id': walletId }); + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + + response = await TestUtils.request + .put('/wallet/utxos-selected-as-input') + .send({ txHex, mark_as_used: 123 }) + .set({ 'x-wallet-id': walletId }); + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + + response = await TestUtils.request + .put('/wallet/utxos-selected-as-input') + .send({ txHex, mark_as_used: 'abc' }) + .set({ 'x-wallet-id': walletId }); + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + + it('should fail if ttl is not a number', async () => { + const response = await TestUtils.request + .put('/wallet/utxos-selected-as-input') + .send({ txHex, ttl: '123a' }) + .set({ 'x-wallet-id': walletId }); + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + + it('should fail if txHex is an invalid transaction', async () => { + const response = await TestUtils.request + .put('/wallet/utxos-selected-as-input') + .send({ txHex: '0123456789abcdef' }) + .set({ 'x-wallet-id': walletId }); + expect(response.status).toBe(200); + expect(response.body.success).toBe(false); + expect(selectSpy).toHaveBeenCalledTimes(0); + }); + + it('should mark the inputs as selected on the storage', async () => { + const response = await TestUtils.request + .put('/wallet/utxos-selected-as-input') + .send({ txHex }) + .set({ 'x-wallet-id': walletId }); + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + + expect(selectSpy).toHaveBeenCalledTimes(3); + expect(selectSpy).toHaveBeenCalledWith( + { index: 0, txId: '5db0a8c77f818c51cb107532fc1a36785adfa700d81d973fd1f23438b2f3dd74' }, + true, + undefined, + ); + }); + + it('should mark the inputs as selected on the storage with ttl', async () => { + const response = await TestUtils.request + .put('/wallet/utxos-selected-as-input') + .send({ txHex, ttl: 123 }) + .set({ 'x-wallet-id': walletId }); + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + + expect(selectSpy).toHaveBeenCalledTimes(3); + expect(selectSpy).toHaveBeenCalledWith( + { index: 0, txId: '5db0a8c77f818c51cb107532fc1a36785adfa700d81d973fd1f23438b2f3dd74' }, + true, + 123, + ); + }); + + it('should mark the inputs as selected on the storage with mark false', async () => { + const response = await TestUtils.request + .put('/wallet/utxos-selected-as-input') + .send({ txHex, mark_as_used: false }) + .set({ 'x-wallet-id': walletId }); + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + + expect(selectSpy).toHaveBeenCalledTimes(3); + expect(selectSpy).toHaveBeenCalledWith( + { index: 0, txId: '5db0a8c77f818c51cb107532fc1a36785adfa700d81d973fd1f23438b2f3dd74' }, + false, + undefined, + ); + }); + + it('should mark the inputs as selected on the storage with mark true', async () => { + const response = await TestUtils.request + .put('/wallet/utxos-selected-as-input') + .send({ txHex, mark_as_used: true }) + .set({ 'x-wallet-id': walletId }); + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + + expect(selectSpy).toHaveBeenCalledTimes(3); + expect(selectSpy).toHaveBeenCalledWith( + { index: 0, txId: '5db0a8c77f818c51cb107532fc1a36785adfa700d81d973fd1f23438b2f3dd74' }, + true, + undefined, + ); + }); + + it('should mark the inputs as selected on the storage with all options', async () => { + const response = await TestUtils.request + .put('/wallet/utxos-selected-as-input') + .send({ txHex, mark_as_used: false, ttl: 456 }) + .set({ 'x-wallet-id': walletId }); + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + + expect(selectSpy).toHaveBeenCalledTimes(3); + expect(selectSpy).toHaveBeenCalledWith( + { index: 0, txId: '5db0a8c77f818c51cb107532fc1a36785adfa700d81d973fd1f23438b2f3dd74' }, + false, + 456, + ); + }); +}); diff --git a/__tests__/p2sh/tx-proposal-create.test.js b/__tests__/p2sh/tx-proposal-create.test.js index 437551c4..e2533104 100644 --- a/__tests__/p2sh/tx-proposal-create.test.js +++ b/__tests__/p2sh/tx-proposal-create.test.js @@ -151,4 +151,30 @@ describe('create tx-proposal api', () => { expect(response.status).toBe(200); expect(response.body.success).toBe(false); }); + + it('should mark utxos as used when sending mark_inputs_as_used', async () => { + const markSpy = jest.spyOn(hathorLib.Storage.prototype, 'utxoSelectAsInput').mockImplementation(jest.fn(async () => {})); + try { + const response = await TestUtils.request + .post('/wallet/p2sh/tx-proposal') + .send({ + outputs: [ + { address: 'WPynsVhyU6nP7RSZAkqfijEutC88KgAyFc', value: 1 }, + { address: 'wcUZ6J7t2B1s8bqRYiyuZAftcdCGRSiiau', value: 1 }, + ], + mark_inputs_as_used: true, + }) + .set({ 'x-wallet-id': walletId }); + expect(response.status).toBe(200); + expect(response.body.txHex).toBeDefined(); + expect(response.body.success).toBe(true); + const tx = hathorLib.helpersUtils.createTxFromHex(response.body.txHex, new hathorLib.Network('testnet')); + expect(tx.outputs.map(o => o.decodedScript.address.base58)) + .toEqual(expect.arrayContaining(['WPynsVhyU6nP7RSZAkqfijEutC88KgAyFc', 'wcUZ6J7t2B1s8bqRYiyuZAftcdCGRSiiau'])); + + expect(markSpy).toHaveBeenCalledTimes(1); + } finally { + markSpy.mockRestore(); + } + }); }); diff --git a/__tests__/p2sh/tx-proposal-melt-tokens.test.js b/__tests__/p2sh/tx-proposal-melt-tokens.test.js index be1214d3..8f25e76c 100644 --- a/__tests__/p2sh/tx-proposal-melt-tokens.test.js +++ b/__tests__/p2sh/tx-proposal-melt-tokens.test.js @@ -256,4 +256,29 @@ describe('melt-tokens tx-proposal api', () => { }); } }); + + it('should mark utxos as used when sending mark_inputs_as_used', async () => { + const markSpy = jest.spyOn(hathorLib.Storage.prototype, 'utxoSelectAsInput').mockImplementation(jest.fn(async () => {})); + try { + const response = await TestUtils.request + .post('/wallet/p2sh/tx-proposal/melt-tokens') + .send({ + token: '0000073b972162f70061f61cf0082b7a47263cc1659a05976aca5cd01b3351ee', + amount: 1, + mark_inputs_as_used: true, + }) + .set({ 'x-wallet-id': walletId }); + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.txHex).toBeDefined(); + const tx = hathorLib.helpersUtils + .createTxFromHex(response.body.txHex, new hathorLib.Network('testnet')); + expect(tx.outputs.map(o => o.decodedScript.address.base58)) + .toEqual(expect.arrayContaining(['wbe2eJdyZVimA7nJjmBQnKYJSXmpnpMKgG'])); + + expect(markSpy).toHaveBeenCalledTimes(2); + } finally { + markSpy.mockRestore(); + } + }); }); diff --git a/__tests__/p2sh/tx-proposal-mint-tokens.test.js b/__tests__/p2sh/tx-proposal-mint-tokens.test.js index 1944d677..2069f514 100644 --- a/__tests__/p2sh/tx-proposal-mint-tokens.test.js +++ b/__tests__/p2sh/tx-proposal-mint-tokens.test.js @@ -235,4 +235,34 @@ describe('mint-tokens tx-proposal api', () => { expect(authorityOutputs).toHaveLength(1); expect(authorityOutputs[0].value).toBe(AUTHORITY_VALUE.MINT); }); + + it('should mark utxos as used when sending mark_inputs_as_used', async () => { + const markSpy = jest.spyOn(hathorLib.Storage.prototype, 'utxoSelectAsInput').mockImplementation(jest.fn(async () => {})); + try { + const response = await TestUtils.request + .post('/wallet/p2sh/tx-proposal/mint-tokens') + .send({ + token: '0000073b972162f70061f61cf0082b7a47263cc1659a05976aca5cd01b3351ee', + amount: 1, + mark_inputs_as_used: true, + }) + .set({ 'x-wallet-id': walletId }); + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.txHex).toBeDefined(); + const tx = hathorLib.helpersUtils + .createTxFromHex(response.body.txHex, new hathorLib.Network('testnet')); + expect(tx.outputs.map(o => o.decodedScript.address.base58)) + .toEqual(expect.arrayContaining(['wbe2eJdyZVimA7nJjmBQnKYJSXmpnpMKgG'])); + expect(tx.inputs).toEqual(expect.not.arrayContaining([ + expect.objectContaining({ + data: expect.any(Object), + }), + ])); + + expect(markSpy).toHaveBeenCalledTimes(2); + } finally { + markSpy.mockRestore(); + } + }); }); diff --git a/__tests__/p2sh/tx-proposal-new-token.test.js b/__tests__/p2sh/tx-proposal-new-token.test.js index 8826f696..49726290 100644 --- a/__tests__/p2sh/tx-proposal-new-token.test.js +++ b/__tests__/p2sh/tx-proposal-new-token.test.js @@ -440,4 +440,34 @@ describe('create-token tx-proposal api', () => { expect(authorityOutputs[0].value).toBe(AUTHORITY_VALUE.MINT); expect(authorityOutputs[1].value).toBe(AUTHORITY_VALUE.MELT); }); + + it('should mark utxos as used when sending mark_inputs_as_used', async () => { + const markSpy = jest.spyOn(hathorLib.Storage.prototype, 'utxoSelectAsInput').mockImplementation(jest.fn(async () => {})); + try { + const response = await TestUtils.request + .post('/wallet/p2sh/tx-proposal/create-token') + .send({ + name: 'My Custom Token', + symbol: 'MCT', + amount: 1, + mark_inputs_as_used: true, + }) + .set({ 'x-wallet-id': walletId }); + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.txHex).toBeDefined(); + const tx = hathorLib.helpersUtils.createTxFromHex(response.body.txHex, new hathorLib.Network('testnet')); + expect(tx.outputs.map(o => o.decodedScript.address.base58)) + .toEqual(expect.arrayContaining([TestUtils.multisigAddresses[1]])); + expect(tx.inputs).toEqual(expect.not.arrayContaining([ + expect.objectContaining({ + data: expect.any(Object), + }), + ])); + + expect(markSpy).toHaveBeenCalledTimes(1); + } finally { + markSpy.mockRestore(); + } + }); }); diff --git a/src/api-docs.js b/src/api-docs.js index c2283603..97423a1f 100644 --- a/src/api-docs.js +++ b/src/api-docs.js @@ -803,6 +803,46 @@ const defaultApiDocs = { }, }, }, + '/wallet/utxos-selected-as-input': { + put: { + operationId: 'utxosSelectedAsInput', + summary: 'Mark or unmark the inputs of a given transaction as selected as inputs on the storage. This prevents the inputs from being chosen by another transaction.', + requestBody: { + description: 'Transaction hex representation.', + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + txHex: { + type: 'string', + description: 'Hex format of a Transaction instance.' + }, + } + }, + } + } + }, + responses: { + 200: { + description: 'Mark the inputs from the given transaction', + content: { + 'application/json': { + examples: { + success: { + summary: 'Success', + value: { + success: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, '/wallet/tx-proposal': { post: { operationId: 'createTxProposal', @@ -1322,6 +1362,10 @@ const defaultApiDocs = { type: 'string', description: 'Optional address to send the change amount.' }, + mark_inputs_as_used: { + type: 'boolean', + description: 'If we should lock the utxos chosen as inputs so they are not chosen when creating another transaction.' + }, } }, examples: { @@ -1433,6 +1477,10 @@ const defaultApiDocs = { type: 'boolean', description: 'If the melt authority address is allowed to be from another wallet. Default is false.' }, + mark_inputs_as_used: { + type: 'boolean', + description: 'If we should lock the utxos chosen as inputs so they are not chosen when creating another transaction.' + }, } }, examples: { @@ -1518,16 +1566,9 @@ const defaultApiDocs = { type: 'boolean', description: 'If the mint authority address is allowed to be from another wallet. Default is false.' }, - unshift_data: { + mark_inputs_as_used: { type: 'boolean', - description: 'Add data outputs at the beginning of the outputs. Default is true.' - }, - data: { - type: 'array', - items: { - type: 'string' - }, - description: 'List of utf-8 encoded strings to create a data output for each.' + description: 'If we should lock the utxos chosen as inputs so they are not chosen when creating another transaction.' }, } }, @@ -1569,6 +1610,94 @@ const defaultApiDocs = { }, } }, + '/wallet/p2sh/tx-proposal/melt-tokens': { + post: { + operationId: 'meltTokensP2shProposal', + summary: 'Get the hex representation of a melt tokens transaction without input data.', + parameters: [ + { $ref: '#/components/parameters/XWalletIdParameter' }, + ], + requestBody: { + description: 'Data to melt tokens.', + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['token', 'amount'], + properties: { + token: { + type: 'string', + description: 'UID of the token to melt.' + }, + amount: { + type: 'integer', + description: 'The amount of tokens to melt. It must be an integer with the value in cents, i.e., 123 means 1.23.' + }, + deposit_address: { + type: 'string', + description: 'Optional address to send the deposit HTR received after the melt.' + }, + change_address: { + type: 'string', + description: 'Optional address to send the change amount of custom tokens after melt.' + }, + create_melt: { + type: 'boolean', + description: 'If we should create another melt authority for the token. Default is true.' + }, + melt_authority_address: { + type: 'string', + description: 'Optional address to send the new melt authority output created.' + }, + allow_external_melt_authority_address: { + type: 'boolean', + description: 'If the melt authority address is allowed to be from another wallet. Default is false.' + }, + mark_inputs_as_used: { + type: 'boolean', + description: 'If we should lock the utxos chosen as inputs so they are not chosen when creating another transaction.' + }, + } + }, + examples: { + 'Melt Tokens': { + summary: 'Data to melt tokens', + value: { + token: '0000073b972162f70061f61cf0082b7a47263cc1659a05976aca5cd01b3351ee', + amount: 100, + } + } + } + } + } + }, + responses: { + 200: { + description: 'Melt tokens.', + content: { + 'application/json': { + examples: { + error: { + summary: 'Insuficient amount of tokens', + value: { success: false, error: "There aren't enough inputs to melt." } + }, + success: { + summary: 'Success', + value: { success: true, txHex: '0001010203000016392ed330ed99ff0f74e4169a8d257fd1d07d3b38c4f8ecf21a78f10efa000016392ed330ed99ff0f74e4169a8d257fd1d07d3b38c4f8ecf21a78f10efa00006946304402201166baf8513c0bfd21edcb169a4df5645ca826b22b6ed22d13945628094a04c502204f382ef9e6b903397b2bcaaed5316b0bb54212037a30e5cda7a5cf4d785b8f332102a5c1b462ccdcd8b4bb2cf672e0672576420c3102ecbe74da15b2cf56cf49b4a5000016392ed330ed99ff0f74e4169a8d257fd1d07d3b38c4f8ecf21a78f10efa02006946304402201166baf8513c0bfd21edcb169a4df5645ca826b22b6ed22d13945628094a04c502204f382ef9e6b903397b2bcaaed5316b0bb54212037a30e5cda7a5cf4d785b8f332102a5c1b462ccdcd8b4bb2cf672e0672576420c3102ecbe74da15b2cf56cf49b4a5000001f1000017a91462d397b360118b99a8d35892366074fe16fa6f098700000001010017a91462d397b360118b99a8d35892366074fe16fa6f098700000001810017a91462d397b360118b99a8d35892366074fe16fa6f098740327a9b3baad50b649b5f1d0000000000' } + }, + 'wallet-not-ready': { + summary: 'Wallet is not ready yet', + value: { success: false, message: 'Wallet is not ready.', state: 1 } + }, + ...commonExamples.xWalletIdErrResponseExamples, + }, + }, + }, + }, + }, + } + }, '/wallet/p2sh/tx-proposal/get-my-signatures': { post: { operationId: 'proposalP2shGetMySignatures', diff --git a/src/controllers/wallet/p2sh/tx-proposal.controller.js b/src/controllers/wallet/p2sh/tx-proposal.controller.js index 57655fce..cb79b5aa 100644 --- a/src/controllers/wallet/p2sh/tx-proposal.controller.js +++ b/src/controllers/wallet/p2sh/tx-proposal.controller.js @@ -13,7 +13,7 @@ const { const { parametersValidation } = require('../../../helpers/validations.helper'); const { lock, lockTypes } = require('../../../lock'); const { cantSendTxErrorMessage } = require('../../../helpers/constants'); -const { mapTxReturn } = require('../../../helpers/tx.helper'); +const { mapTxReturn, markUtxosSelectedAsInput } = require('../../../helpers/tx.helper'); const { DEFAULT_PIN } = require('../../../constants'); async function buildTxProposal(req, res) { @@ -29,6 +29,8 @@ async function buildTxProposal(req, res) { const { outputs } = req.body; const inputs = req.body.inputs || []; const changeAddress = req.body.change_address || null; + // `mark_inputs_as_used` but if it's undefined or null defaults to `false`. + const markAsUsed = req.body.mark_inputs_as_used ?? false; if (changeAddress && !await req.wallet.isAddressMine(changeAddress)) { res.send({ success: false, error: 'Change address does not belong to the loaded wallet.' }); @@ -48,6 +50,17 @@ async function buildTxProposal(req, res) { changeAddress, }); const txData = await sendTransaction.prepareTxData(); + + if (markAsUsed) { + await markUtxosSelectedAsInput( + req.wallet, + txData.inputs.map(input => ({ + txId: input.txId, + index: input.index, + })), + true, + ); + } txData.version = hathorLibConstants.DEFAULT_TX_VERSION; const tx = transactionUtils.createTransactionFromData(txData, network); @@ -78,8 +91,11 @@ async function buildCreateTokenTxProposal(req, res) { const createMelt = req.body.create_melt ?? true; const meltAuthorityAddress = req.body.melt_authority_address || null; const allowExternalMeltAuthorityAddress = req.body.allow_external_melt_authority_address || null; + // `mark_inputs_as_used` but if it's undefined or null defaults to `false`. + const markAsUsed = req.body.mark_inputs_as_used ?? false; try { + /** @type {import('@hathor/wallet-lib').CreateTokenTransaction} */ const createTokenTransaction = await req.wallet.prepareCreateNewToken(name, symbol, amount, { address, changeAddress, @@ -92,6 +108,14 @@ async function buildCreateTokenTxProposal(req, res) { signTx: false, }); + if (markAsUsed) { + await markUtxosSelectedAsInput( + req.wallet, + createTokenTransaction.inputs.map(input => ({ txId: input.hash, index: input.index })), + true, + ); + } + res.send({ success: true, txHex: createTokenTransaction.toHex() }); } catch (err) { res.send({ success: false, error: err.message }); @@ -114,12 +138,15 @@ async function buildMintTokensTxProposal(req, res) { const createAnotherMint = req.body.create_mint ?? true; const mintAuthorityAddress = req.body.mint_authority_address || null; const allowExternalMintAuthorityAddress = req.body.allow_external_mint_authority_address || null; + // `mark_inputs_as_used` but if it's undefined or null defaults to `false`. + const markAsUsed = req.body.mark_inputs_as_used ?? false; try { if (changeAddress && !await req.wallet.isAddressMine(changeAddress)) { throw new Error('Change address is not from this wallet'); } + /** @type {import('@hathor/wallet-lib').Transaction} */ const mintTokenTransaction = await req.wallet.prepareMintTokensData( token, amount, @@ -132,6 +159,14 @@ async function buildMintTokensTxProposal(req, res) { signTx: false, } ); + + if (markAsUsed) { + await markUtxosSelectedAsInput( + req.wallet, + mintTokenTransaction.inputs.map(input => ({ txId: input.hash, index: input.index })), + true, + ); + } res.send({ success: true, txHex: mintTokenTransaction.toHex() }); } catch (err) { res.send({ success: false, error: err.message }); @@ -157,12 +192,15 @@ async function buildMeltTokensTxProposal(req, res) { const createAnotherMelt = req.body.create_melt ?? true; const meltAuthorityAddress = req.body.melt_authority_address || null; const allowExternalMeltAuthorityAddress = req.body.allow_external_melt_authority_address || null; + // `mark_inputs_as_used` but if it's undefined or null defaults to `false`. + const markAsUsed = req.body.mark_inputs_as_used ?? false; try { if (changeAddress && !await req.wallet.isAddressMine(changeAddress)) { throw new Error('Change address is not from this wallet'); } + /** @type {import('@hathor/wallet-lib').Transaction} */ const meltTokenTransaction = await req.wallet.prepareMeltTokensData( token, amount, @@ -176,6 +214,14 @@ async function buildMeltTokensTxProposal(req, res) { } ); + if (markAsUsed) { + await markUtxosSelectedAsInput( + req.wallet, + meltTokenTransaction.inputs.map(input => ({ txId: input.hash, index: input.index })), + true, + ); + } + res.send({ success: true, txHex: meltTokenTransaction.toHex() }); } catch (err) { logger.error(err); diff --git a/src/controllers/wallet/wallet.controller.js b/src/controllers/wallet/wallet.controller.js index d48e65b4..9f8d8378 100755 --- a/src/controllers/wallet/wallet.controller.js +++ b/src/controllers/wallet/wallet.controller.js @@ -11,7 +11,7 @@ const { matchedData } = require('express-validator'); const { parametersValidation } = require('../../helpers/validations.helper'); const { lock, lockTypes } = require('../../lock'); const { cantSendTxErrorMessage, friendlyWalletState } = require('../../helpers/constants'); -const { mapTxReturn, prepareTxFunds, getTx } = require('../../helpers/tx.helper'); +const { mapTxReturn, prepareTxFunds, getTx, markUtxosSelectedAsInput } = require('../../helpers/tx.helper'); const { stopWallet } = require('../../services/wallets.service'); async function getStatus(req, res) { @@ -763,6 +763,41 @@ async function stop(req, res) { res.send({ success: true }); } +/** + * Mark the inputs from the txHex as used. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ +async function utxosSelectedAsInput(req, res) { + const validationResult = parametersValidation(req); + if (!validationResult.success) { + res.status(400).json(validationResult); + return; + } + + /** @type {{ wallet: import('@hathor/wallet-lib').HathorWallet }} */ + const { wallet } = req; + const { txHex, ttl } = req.body; + const markAsUsed = req.body.mark_as_used ?? true; + + try { + const tx = helpersUtils.createTxFromHex(txHex, wallet.getNetworkObject()); + await markUtxosSelectedAsInput( + wallet, + tx.inputs.map( + input => ({ txId: input.hash, index: input.index }) + ), + markAsUsed, + ttl, + ); + + res.send({ success: true }); + } catch (err) { + res.send({ success: false, error: err.message }); + } +} + module.exports = { getStatus, getBalance, @@ -783,4 +818,5 @@ module.exports = { utxoConsolidation, createNft, stop, + utxosSelectedAsInput, }; diff --git a/src/helpers/tx.helper.js b/src/helpers/tx.helper.js index 352039e9..dfebe647 100644 --- a/src/helpers/tx.helper.js +++ b/src/helpers/tx.helper.js @@ -237,9 +237,24 @@ async function getTx(wallet, id, options) { } } +/** + * Mark or unmark all utxos as selected_as_input on the given storage. + * @param {import('@hathor/wallet-lib').HathorWallet} wallet + * @param {{ txId: string, index: number }[]} utxos + * @param {boolean} [markAs=true] + * @param {number?} [ttl=undefined] + */ +async function markUtxosSelectedAsInput(wallet, utxos, markAs, ttl) { + const mark = markAs ?? true; + for (const utxo of utxos) { + await wallet.storage.utxoSelectAsInput(utxo, mark, ttl); + } +} + module.exports = { mapTxReturn, getUtxosToFillTx, prepareTxFunds, - getTx + getTx, + markUtxosSelectedAsInput, }; diff --git a/src/routes/wallet/p2sh/tx-proposal.routes.js b/src/routes/wallet/p2sh/tx-proposal.routes.js index 025015dd..09c0f772 100644 --- a/src/routes/wallet/p2sh/tx-proposal.routes.js +++ b/src/routes/wallet/p2sh/tx-proposal.routes.js @@ -81,6 +81,13 @@ txProposalRouter.post( isString: true, optional: true, }, + mark_inputs_as_used: { + in: ['body'], + errorMessage: 'Invalid mark_as_used argument', + isBoolean: true, + toBoolean: true, + optional: true, + } }), buildTxProposal, ); @@ -98,6 +105,7 @@ txProposalRouter.post( body('create_melt').isBoolean().optional(), body('melt_authority_address').isString().notEmpty().optional(), body('allow_external_melt_authority_address').isBoolean().optional().toBoolean(), + body('mark_inputs_as_used').isBoolean().optional().toBoolean(), buildCreateTokenTxProposal, ); @@ -110,6 +118,7 @@ txProposalRouter.post( body('create_mint').isBoolean().optional(), body('mint_authority_address').isString().notEmpty().optional(), body('allow_external_mint_authority_address').isBoolean().optional().toBoolean(), + body('mark_inputs_as_used').isBoolean().optional().toBoolean(), buildMintTokensTxProposal, ); @@ -122,6 +131,7 @@ txProposalRouter.post( body('create_melt').isBoolean().optional(), body('melt_authority_address').isString().notEmpty().optional(), body('allow_external_melt_authority_address').isBoolean().optional().toBoolean(), + body('mark_inputs_as_used').isBoolean().optional().toBoolean(), buildMeltTokensTxProposal, ); diff --git a/src/routes/wallet/wallet.routes.js b/src/routes/wallet/wallet.routes.js index a39d055a..ecb8d906 100644 --- a/src/routes/wallet/wallet.routes.js +++ b/src/routes/wallet/wallet.routes.js @@ -12,7 +12,8 @@ const { getStatus, getBalance, getAddress, getAddresses, getTxHistory, getTransaction, simpleSendTx, decodeTx, sendTx, createToken, mintTokens, meltTokens, utxoFilter, utxoConsolidation, createNft, getAddressInfo, stop, - getAddressIndex, getTxConfirmationBlocks + getAddressIndex, getTxConfirmationBlocks, + utxosSelectedAsInput, } = require('../../controllers/wallet/wallet.controller'); const { txHexSchema, partialTxSchema } = require('../../schemas'); const p2shRouter = require('./p2sh/p2sh.routes'); @@ -465,4 +466,45 @@ walletRouter.post( */ walletRouter.post('/stop', stop); +/** + * PUT request to mark inputs from a txHex as used. + * For the docs, see api-docs.js + */ +walletRouter.put( + '/utxos-selected-as-input', + checkSchema({ + txHex: { + in: ['body'], + errorMessage: 'Invalid txHex', + isString: true, + custom: { + options: (value, { req, location, path }) => { + // Test if txHex is actually hex + if (!(/^[0-9a-fA-F]+$/.test(value))) return false; + return true; + } + }, + }, + mark_as_used: { + in: ['body'], + errorMessage: 'Invalid mark', + isBoolean: true, + toBoolean: true, + optional: true, + }, + ttl: { + in: ['body'], + errorMessage: 'Invalid ttl', + isInt: { + options: { + min: 1, + }, + }, + toInt: true, + optional: true, + }, + }), + utxosSelectedAsInput, +); + module.exports = walletRouter; From 68740af7b86fb4057df6c1920055b97eb1b8770b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Carneiro?= Date: Tue, 24 Sep 2024 18:14:31 -0300 Subject: [PATCH 2/3] feat: use wallet facade method to mark inputs as selected (#490) * feat: use wallet facade method to mark inputs as selected * tests(unit): ci issues * chore: api-docs never exiting * chore: linter changes --- __tests__/__fixtures__/ws-fixtures.js | 1 + __tests__/history-sync.test.js | 7 +++++-- .../mark_utxos_selected_as_input.test.js | 6 +++--- package-lock.json | 19 +++++-------------- package.json | 2 +- scripts/convert-docs.js | 8 +++++++- src/helpers/tx.helper.js | 5 +++-- 7 files changed, 25 insertions(+), 23 deletions(-) diff --git a/__tests__/__fixtures__/ws-fixtures.js b/__tests__/__fixtures__/ws-fixtures.js index c4e722a2..1691fdec 100644 --- a/__tests__/__fixtures__/ws-fixtures.js +++ b/__tests__/__fixtures__/ws-fixtures.js @@ -1,4 +1,5 @@ export default { + capabilities: { capabilities: ['history-streaming'] }, dashboard: { transactions: 2, blocks: 1537, diff --git a/__tests__/history-sync.test.js b/__tests__/history-sync.test.js index c9f35c0d..fbd29b3b 100644 --- a/__tests__/history-sync.test.js +++ b/__tests__/history-sync.test.js @@ -6,6 +6,10 @@ import { initializedWallets } from '../src/services/wallets.service'; const walletId = 'stub_history_sync'; describe('history sync', () => { + beforeEach(() => { + settings._resetConfig(); + }); + afterEach(async () => { await TestUtils.stopWallet({ walletId }); }); @@ -27,11 +31,11 @@ describe('history sync', () => { const response = await TestUtils.request .post('/start') .send({ seedKey: TestUtils.seedKey, 'wallet-id': walletId }); - settings._resetConfig(); expect(response.status).toBe(200); expect(response.body.success).toBe(true); const wallet = initializedWallets.get(walletId); expect(wallet.historySyncMode).toEqual(hathorLib.HistorySyncMode.MANUAL_STREAM_WS); + await TestUtils.stopWallet({ walletId }); }); it('should use the history sync from the request when provided', async () => { @@ -45,7 +49,6 @@ describe('history sync', () => { 'wallet-id': walletId, history_sync_mode: 'xpub_stream_ws', }); - settings._resetConfig(); expect(response.status).toBe(200); expect(response.body.success).toBe(true); const wallet = initializedWallets.get(walletId); diff --git a/__tests__/mark_utxos_selected_as_input.test.js b/__tests__/mark_utxos_selected_as_input.test.js index 0a3379f8..8c0a2cc6 100644 --- a/__tests__/mark_utxos_selected_as_input.test.js +++ b/__tests__/mark_utxos_selected_as_input.test.js @@ -109,7 +109,7 @@ describe('mark utxos selected_as_input api', () => { expect(selectSpy).toHaveBeenCalledWith( { index: 0, txId: '5db0a8c77f818c51cb107532fc1a36785adfa700d81d973fd1f23438b2f3dd74' }, true, - undefined, + null, ); }); @@ -141,7 +141,7 @@ describe('mark utxos selected_as_input api', () => { expect(selectSpy).toHaveBeenCalledWith( { index: 0, txId: '5db0a8c77f818c51cb107532fc1a36785adfa700d81d973fd1f23438b2f3dd74' }, false, - undefined, + null, ); }); @@ -157,7 +157,7 @@ describe('mark utxos selected_as_input api', () => { expect(selectSpy).toHaveBeenCalledWith( { index: 0, txId: '5db0a8c77f818c51cb107532fc1a36785adfa700d81d973fd1f23438b2f3dd74' }, true, - undefined, + null, ); }); diff --git a/package-lock.json b/package-lock.json index 61797c22..f658d3b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@dinamonetworks/hsm-dinamo": "4.9.1", "@hathor/healthcheck-lib": "0.1.0", - "@hathor/wallet-lib": "1.12.1", + "@hathor/wallet-lib": "1.13.0", "axios": "1.7.2", "express": "4.18.2", "express-validator": "6.10.0", @@ -2168,9 +2168,9 @@ "integrity": "sha512-Oi223+iKye5cmPyMIqp64E/ZP+in0JndN/s9uEigmXxt6wRhwciCPbzSY4S2oicy1uNqhv7lLdyUc3O/P3sCzQ==" }, "node_modules/@hathor/wallet-lib": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@hathor/wallet-lib/-/wallet-lib-1.12.1.tgz", - "integrity": "sha512-kunwYdIJwc0EEQ7LUjOwsu0/s7nrecxVlUgGi6T0ssuvkMVX3NPjHextb+TDzMMQWgEMTX+6qVcXntbEuZ1Y7g==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@hathor/wallet-lib/-/wallet-lib-1.13.0.tgz", + "integrity": "sha512-nOAmsKC5yeYxMHBTKS6PEUBOEVxNVQzK6yNTFG4Jhiol4YK+TcSJalECkezo4NKzfdr9DiUiNDseuAfbdEQ78g==", "license": "MIT", "dependencies": { "abstract-level": "1.0.4", @@ -2183,7 +2183,7 @@ "level": "8.0.1", "lodash": "4.17.21", "long": "5.2.3", - "queue-promise": "^2.2.1", + "queue-microtask": "1.2.3", "ws": "8.17.1" }, "engines": { @@ -9977,15 +9977,6 @@ } ] }, - "node_modules/queue-promise": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/queue-promise/-/queue-promise-2.2.1.tgz", - "integrity": "sha512-C3eyRwLF9m6dPV4MtqMVFX+Xmc7keZ9Ievm3jJ/wWM5t3uVbFnGsJXwpYzZ4LaIEcX9bss/mdaKzyrO6xheRuA==", - "license": "MIT", - "engines": { - "node": ">=8.12.0" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", diff --git a/package.json b/package.json index 797e7147..8a32639d 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dependencies": { "@dinamonetworks/hsm-dinamo": "4.9.1", "@hathor/healthcheck-lib": "0.1.0", - "@hathor/wallet-lib": "1.12.1", + "@hathor/wallet-lib": "1.13.0", "axios": "1.7.2", "express": "4.18.2", "express-validator": "6.10.0", diff --git a/scripts/convert-docs.js b/scripts/convert-docs.js index 02e8d10a..9d516e58 100644 --- a/scripts/convert-docs.js +++ b/scripts/convert-docs.js @@ -17,4 +17,10 @@ const settings = require('../src/settings'); // Output to temporary JSON file await writeFile('./tmp/api-docs.json', JSON.stringify(docsObj, null, 2)); })() - .catch(err => console.error(err.stack)); + .then(() => { + process.exit(0); + }) + .catch(err => { + console.error(err.stack); + process.exit(1); + }); diff --git a/src/helpers/tx.helper.js b/src/helpers/tx.helper.js index dfebe647..2d454fb8 100644 --- a/src/helpers/tx.helper.js +++ b/src/helpers/tx.helper.js @@ -6,6 +6,7 @@ */ const { constants: { NATIVE_TOKEN_UID } } = require('@hathor/wallet-lib'); +/** @import { HathorWallet } from '@hathor/wallet-lib' */ /** * The endpoints that return a created tx must keep compatibility @@ -239,7 +240,7 @@ async function getTx(wallet, id, options) { /** * Mark or unmark all utxos as selected_as_input on the given storage. - * @param {import('@hathor/wallet-lib').HathorWallet} wallet + * @param {HathorWallet} wallet * @param {{ txId: string, index: number }[]} utxos * @param {boolean} [markAs=true] * @param {number?} [ttl=undefined] @@ -247,7 +248,7 @@ async function getTx(wallet, id, options) { async function markUtxosSelectedAsInput(wallet, utxos, markAs, ttl) { const mark = markAs ?? true; for (const utxo of utxos) { - await wallet.storage.utxoSelectAsInput(utxo, mark, ttl); + await wallet.markUtxoSelected(utxo.txId, utxo.index, mark, ttl); } } From 018aac140c19475fb5a7eb36901d3d65840803b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Carneiro?= Date: Thu, 26 Sep 2024 21:52:48 -0300 Subject: [PATCH 3/3] chore: bump v0.32.0-rc.1 (#495) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1ff7212d..3bbae854 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hathor-wallet-headless", - "version": "0.31.0", + "version": "0.32.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hathor-wallet-headless", - "version": "0.31.0", + "version": "0.32.0-rc.1", "license": "MIT", "dependencies": { "@dinamonetworks/hsm-dinamo": "4.9.1", diff --git a/package.json b/package.json index a267e7ea..09da636b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hathor-wallet-headless", - "version": "0.31.0", + "version": "0.32.0-rc.1", "description": "Hathor Wallet Headless, i.e., without graphical user interface", "main": "index.js", "engines": {