diff --git a/docs/node/CATEGORIES/14-spv.md b/docs/node/CATEGORIES/14-spv.md index 691db1f94f..046fc3c8ad 100644 --- a/docs/node/CATEGORIES/14-spv.md +++ b/docs/node/CATEGORIES/14-spv.md @@ -158,6 +158,20 @@ interface SendMessageResult { } ``` +## refundHtlcAll + +Gets all HTLC contracts stored in wallet and creates refunds transactions for all that have expired + +```ts title="client.spv.refundHtlcAll()" +interface spv { + refundHtlcAll (destinationAddress: string, options: SpvDefaultOptions = { feeRate: new BigNumber('10000') }): Promise +} + +interface SpvDefaultOptions { + feeRate?: BigNumber +} +``` + ## listHtlcOutputs List all outputs related to HTLC addresses in the wallet. diff --git a/packages/jellyfish-api-core/__tests__/category/spv/refundHtlcAll.test.ts b/packages/jellyfish-api-core/__tests__/category/spv/refundHtlcAll.test.ts new file mode 100644 index 0000000000..9d52f3d679 --- /dev/null +++ b/packages/jellyfish-api-core/__tests__/category/spv/refundHtlcAll.test.ts @@ -0,0 +1,219 @@ +import { MasterNodeRegTestContainer } from '@defichain/testcontainers' +import { BigNumber, RpcApiError } from '@defichain/jellyfish-api-core' +import { Testing } from '@defichain/jellyfish-testing' + +describe('Spv', () => { + let testing: Testing + let container: MasterNodeRegTestContainer + + beforeEach(async () => { + testing = Testing.create(new MasterNodeRegTestContainer()) + container = testing.container + await container.start() + await container.spv.fundAddress(await container.call('spv_getnewaddress')) // Funds 1 BTC + }) + + afterEach(async () => { + await container.stop() + }) + + it('should refundHtlcAll', async () => { + const pubKeyA = await container.call('spv_getaddresspubkey', [await container.call('spv_getnewaddress')]) + const pubKeyB = await container.call('spv_getaddresspubkey', [await container.call('spv_getnewaddress')]) + const htlc1 = await container.call('spv_createhtlc', [pubKeyA, pubKeyB, '10']) + const htlc2 = await container.call('spv_createhtlc', [pubKeyA, pubKeyB, '10']) + + const fund1 = await container.call('spv_sendtoaddress', [htlc1.address, 0.1]) // Funds HTLC address + const fund2 = await container.call('spv_sendtoaddress', [htlc2.address, 0.2]) + await container.spv.increaseSpvHeight(10) + + const destinationAddress = await container.call('spv_getnewaddress') + const results = await testing.rpc.spv.refundHtlcAll(destinationAddress) // This refund should only happen after timeout threshold set in createHtlc. See https://en.bitcoin.it/wiki/BIP_0199 + expect(results.length).toStrictEqual(1) + for (const txid of results) { + expect(typeof txid).toStrictEqual('string') + expect(txid.length).toStrictEqual(64) + } + + /** + * Assert that refund happened + * confirms at 11 as it `increaseSpvHeight` increased height by 10, + * and refund action adds 1. + */ + const outputList: any[] = await container.call('spv_listhtlcoutputs') + expect(outputList.length).toStrictEqual(2) + const output1 = outputList.find(({ txid }: { txid: string }) => txid === fund1.txid) + const output2 = outputList.find(({ txid }: { txid: string }) => txid === fund2.txid) + expect(output1).toStrictEqual({ + txid: fund1.txid, + vout: expect.any(Number), + amount: new BigNumber(0.1).toNumber(), + address: htlc1.address, + confirms: 11, + spent: { + txid: results[0], + confirms: 1 + } + }) + expect(output2).toStrictEqual({ + txid: fund2.txid, + vout: expect.any(Number), + amount: new BigNumber(0.2).toNumber(), + address: htlc2.address, + confirms: 11, + spent: { + txid: results[0], + confirms: 1 + } + }) + + /** + * Assert that the destination address received the refund + */ + const listReceivingAddresses: any[] = await container.call('spv_listreceivedbyaddress') + const receivingAddress = listReceivingAddresses.find(({ address }: { address: string }) => address === destinationAddress) + expect(receivingAddress.address).toStrictEqual(destinationAddress) + expect(receivingAddress.txids.some((txid: string) => txid === results[0])).toStrictEqual(true) + }) + + it('should refundHtlcAll for expired only', async () => { + const pubKeyA = await container.call('spv_getaddresspubkey', [await container.call('spv_getnewaddress')]) + const pubKeyB = await container.call('spv_getaddresspubkey', [await container.call('spv_getnewaddress')]) + const htlc1 = await container.call('spv_createhtlc', [pubKeyA, pubKeyB, '10']) + const htlc2 = await container.call('spv_createhtlc', [pubKeyA, pubKeyB, '50']) + + const fund1 = await container.call('spv_sendtoaddress', [htlc1.address, 0.1]) // Funds HTLC address + const fund2 = await container.call('spv_sendtoaddress', [htlc2.address, 0.2]) + await container.spv.increaseSpvHeight(10) + + const destinationAddress = await container.call('spv_getnewaddress') + const results = await testing.rpc.spv.refundHtlcAll(destinationAddress) // This refund should only happen after timeout threshold set in createHtlc. See https://en.bitcoin.it/wiki/BIP_0199 + expect(results.length).toStrictEqual(1) + for (const txid of results) { + expect(typeof txid).toStrictEqual('string') + expect(txid.length).toStrictEqual(64) + } + + /** + * Assert that refund happened + * confirms at 11 as it `increaseSpvHeight` increased height by 10, + * and refund action adds 1. + */ + const outputList: any[] = await container.call('spv_listhtlcoutputs') + expect(outputList.length).toStrictEqual(2) + const output1 = outputList.find(({ txid }: { txid: string }) => txid === fund1.txid) + const output2 = outputList.find(({ txid }: { txid: string }) => txid === fund2.txid) + expect(output1).toStrictEqual({ + txid: fund1.txid, + vout: expect.any(Number), + amount: new BigNumber(0.1).toNumber(), + address: htlc1.address, + confirms: 11, + spent: { + txid: results[0], + confirms: 1 + } + }) + expect(output2).toStrictEqual({ + txid: fund2.txid, + vout: expect.any(Number), + amount: new BigNumber(0.2).toNumber(), + address: htlc2.address, + confirms: 11 + }) + + /** + * Assert that the destination address received the refund + */ + const listReceivingAddresses: any[] = await container.call('spv_listreceivedbyaddress') + const receivingAddress = listReceivingAddresses.find(({ address }: { address: string }) => address === destinationAddress) + expect(receivingAddress.address).toStrictEqual(destinationAddress) + expect(receivingAddress.txids.some((txid: string) => txid === results[0])).toStrictEqual(true) + }) + + it('should refundHtlcAll with custom feeRate', async () => { + const pubKeyA = await container.call('spv_getaddresspubkey', [await container.call('spv_getnewaddress')]) + const pubKeyB = await container.call('spv_getaddresspubkey', [await container.call('spv_getnewaddress')]) + const htlc = await container.call('spv_createhtlc', [pubKeyA, pubKeyB, '10']) + + const fund = await container.call('spv_sendtoaddress', [htlc.address, 0.1]) // Funds HTLC address + await container.spv.increaseSpvHeight() + + const destinationAddress = await container.call('spv_getnewaddress') + const results = await testing.rpc.spv.refundHtlcAll(destinationAddress, { feeRate: new BigNumber('20000') }) // This refund should only happen after timeout threshold set in createHtlc. See https://en.bitcoin.it/wiki/BIP_0199 + expect(results.length).toStrictEqual(1) + for (const txid of results) { + expect(typeof txid).toStrictEqual('string') + expect(txid.length).toStrictEqual(64) + } + + /** + * Assert that refund happened + * confirms at 11 as it `increaseSpvHeight` increased height by 10, + * and refund action adds 1. + */ + const outputList: any[] = await container.call('spv_listhtlcoutputs') + expect(outputList.length).toStrictEqual(1) + const output = outputList.find(({ txid }: { txid: string }) => txid === fund.txid) + expect(output).toStrictEqual({ + txid: fund.txid, + vout: expect.any(Number), + amount: new BigNumber(0.1).toNumber(), + address: htlc.address, + confirms: 11, + spent: { + txid: results[0], + confirms: 1 + } + }) + + /** + * Assert that the destination address received the refund + */ + const listReceivingAddresses = await container.call('spv_listreceivedbyaddress') + const receivingAddress = listReceivingAddresses.find(({ address }: { address: string }) => address === destinationAddress) + expect(receivingAddress.address).toStrictEqual(destinationAddress) + expect(receivingAddress.txids.some((txid: string) => txid === results[0])).toStrictEqual(true) + }) + + it('should not refundHtlcAll when no unspent HTLC outputs found', async () => { + const pubKeyA = await container.call('spv_getaddresspubkey', [await container.call('spv_getnewaddress')]) + const pubKeyB = await container.call('spv_getaddresspubkey', [await container.call('spv_getnewaddress')]) + await container.call('spv_createhtlc', [pubKeyA, pubKeyB, '10']) + + const promise = testing.rpc.spv.refundHtlcAll(await container.call('spv_getnewaddress')) + await expect(promise).rejects.toThrow(RpcApiError) + await expect(promise).rejects.toThrow('No unspent HTLC outputs found') + }) + + it('should not refundHtlcAll with invalid destination address', async () => { + const pubKeyA = await container.call('spv_getaddresspubkey', [await container.call('spv_getnewaddress')]) + const pubKeyB = await container.call('spv_getaddresspubkey', [await container.call('spv_getnewaddress')]) + const htlc = await container.call('spv_createhtlc', [pubKeyA, pubKeyB, '10']) + + await container.call('spv_sendtoaddress', [htlc.address, 0.1]) // Funds HTLC address + await container.spv.increaseSpvHeight(10) + + const promise = testing.rpc.spv.refundHtlcAll('XXXX') + await expect(promise).rejects.toThrow(RpcApiError) + await expect(promise).rejects.toThrow('Invalid destination address') + }) + + it('should not refundHtlcAll with not enough funds to cover fee', async () => { + const pubKeyA = await container.call('spv_getaddresspubkey', [await container.call('spv_getnewaddress')]) + const pubKeyB = await container.call('spv_getaddresspubkey', [await container.call('spv_getnewaddress')]) + const htlc = await container.call('spv_createhtlc', [pubKeyA, pubKeyB, '10']) + + await testing.container.call('spv_sendtoaddress', [htlc.address, 0.00000546]) // Funds HTLC address with dust + + const promise = testing.rpc.spv.refundHtlcAll(await container.call('spv_getnewaddress')) + await expect(promise).rejects.toThrow(RpcApiError) + await expect(promise).rejects.toThrow('No unspent HTLC outputs found') + }) + + it('should not refundHtlcAll when no htlc is created for wallet', async () => { + const promise = testing.rpc.spv.refundHtlcAll(await container.call('spv_getnewaddress')) + await expect(promise).rejects.toThrow(RpcApiError) + await expect(promise).rejects.toThrow('Redeem script details not found.') + }) +}) diff --git a/packages/jellyfish-api-core/src/category/spv.ts b/packages/jellyfish-api-core/src/category/spv.ts index 808fbefda9..3d09a62597 100644 --- a/packages/jellyfish-api-core/src/category/spv.ts +++ b/packages/jellyfish-api-core/src/category/spv.ts @@ -120,6 +120,23 @@ export class Spv { return await this.client.call('spv_refundhtlc', [scriptAddress, destinationAddress, options.feeRate], 'number') } + /** + * Gets all HTLC contracts stored in wallet and creates refunds transactions for all that have expired + * + * @param {string} destinationAddress Destination for funds in the HTLC + * @param {SpvDefaultOptions} [options] + * @param {BigNumber} [options.feeRate=10000] Fee rate in satoshis per KB. Minimum is 1000. + * @return {Promise} array of txid + */ + async refundHtlcAll (destinationAddress: string, options: SpvDefaultOptions = { feeRate: new BigNumber('10000') }): Promise { + /** + * Looking at ain, its returning an array of txid containing only 1 txid. + * Considering some factors, this implementation is different from the rpc docs + * on ain side. Refer to PR https://github.com/DeFiCh/jellyfish/pull/1324 + */ + return await this.client.call('spv_refundhtlcall', [destinationAddress, options.feeRate], 'number') + } + /** * List all outputs related to HTLC addresses in the wallet. *