diff --git a/examples/demo/index.html b/examples/demo/index.html index 2a9e57f..ecd2541 100644 --- a/examples/demo/index.html +++ b/examples/demo/index.html @@ -70,6 +70,9 @@

Mintlayer Wallet D + + + @@ -328,6 +331,42 @@

Sign Challenge

+ + + + + + + + + @@ -351,7 +390,7 @@

Output

'lockTokenSupply', 'changeTokenAuthority', 'changeTokenMetadata', 'createOrder', 'fillOrder', 'concludeOrder', 'bridgeRequest', 'broadcastTx', 'freezeToken', 'unfreezeToken', 'burn', 'dataDeposit', 'createDelegation', 'delegationStake', - 'delegationWithdraw', 'signChallenge' + 'delegationWithdraw', 'signChallenge', 'createHtlc', 'refundHtlc', 'requestSecretHash' ]; sections.forEach(id => { const el = document.getElementById(id); @@ -921,6 +960,60 @@

Output

} } + async function createHTLC () { + const amount = document.getElementById('create_htlc_amount').value; + const spend_address = document.getElementById('create_htlc_spend_address').value; + const refund_address = document.getElementById('create_htlc_refund_address').value; + const secret_hash_hex = document.getElementById('create_htlc_secret_hash_hex').value; + const lock_type = document.getElementById('create_htlc_lock_type').value; + const lock_content = document.getElementById('create_htlc_lock_content').value; + const token_id = document.getElementById('create_htlc_token_id').value; + + try { + const result = await window.mintlayer.createHtlc({ + amount: amount, + spend_address: spend_address, + refund_address: refund_address, + refund_timelock: { + type: lock_type, + content: lock_content + }, + token_id: token_id || null, // null for native token + secret_hash: { + hex: secret_hash_hex + }, + }); + + displayOutput(`Transaction: ${JSON.stringify(result, null, 2)}`); + } catch (error) { + displayOutput(`Error: ${error.message}`); + } + } + + async function refundHTLC () { + const transaction_id = document.getElementById('refund_htlc_transaction_id').value; + + try { + const { signature } = await window.mintlayer.refundHtlc({ + transaction_id, + }); + + displayOutput(`Signature: ${JSON.stringify(signature, null, 2)}`); + } catch (error) { + displayOutput(`Error: ${error.message}`); + } + } + + async function requestSecretHash () { + try { + const response = await window.mintlayer.requestSecretHash(); + + displayOutput(`Secret Hash: ${JSON.stringify(response, null, 2)}`); + } catch (error) { + displayOutput(`Error: ${error.message}`); + } + } + if (window.mintlayer) displayOutput('Mintlayer Connect SDK detected!'); else displayOutput('Mintlayer Connect SDK not found.'); diff --git a/packages/sdk/src/mintlayer-connect-sdk.ts b/packages/sdk/src/mintlayer-connect-sdk.ts index ad869c0..ec961f5 100644 --- a/packages/sdk/src/mintlayer-connect-sdk.ts +++ b/packages/sdk/src/mintlayer-connect-sdk.ts @@ -41,6 +41,7 @@ import initWasm, { encode_output_data_deposit, encode_output_create_delegation, encode_output_delegate_staking, + encode_output_htlc, extract_htlc_secret, } from '@mintlayer/wasm-lib'; const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; @@ -63,6 +64,19 @@ function stringToUint8Array(str: string): Uint8Array { return new TextEncoder().encode(str); } +function hexToUint8Array(hex: any) { + if (hex.length % 2 !== 0) { + throw new Error("Invalid hex string"); + } + + const array = new Uint8Array(hex.length / 2); + for (let i = 0; i < array.length; i++) { + array[i] = parseInt(hex.substr(i * 2, 2), 16); + } + + return array; +} + function atomsToDecimal(atoms: string | number, decimals: number): string { const atomsStr = atoms.toString(); const atomsLength = atomsStr.length; @@ -134,6 +148,8 @@ export class MojitoAccountProvider implements AccountProvider { } } +type CreateHtlcArgs = any; + type AmountFields = { atoms: string; decimal: string; @@ -352,6 +368,37 @@ type IssueFungibleTokenOutput = { total_supply: TotalSupplyValue; }; +type Timelock = + | { + type: 'UntilTime'; + content: { + timestamp: string; + }; +} + | { + type: 'ForBlockCount'; + content: number; +}; + +type HtlcOutput = { + type: 'Htlc'; + value: { + token_id?: string; + type: 'Coin' | 'TokenV1'; + amount: AmountFields; + } + token_id?: string; + htlc: { + refund_key: string; + secret_hash: { + hex: string; + string: string | null; + }, + spend_key: string; + refund_timelock: Timelock; + } +} + type IssueNftOutput = { type: 'IssueNft'; destination: string; @@ -388,13 +435,14 @@ type Output = | IssueNftOutput | CreateOrderOutput | CreateDelegationIdOutput - | DelegateStakingOutput; + | DelegateStakingOutput + | HtlcOutput; export interface TransactionJSONRepresentation { inputs: Input[]; outputs: Output[]; fee?: AmountFields; - id?: string; + id: string; } interface Transaction { @@ -438,6 +486,7 @@ type BuildTransactionParams = | { type: 'Transfer'; params: TransferParams; + opts?: any; // TODO: define options type } | { type: 'BurnToken'; @@ -585,6 +634,17 @@ type BuildTransactionParams = ask_token_details: TokenDetails; give_token_details: TokenDetails; }; + } + | { + type: 'Htlc'; + params: { + amount: number; + token_id: string; + secret_hash: string; + spend_address: string; + refund_address: string; + refund_timelock: string; + }; }; interface OrderData { @@ -725,13 +785,13 @@ export type BridgeRequestArgs = { export type SignChallengeArgs = { message: string; address?: string; -} +}; export type SignChallengeResponse = { message: string; address: string; signature: string; -} +}; class Client { private network: 'mainnet' | 'testnet'; @@ -913,7 +973,7 @@ class Client { * @private */ private selectUTXOs(utxos: UtxoEntry[], amount: bigint, token_id: string | null): UtxoInput[] { - const transferableUtxoTypes = ['Transfer', 'LockThenTransfer', 'IssueNft']; + const transferableUtxoTypes = ['Transfer', 'LockThenTransfer', 'IssueNft', 'Htlc']; const filteredUtxos: any[] = utxos // type fix for NFT considering that NFT don't have amount .map((utxo) => { if (utxo.utxo.type === 'IssueNft') { @@ -1346,6 +1406,8 @@ class Client { return 0n; case 'ConcludeOrder': return 0n; + case 'Htlc': + return BigInt(1 * Math.pow(10, 11)); // TODO: 0n default: throw new Error(`Unknown transaction type: ${type}`); } @@ -1877,6 +1939,60 @@ class Client { }, }); } + + if (type === 'Htlc') { + // @ts-ignore + const { token_id, token_details } = params; + + if (token_details) { + input_amount_token_req += BigInt(params.amount! * Math.pow(10, token_details.number_of_decimals)); + send_token = { + token_id, + number_of_decimals: token_details.number_of_decimals, + }; + } else { + input_amount_coin_req += BigInt(params.amount! * Math.pow(10, 11)); + } + + outputs.push({ + type: 'Htlc', + htlc: { + refund_key: params.refund_address, + refund_timelock: { + content: 20, + type: 'ForBlockCount', + }, + secret_hash: { + // @ts-ignore + hex: params.secret_hash.hex, + string: null, + }, + spend_key: params.spend_address, + }, + value: + { + ...(token_details + ? { + amount: { + decimal: params.amount!.toString(), + atoms: (params.amount! * Math.pow(10, token_details.number_of_decimals)).toString(), + }, + } + : { + amount: { + decimal: params.amount!.toString(), + atoms: (params.amount! * Math.pow(10, 11)).toString(), + }, + }), + ...(token_details + ? { type: 'TokenV1', token_id } + : { + type: 'Coin', + }), + }, + }); + } + return { inputs, outputs, send_token, input_amount_coin_req, input_amount_token_req }; } @@ -1909,6 +2025,10 @@ class Client { } const data = await response.json(); + + // @ts-ignore + const forceSpendUtxo: UtxoEntry[] = arg?.opts?.forceSpendUtxo || []; + const utxos: UtxoEntry[] = data.utxos; const { inputs, outputs, input_amount_coin_req, input_amount_token_req, send_token } = @@ -1931,6 +2051,21 @@ class Client { ? this.selectUTXOs(utxos, input_amount_token_req, send_token.token_id) : []; + if(forceSpendUtxo) { + const forceCoinUtxos = forceSpendUtxo.filter(utxo => utxo.utxo.value.type === 'Coin'); + const forceTokenUtxos = forceSpendUtxo.filter(utxo => utxo.utxo.value.type === 'TokenV1' && utxo.utxo.value.token_id === send_token?.token_id); + + if (forceCoinUtxos.length > 0) { + // @ts-ignore + inputObjCoin.unshift(...forceCoinUtxos); + } + if (forceTokenUtxos.length > 0) { + // @ts-ignore + inputObjToken.unshift(...forceTokenUtxos); + } + } + + const totalInputValueCoin = inputObjCoin.reduce((acc, item) => acc + BigInt(item.utxo!.value.amount.atoms), 0n); const totalInputValueToken = inputObjToken.reduce((acc, item) => acc + BigInt(item.utxo!.value.amount.atoms), 0n); @@ -1993,6 +2128,7 @@ class Client { atoms: totalFee.toString(), decimal: atomsToDecimal(totalFee.toString(), 11).toString(), }, + id: 'to_be_filled_in' }; const BINRepresentation = this.getTransactionBINrepresentation(JSONRepresentation, 1); @@ -2271,12 +2407,42 @@ class Client { if (output.type === 'DelegateStaking') { return encode_output_delegate_staking(Amount.from_atoms(output.amount.atoms), output.delegation_id, network); } + + if (output.type === 'Htlc') { + let refund_timelock: Uint8Array = new Uint8Array(); + + if (output.htlc.refund_timelock.type === 'UntilTime') { + refund_timelock = encode_lock_until_time(BigInt(output.htlc.refund_timelock.content.timestamp)); // TODO: check if timestamp is correct + } + if (output.htlc.refund_timelock.type === 'ForBlockCount') { + refund_timelock = encode_lock_for_block_count(BigInt(output.htlc.refund_timelock.content)); + } + + return encode_output_htlc( + Amount.from_atoms(output.value.amount.atoms), + output.value.token_id, + output.htlc.secret_hash.hex, + output.htlc.spend_key, + output.htlc.refund_key, + refund_timelock, + network, + ); + } }); const outputsArray = outputsArrayItems.filter((x): x is NonNullable => x !== undefined); const inputAddresses: string[] = (transactionJSONrepresentation.inputs as UtxoInput[]) - .filter(({ input }) => input.input_type === 'UTXO') - .map((input) => input.utxo.destination); + .filter(({ input, utxo }) => input.input_type === 'UTXO') + .map((input) => { + if (input.utxo.destination){ + return input.utxo.destination; + } + // @ts-ignore + if (input.utxo.htlc) { + // @ts-ignore + return input.utxo.htlc.refund_key; // TODO: need to handle spend too + } + }); // @ts-ignore if (transactionJSONrepresentation.inputs[0].input.account_type === 'DelegationBalance') { @@ -2782,6 +2948,107 @@ class Client { } } + async createHtlc(params: CreateHtlcArgs): Promise { + this.ensureInitialized(); + + let token_details: TokenDetails | undefined = undefined; + + if(params.token_id){ + const request = await fetch(`${this.getApiServer()}/token/${params.token_id}`); + if (!request.ok) { + throw new Error('Failed to fetch token'); + } + const token = await request.json(); + token_details = token; + } + + const tx = await this.buildTransaction({ + type: 'Htlc', + params: { + amount: params.amount, + token_id: params.token_id, + token_details: token_details || undefined, + // @ts-ignore + secret_hash: params.secret_hash, + spend_address: params.spend_address, + refund_address: params.refund_address, + refund_timelock: params.refund_timelock, + }, + }); + return this.signTransaction(tx); + } + + async refundHtlc(params: any): Promise { + this.ensureInitialized(); + + const { transaction_id, utxo } = params; + + let useHtlcUtxo: any[] = []; + + if (transaction_id) { + const response = await fetch(`${this.getApiServer()}/transaction/${transaction_id}`); + if (!response.ok) { + throw new Error('Failed to fetch transaction'); + } + const transaction: TransactionJSONRepresentation = await response.json(); + // @ts-ignore + const { created } = this.previewUtxoChange({ JSONRepresentation: { ...transaction } } as Transaction); + // @ts-ignore + useHtlcUtxo = created.filter(({utxo}) => utxo.type === 'Htlc') || null; + } + + const tx = await this.buildTransaction({ + type: 'Transfer', + params: { + to: useHtlcUtxo[0].utxo.htlc.refund_key, + amount: useHtlcUtxo[0].utxo.value.amount.decimal, + }, + opts: { + forceSpendUtxo: useHtlcUtxo, + } + }); + return this.signTransaction(tx); + } + + async extractHtlcSecret(arg: any): Promise { + const { + transaction_id, + transaction_hex, + } = arg; + + const res = await fetch(`${this.getApiServer()}/transaction/${transaction_id}`); + if (!res.ok) { + throw new Error('Failed to fetch transaction'); + } + const transaction: TransactionJSONRepresentation = await res.json(); + + const transaction_signed = hexToUint8Array(transaction_hex); + + const inputs = transaction.inputs.filter(({utxo}: any) => utxo && utxo.type === 'Htlc'); + + const outpointedSourceIds: any[] = (inputs as any[]) + .filter(({ input }) => input.input_type === 'UTXO') + .map(({ input }) => { + const bytes = Uint8Array.from(input.source_id.match(/.{1,2}/g)!.map((byte: any) => parseInt(byte, 16))); + return { + source_id: encode_outpoint_source_id(bytes, SourceId.Transaction), + index: input.index, + }; + }); + + const htlc_outpoint_source_id: any = outpointedSourceIds[0].source_id; + const htlc_output_index: any = outpointedSourceIds[0].index; + + const secret = extract_htlc_secret( + transaction_signed, + true, + htlc_outpoint_source_id, + htlc_output_index + ); + + return secret; + } + async signTransaction(tx: Transaction): Promise { this.ensureInitialized(); return this.request({ @@ -2806,6 +3073,14 @@ class Client { }); } + async requestSecretHash(args: any): Promise { + this.ensureInitialized(); + return this.request({ + method: 'requestSecretHash', + params: {}, + }); + } + /** * Returns a preview of UTXO changes (spent/created) for a built transaction. * @@ -2840,7 +3115,7 @@ class Client { outpoint: { index, source_type: SourceId.Transaction, - source_id: tx.transaction_id, + source_id: tx.JSONRepresentation.id, }, utxo: { type: output.type, @@ -2854,7 +3129,7 @@ class Client { outpoint: { index, source_type: SourceId.Transaction, - source_id: tx.transaction_id, + source_id: tx.JSONRepresentation.id, }, // @ts-ignore utxo: { @@ -2866,6 +3141,24 @@ class Client { }, }); } + if (output.type === 'Htlc') { + created.push({ + outpoint: { + index, + source_type: SourceId.Transaction, + source_id: tx.JSONRepresentation.id, + }, + // @ts-ignore + utxo: { + // @ts-ignore + type: output.type, + // @ts-ignore + value: output.value, + // @ts-ignore + htlc: output.htlc, + }, + }); + } }); return { spent, created }; diff --git a/packages/sdk/tests/__snapshots__/htlc.test.ts.snap b/packages/sdk/tests/__snapshots__/htlc.test.ts.snap new file mode 100644 index 0000000..5e415ed --- /dev/null +++ b/packages/sdk/tests/__snapshots__/htlc.test.ts.snap @@ -0,0 +1,522 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`buildTransaction for htlc refund 1`] = ` +{ + "BINRepresentation": { + "inputs": [ + Uint8Array [ + 0, + 0, + 81, + 57, + 50, + 137, + 15, + 177, + 254, + 233, + 178, + 29, + 48, + 4, + 212, + 41, + 46, + 126, + 172, + 232, + 117, + 63, + 67, + 214, + 1, + 1, + 61, + 99, + 91, + 139, + 17, + 149, + 242, + 7, + 0, + 0, + 0, + 0, + ], + Uint8Array [ + 0, + 0, + 175, + 59, + 95, + 173, + 32, + 246, + 249, + 126, + 178, + 16, + 147, + 78, + 148, + 33, + 118, + 247, + 247, + 208, + 247, + 4, + 35, + 89, + 6, + 89, + 238, + 14, + 2, + 23, + 5, + 58, + 124, + 171, + 0, + 0, + 0, + 0, + ], + ], + "outputs": [ + Uint8Array [ + 0, + 0, + 7, + 0, + 16, + 165, + 212, + 232, + 1, + 134, + 236, + 69, + 4, + 87, + 208, + 154, + 217, + 128, + 115, + 147, + 216, + 156, + 236, + 156, + 113, + 214, + 32, + 96, + 33, + ], + Uint8Array [ + 0, + 0, + 7, + 0, + 215, + 72, + 204, + 224, + 1, + 134, + 236, + 69, + 4, + 87, + 208, + 154, + 217, + 128, + 115, + 147, + 216, + 156, + 236, + 156, + 113, + 214, + 32, + 96, + 33, + ], + ], + "transactionsize": 345, + }, + "HEXRepresentation_unsigned": "0100080000513932890fb1fee9b21d3004d4292e7eace8753f43d601013d635b8b1195f207000000000000af3b5fad20f6f97eb210934e942176f7f7d0f70423590659ee0e0217053a7cab00000000080000070010a5d4e80186ec450457d09ad9807393d89cec9c71d620602100000700d748cce00186ec450457d09ad9807393d89cec9c71d6206021", + "JSONRepresentation": { + "fee": { + "atoms": "34500000000", + "decimal": "0.345", + }, + "id": "63c90b6d244cdf901322fa7e75fb6499a8e7a30152d573626e5a10b06befe65a", + "inputs": [ + { + "input": { + "index": 0, + "input_type": "UTXO", + "source_id": "513932890fb1fee9b21d3004d4292e7eace8753f43d601013d635b8b1195f207", + "source_type": 0, + }, + "utxo": { + "htlc": { + "refund_key": "tmt1qxrwc3gy2lgf4kvqwwfa388vn3cavgrqyyrgswe6", + "refund_timelock": { + "content": 20, + "type": "ForBlockCount", + }, + "secret_hash": { + "hex": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", + "string": null, + }, + "spend_key": "tmt1q9mfg7d6ul2nt5yhmm7l7r6wwyqkd822rymr83uc", + }, + "type": "Htlc", + "value": { + "amount": { + "atoms": "1000000000000", + "decimal": "10", + }, + "type": "Coin", + }, + }, + }, + { + "input": { + "index": 0, + "input_type": "UTXO", + "source_id": "af3b5fad20f6f97eb210934e942176f7f7d0f70423590659ee0e0217053a7cab", + "source_type": "Transaction", + }, + "utxo": { + "destination": "tmt1q9l0g4kd3s6x5rmesaznegz06pw9hxu6qvqu3pa7", + "type": "Transfer", + "value": { + "amount": { + "atoms": "1000000000000", + "decimal": "10", + }, + "type": "Coin", + }, + }, + }, + ], + "outputs": [ + { + "destination": "tmt1qxrwc3gy2lgf4kvqwwfa388vn3cavgrqyyrgswe6", + "type": "Transfer", + "value": { + "amount": { + "atoms": "1000000000000", + "decimal": "10", + }, + "type": "Coin", + }, + }, + { + "destination": "tmt1qxrwc3gy2lgf4kvqwwfa388vn3cavgrqyyrgswe6", + "type": "Transfer", + "value": { + "amount": { + "atoms": "965500000000", + "decimal": "9.655", + }, + "type": "Coin", + }, + }, + ], + }, + "transaction_id": "63c90b6d244cdf901322fa7e75fb6499a8e7a30152d573626e5a10b06befe65a", +} +`; + +exports[`buildTransaction for transfer - snapshot 2 1`] = ` +{ + "BINRepresentation": { + "inputs": [ + Uint8Array [ + 0, + 0, + 175, + 59, + 95, + 173, + 32, + 246, + 249, + 126, + 178, + 16, + 147, + 78, + 148, + 33, + 118, + 247, + 247, + 208, + 247, + 4, + 35, + 89, + 6, + 89, + 238, + 14, + 2, + 23, + 5, + 58, + 124, + 171, + 0, + 0, + 0, + 0, + ], + Uint8Array [ + 0, + 0, + 106, + 240, + 156, + 227, + 73, + 188, + 62, + 218, + 108, + 20, + 240, + 25, + 37, + 125, + 11, + 57, + 172, + 153, + 198, + 247, + 76, + 87, + 238, + 189, + 216, + 153, + 158, + 58, + 3, + 156, + 55, + 104, + 1, + 0, + 0, + 0, + ], + ], + "outputs": [ + Uint8Array [ + 10, + 0, + 7, + 0, + 16, + 165, + 212, + 232, + 169, + 74, + 143, + 229, + 204, + 177, + 155, + 166, + 28, + 76, + 8, + 115, + 211, + 145, + 233, + 135, + 152, + 47, + 187, + 211, + 1, + 118, + 148, + 121, + 186, + 231, + 213, + 53, + 208, + 151, + 222, + 253, + 255, + 15, + 78, + 113, + 1, + 102, + 157, + 74, + 25, + 2, + 80, + 1, + 134, + 236, + 69, + 4, + 87, + 208, + 154, + 217, + 128, + 115, + 147, + 216, + 156, + 236, + 156, + 113, + 214, + 32, + 96, + 33, + ], + Uint8Array [ + 0, + 0, + 15, + 204, + 3, + 207, + 252, + 53, + 254, + 5, + 1, + 134, + 236, + 69, + 4, + 87, + 208, + 154, + 217, + 128, + 115, + 147, + 216, + 156, + 236, + 156, + 113, + 214, + 32, + 96, + 33, + ], + ], + "transactionsize": 390, + }, + "HEXRepresentation_unsigned": "0100080000af3b5fad20f6f97eb210934e942176f7f7d0f70423590659ee0e0217053a7cab0000000000006af09ce349bc3eda6c14f019257d0b39ac99c6f74c57eebdd8999e3a039c376801000000080a00070010a5d4e8a94a8fe5ccb19ba61c4c0873d391e987982fbbd301769479bae7d535d097defdff0f4e7101669d4a1902500186ec450457d09ad9807393d89cec9c71d620602100000fcc03cffc35fe050186ec450457d09ad9807393d89cec9c71d6206021", + "JSONRepresentation": { + "fee": { + "atoms": "139000000000", + "decimal": "1.39", + }, + "id": "e7df67c55b06aa147a3e024112f1a87dacf01bdf288d9dd5f8baa930a13137ab", + "inputs": [ + { + "input": { + "index": 0, + "input_type": "UTXO", + "source_id": "af3b5fad20f6f97eb210934e942176f7f7d0f70423590659ee0e0217053a7cab", + "source_type": "Transaction", + }, + "utxo": { + "destination": "tmt1q9l0g4kd3s6x5rmesaznegz06pw9hxu6qvqu3pa7", + "type": "Transfer", + "value": { + "amount": { + "atoms": "1000000000000", + "decimal": "10", + }, + "type": "Coin", + }, + }, + }, + { + "input": { + "index": 1, + "input_type": "UTXO", + "source_id": "6af09ce349bc3eda6c14f019257d0b39ac99c6f74c57eebdd8999e3a039c3768", + "source_type": "Transaction", + }, + "utxo": { + "destination": "tmt1qxrwc3gy2lgf4kvqwwfa388vn3cavgrqyyrgswe6", + "type": "Transfer", + "value": { + "amount": { + "atoms": "1687021711700428", + "decimal": "16870.21711700428", + }, + "type": "Coin", + }, + }, + }, + ], + "outputs": [ + { + "htlc": { + "refund_key": "tmt1qxrwc3gy2lgf4kvqwwfa388vn3cavgrqyyrgswe6", + "refund_timelock": { + "content": 20, + "type": "ForBlockCount", + }, + "secret_hash": { + "hex": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", + "string": null, + }, + "spend_key": "tmt1q9mfg7d6ul2nt5yhmm7l7r6wwyqkd822rymr83uc", + }, + "type": "Htlc", + "value": { + "amount": { + "atoms": "1000000000000", + "decimal": "10", + }, + "type": "Coin", + }, + }, + { + "destination": "tmt1qxrwc3gy2lgf4kvqwwfa388vn3cavgrqyyrgswe6", + "type": "Transfer", + "value": { + "amount": { + "atoms": "1686882711700428", + "decimal": "16868.82711700428", + }, + "type": "Coin", + }, + }, + ], + }, + "transaction_id": "e7df67c55b06aa147a3e024112f1a87dacf01bdf288d9dd5f8baa930a13137ab", +} +`; diff --git a/packages/sdk/tests/htlc.test.ts b/packages/sdk/tests/htlc.test.ts new file mode 100644 index 0000000..0ec9f4d --- /dev/null +++ b/packages/sdk/tests/htlc.test.ts @@ -0,0 +1,367 @@ +import { Client } from '../src/mintlayer-connect-sdk'; +import fetchMock from 'jest-fetch-mock'; +import { createHash } from 'crypto' + +import { addresses, utxos } from './__mocks__/accounts/account_01' + +beforeEach(() => { + fetchMock.resetMocks(); + + // эмуляция window.mojito + (window as any).mojito = { + isExtension: true, + connect: jest.fn().mockResolvedValue(addresses), + restore: jest.fn().mockResolvedValue(addresses), + disconnect: jest.fn().mockResolvedValue(undefined), + request: jest.fn().mockResolvedValue('signed-transaction'), + }; + + fetchMock.doMock(); + + fetchMock.mockResponse(async req => { + const url = req.url; + + if (url.endsWith('/chain/tip')) { + return JSON.stringify({ height: 200000 }); + } + + if (url.includes('/transaction/')) { + const txId = url.split('/transaction/').pop(); + if(txId === '513932890fb1fee9b21d3004d4292e7eace8753f43d601013d635b8b1195f207') { + return JSON.stringify({ + "block_id": "445e7c4520afd0f1296aebbe87d8547d9025dd99a86e594d178efaefd6db1f6d", + "confirmations": "913", + "fee": { + "atoms": "124900000000", + "decimal": "1.249" + }, + "flags": 0, + "id": "513932890fb1fee9b21d3004d4292e7eace8753f43d601013d635b8b1195f207", + "inputs": [ + { + "input": { + "index": 1, + "input_type": "UTXO", + "source_id": "92b08778d6d0345f1f943f83e7969fbcece9629938dddcec94f0b28382a58feb", + "source_type": "Transaction" + }, + "utxo": { + "destination": "tmt1qxrwc3gy2lgf4kvqwwfa388vn3cavgrqyyrgswe6", + "type": "Transfer", + "value": { + "amount": { + "atoms": "1689595604300000", + "decimal": "16895.956043" + }, + "type": "Coin" + } + } + } + ], + "is_replaceable": false, + "outputs": [ + { + "htlc": { + "refund_key": "tmt1qxrwc3gy2lgf4kvqwwfa388vn3cavgrqyyrgswe6", + "refund_timelock": { + "content": 20, + "type": "ForBlockCount" + }, + "secret_hash": { + "hex": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", + "string": null + }, + "spend_key": "tmt1q9mfg7d6ul2nt5yhmm7l7r6wwyqkd822rymr83uc" + }, + "type": "Htlc", + "value": { + "amount": { + "atoms": "1000000000000", + "decimal": "10" + }, + "type": "Coin" + } + }, + { + "destination": "tmt1qxrwc3gy2lgf4kvqwwfa388vn3cavgrqyyrgswe6", + "type": "Transfer", + "value": { + "amount": { + "atoms": "1688470704300000", + "decimal": "16884.707043" + }, + "type": "Coin" + } + } + ], + "timestamp": "1749276116", + "version_byte": 1 + }); + } + if(txId === '5a6752ae5d4da45c9f163d0f1b24aed13e3fda88b11742469b202b37f7b9e38f') { + return JSON.stringify({ + "block_id": "abe6ebe0f1b51f9c404348d0e9fb2a3ef1dadfdb67ad7644ca940aebe392c7d4", + "confirmations": "12", + "fee": { + "atoms": "134500000000", + "decimal": "1.345" + }, + "flags": 0, + "id": "5a6752ae5d4da45c9f163d0f1b24aed13e3fda88b11742469b202b37f7b9e38f", + "inputs": [ + { + "input": { + "index": 0, + "input_type": "UTXO", + "source_id": "408a1e5a8c59ed10ffc6a55244f29e465b692223ef6e6ef05b03a3a4b6010507", + "source_type": "Transaction" + }, + "utxo": { + "htlc": { + "refund_key": "tmt1qxrwc3gy2lgf4kvqwwfa388vn3cavgrqyyrgswe6", + "refund_timelock": { + "content": 20, + "type": "ForBlockCount" + }, + "secret_hash": { + "hex": "d5777dbd9541baea8a562381387323773b18e0f6", + "string": null + }, + "spend_key": "tmt1q9mfg7d6ul2nt5yhmm7l7r6wwyqkd822rymr83uc" + }, + "type": "Htlc", + "value": { + "amount": { + "atoms": "1000000000000", + "decimal": "10" + }, + "type": "Coin" + } + } + }, + { + "input": { + "index": 0, + "input_type": "UTXO", + "source_id": "e4e82208a042b4c30be2d3f49ef880cd5393345d25a87de096cebf96b90751ae", + "source_type": "Transaction" + }, + "utxo": { + "destination": "tmt1qyrjfd5e3nref7zga24jcthffahjwyg3csxu3xgc", + "type": "Transfer", + "value": { + "amount": { + "atoms": "1000000000000", + "decimal": "10" + }, + "type": "Coin" + } + } + } + ], + "is_replaceable": false, + "outputs": [ + { + "destination": "tmt1qxrwc3gy2lgf4kvqwwfa388vn3cavgrqyyrgswe6", + "type": "Transfer", + "value": { + "amount": { + "atoms": "1000000000000", + "decimal": "10" + }, + "type": "Coin" + } + }, + { + "destination": "tmt1qxrwc3gy2lgf4kvqwwfa388vn3cavgrqyyrgswe6", + "type": "Transfer", + "value": { + "amount": { + "atoms": "865500000000", + "decimal": "8.655" + }, + "type": "Coin" + } + } + ], + "timestamp": "1749419790", + "version_byte": 1 + }); + } + } + + if (url.includes('/token/')) { + const tokenId = url.split('/token/').pop(); + if (tokenId === 'tmltk1jzgup986mh3x9n5024svm4wtuf2qp5vedlgy5632wah0pjffwhpqgsvmuq') { + return JSON.stringify({ + "authority": "tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx", + "circulating_supply": { + "atoms": "209000000000", + "decimal": "2090" + }, + "frozen": false, + "is_locked": false, + "is_token_freezable": true, + "is_token_unfreezable": null, + "metadata_uri": { + "hex": "697066733a2f2f516d4578616d706c6548617368313233", + "string": "ipfs://QmExampleHash123" + }, + "next_nonce": 7, + "number_of_decimals": 8, + "token_ticker": { + "hex": "58595a32", + "string": "XYZ2" + }, + "total_supply": { + "Fixed": { + "atoms": "100000000000000" + } + } + }); + } + if (tokenId === 'tmltk17jgtcm3gc8fne3su8s96gwj0yw8k2khx3fglfe8mz72jhygemgnqm57l7l') { + return JSON.stringify({ + "authority": "tmt1qyjlh9w9t7qwx7cawlqz6rqwapflsvm3dulgmxyx", + "circulating_supply": { + "atoms": "209000000000", + "decimal": "2090" + }, + "frozen": false, + "is_locked": false, + "is_token_freezable": true, + "is_token_unfreezable": null, + "metadata_uri": { + "hex": "697066733a2f2f516d4578616d706c6548617368313233", + "string": "ipfs://QmExampleHash123" + }, + "next_nonce": 7, + "number_of_decimals": 11, + "token_ticker": { + "hex": "58595a32", + "string": "XYZ2" + }, + "total_supply": { + "Fixed": { + "atoms": "100000000000000" + } + } + }); + } + return JSON.stringify({ a: 'b' }); + } + + if(url.endsWith('/account')) { + return { + body: JSON.stringify({ + utxos: utxos, + }), + }; + } + + console.warn('No mock for:', url); + return JSON.stringify({ error: 'No mock defined' }); + }); +}); + +test('buildTransaction for transfer - snapshot', async () => { + const client = await Client.create({ network: 'testnet', autoRestore: false }); + + const spy = jest.spyOn(Client.prototype as any, 'buildTransaction'); + + await client.connect(); + + await client.createHtlc({ + amount: "10", + spend_address: "tmt1q9mfg7d6ul2nt5yhmm7l7r6wwyqkd822rymr83uc", + refund_address: "tmt1qxrwc3gy2lgf4kvqwwfa388vn3cavgrqyyrgswe6", + refund_timelock: { + type: "UntilTime", + content: { + timestamp: '1749239730' + } + }, + token_id: null, // null for native token + // token_id: "tmltk1jzgup986mh3x9n5024svm4wtuf2qp5vedlgy5632wah0pjffwhpqgsvmuq", + secret_hash: "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", + }); + + const result = await spy.mock.results[0]?.value; + + console.log('result', result); + + console.log(JSON.stringify(result.JSONRepresentation, null, 2)); +}); + +// example of TX: 513932890fb1fee9b21d3004d4292e7eace8753f43d601013d635b8b1195f207 +test('buildTransaction for transfer - snapshot 2', async () => { + const client = await Client.create({ network: 'testnet', autoRestore: false }); + + const spy = jest.spyOn(Client.prototype as any, 'buildTransaction'); + + await client.connect(); + + // secret; + const secret = new Uint8Array([47, 236, 147, 140, 26, 135, 53, 164, 102, 152, 202, 10, 164, 83, 156, 186, 199, 3, 110, 204, 10, 144, 10, 244, 63, 197, 236, 4, 89, 26, 72, 4]); + const sha256 = createHash('sha256').update(secret).digest(); + const ripemd160 = createHash('ripemd160').update(sha256).digest(); + + const secret_hash_hex = Buffer.from(ripemd160).toString('hex'); + + console.log('sha256', sha256); + console.log('ripemd160', ripemd160); + console.log('secret_hash_hex', secret_hash_hex); + + await client.createHtlc({ + amount: "10", + spend_address: "tmt1q9mfg7d6ul2nt5yhmm7l7r6wwyqkd822rymr83uc", + refund_address: "tmt1qxrwc3gy2lgf4kvqwwfa388vn3cavgrqyyrgswe6", + refund_timelock: { + type: "ForBlockCount", + content: "20" + }, + token_id: null, // null for native token + // token_id: "tmltk1jzgup986mh3x9n5024svm4wtuf2qp5vedlgy5632wah0pjffwhpqgsvmuq", + secret_hash: { hex: secret_hash_hex }, // "test" + }); + + const result = await spy.mock.results[0]?.value; + + console.log('result', result); + + console.log(JSON.stringify(result.JSONRepresentation, null, 2)); + expect(result).toMatchSnapshot(); +}); + +test('buildTransaction for htlc refund', async () => { + const client = await Client.create({ network: 'testnet', autoRestore: false }); + + const spy = jest.spyOn(Client.prototype as any, 'buildTransaction'); + + await client.connect(); + + await client.refundHtlc({ + transaction_id: "513932890fb1fee9b21d3004d4292e7eace8753f43d601013d635b8b1195f207", + }); + + const result = await spy.mock.results[0]?.value; + + console.log('result', result); + console.log(JSON.stringify(result.JSONRepresentation, null, 2)); + expect(result).toMatchSnapshot(); +}) + +test('extract Htlc from transaction', async () => { + const client = await Client.create({ network: 'testnet', autoRestore: false }); + + await client.connect(); + + const secret = await client.extractHtlcSecret({ + transaction_id: "5a6752ae5d4da45c9f163d0f1b24aed13e3fda88b11742469b202b37f7b9e38f", + transaction_hex: "0100080000408a1e5a8c59ed10ffc6a55244f29e465b692223ef6e6ef05b03a3a4b6010507000000000000e4e82208a042b4c30be2d3f49ef880cd5393345d25a87de096cebf96b90751ae00000000080000070010a5d4e80186ec450457d09ad9807393d89cec9c71d620602100000700efd183c90186ec450457d09ad9807393d89cec9c71d62060210801011902002fec938c1a8735a46698ca0aa4539cbac7036ecc0a900af43fc5ec04591a48048d01000263e8a1cbb56634ef88997b93fed52b3420fcc7d169954b67def9dce82b549953007ff174e51f07617fe6a6d4eda904999a4468d464b9a82df43bf5fac01dd73241fc8f6ca2822050d61591d04aa4bdc3c484d222882ec5dca2b02738b87b5a3dbc01018d010003b8b4a52ce4957f998479c5e881133648b95fc4b0c54bd58c7d32d37c4d0f235a007765bf6b6b50d44dca4bba8e95c40d610d1130535c22a4f50f3cd9d9b432938d436348180153aaf905a6d8b330e428c8d0d92402bb3cfbd21da02eacd426ad9b", + }); + + const secret_original = new Uint8Array([47, 236, 147, 140, 26, 135, 53, 164, 102, 152, 202, 10, 164, 83, 156, 186, 199, 3, 110, 204, 10, 144, 10, 244, 63, 197, 236, 4, 89, 26, 72, 4]); + + expect(Array.from(secret)).toEqual(Array.from(secret_original)); +});