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
+
+
+
+
Request Secret Hash
+
+
+
+
+
+
+
+
+
+
+
Refund HTLC
+
+
+
+
+
@@ -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));
+});