diff --git a/packages/sdk/src/mintlayer-connect-sdk.ts b/packages/sdk/src/mintlayer-connect-sdk.ts index ad869c0..b2f145a 100644 --- a/packages/sdk/src/mintlayer-connect-sdk.ts +++ b/packages/sdk/src/mintlayer-connect-sdk.ts @@ -41,6 +41,9 @@ import initWasm, { encode_output_data_deposit, encode_output_create_delegation, encode_output_delegate_staking, + encode_signed_transaction, + encode_witness, + SignatureHashType, } from '@mintlayer/wasm-lib'; const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; @@ -170,14 +173,43 @@ interface UtxoEntry { utxo: Utxo; } -type Utxo = { - type: 'Transfer' | 'LockThenTransfer' | 'IssueNft'; +type BaseUtxo = { value: Value; destination: string; token_id?: string; - data?: any; // split NFT utxo }; +type TransferUtxo = BaseUtxo & { + type: 'Transfer'; +}; + +type LockThenTransferUtxo = BaseUtxo & { + type: 'LockThenTransfer'; + lock: { + type: 'ForBlockCount' | 'UntilTime'; + content: string; + }; +}; + +type IssueNftUtxo = { + type: 'IssueNft'; + value: Value; + destination: string; + token_id?: string; + data: { + name: { hex: string; string: string }; + ticker: { hex: string; string: string }; + description: { hex: string; string: string }; + media_hash: { hex: string; string: string }; + media_uri: { hex: string; string: string }; + icon_uri: { hex: string; string: string }; + additional_metadata_uri: { hex: string; string: string }; + creator: string | null; + }; +}; + +type Utxo = TransferUtxo | LockThenTransferUtxo | IssueNftUtxo; + type UtxoInput = { input: { index: number; @@ -1086,10 +1118,10 @@ class Client { async request({ method, params }: { method: string; params?: Record }): Promise { this.ensureInitialized(); - if (typeof window !== 'undefined' && window.mojito?.request) { - return await window.mojito.request(method, params); + if (typeof this.accountProvider.request !== 'undefined') { + return await this.accountProvider.request(method, params); } else { - throw new Error('Mojito extension not available'); + throw new Error('request method not implemented in the account provider'); } } @@ -2835,7 +2867,21 @@ class Client { }); tx.JSONRepresentation.outputs.forEach((output, index) => { - if (output.type === 'Transfer' || output.type === 'LockThenTransfer') { + if (output.type === 'Transfer') { + created.push({ + outpoint: { + index, + source_type: SourceId.Transaction, + source_id: tx.transaction_id, + }, + utxo: { + type: output.type, + value: output.value, + destination: output.destination, + }, + }); + } + if (output.type === 'LockThenTransfer') { created.push({ outpoint: { index, @@ -2846,6 +2892,7 @@ class Client { type: output.type, value: output.value, destination: output.destination, + lock: output.lock, }, }); } @@ -2964,6 +3011,142 @@ class Client { } } -export { Client }; +class Signer { + private keys: Record; + + constructor(privateKeys: Record) { + this.keys = privateKeys; + } + + private getPrivateKey(address: string): Uint8Array | undefined { + return this.keys[address]; + } + + private createSignature(tx: Transaction) { + const network = Network.Testnet; // TODO: make network configurable + const optUtxos_ = tx.JSONRepresentation.inputs.map((input) => { + if (input.input.input_type !== 'UTXO') { + return 0 + } + const { utxo }: UtxoInput = input as UtxoInput; + if (input.input.input_type === 'UTXO') { + if (utxo.type === 'Transfer') { + if (utxo.value.type === 'TokenV1') { + return encode_output_token_transfer(Amount.from_atoms(utxo.value.amount.atoms), utxo.destination, utxo.value.token_id, network); + } else { + return encode_output_transfer(Amount.from_atoms(utxo.value.amount.atoms), utxo.destination, network); + } + } + if (utxo.type === 'LockThenTransfer') { + let lockEncoded: Uint8Array = new Uint8Array(); + if (utxo.lock.type === 'UntilTime') { + // @ts-ignore + lockEncoded = encode_lock_until_time(BigInt(utxo.lock.content.timestamp)); // TODO: check if timestamp is correct + } + if (utxo.lock.type === 'ForBlockCount') { + lockEncoded = encode_lock_for_block_count(BigInt(utxo.lock.content)); + } + if (utxo.value.type === 'TokenV1') { + return encode_output_token_lock_then_transfer(Amount.from_atoms(utxo.value.amount.atoms), utxo.destination, utxo.value.token_id, lockEncoded, network); + } else { + return encode_output_lock_then_transfer(Amount.from_atoms(utxo.value.amount.atoms), utxo.destination, lockEncoded, network); + } + } + return null + } + }) + + const optUtxosArray: number[] = []; + + for (let i = 0; i < optUtxos_.length; i++) { + const utxoBytes = optUtxos_[i]; + if (tx.JSONRepresentation.inputs[i].input.input_type !== 'UTXO') { + optUtxosArray.push(0); + } else { + if (!(utxoBytes instanceof Uint8Array)) { + throw new Error(`optUtxos_[${i}] is not a valid Uint8Array`); + } + optUtxosArray.push(1); + optUtxosArray.push(...utxoBytes); + } + } + + const optUtxos = new Uint8Array(optUtxosArray); + + const encodedWitnesses = tx.JSONRepresentation.inputs.map( + (input, index) => { + let address: string | undefined = undefined; + + if(input.input.input_type === 'UTXO') { + const utxoInput = input as UtxoInput; + address = utxoInput.utxo.destination; + } + + if (input.input.input_type === 'AccountCommand') { + // @ts-ignore + address = input.input.authority; + } + + if (input.input.input_type === 'AccountCommand' && input.input.command === 'FillOrder') { + address = input.input.destination; + } + + if (address === undefined) { + throw new Error(`Address not found for input at index ${index}`); + } + + const addressPrivateKey = this.getPrivateKey(address) + + if (!addressPrivateKey) { + throw new Error(`Private key not found for address: ${address}`); + } + + const transaction = this.hexToUint8Array(tx.HEXRepresentation_unsigned); + + const witness = encode_witness( + SignatureHashType.ALL, + addressPrivateKey, + address, + transaction, + optUtxos, + index, + network, + ) + return witness + }, + ) + + const signature = mergeUint8Arrays(encodedWitnesses); + return signature; + } + + private hexToUint8Array(hex: string): Uint8Array { + if (hex.length % 2 !== 0) { + throw new Error("Hex string must have an even length"); + } + + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); + } + return bytes; + } + + private encodeSignedTransaction(tx: Transaction, signature: Uint8Array): string { + const transaction_signed = encode_signed_transaction( + this.hexToUint8Array(tx.HEXRepresentation_unsigned), + signature + ); + const transaction_signed_hex = transaction_signed.reduce((acc, byte) => acc + byte.toString(16).padStart(2, '0'), ''); + return transaction_signed_hex; + } + + sign(tx: Transaction): string { + const signature = this.createSignature(tx); + return this.encodeSignedTransaction(tx, signature); + } +} + +export { Client, Signer }; console.log('[Mintlayer Connect SDK] Loaded'); diff --git a/packages/sdk/tests/__mocks__/accounts/account_signer.ts b/packages/sdk/tests/__mocks__/accounts/account_signer.ts new file mode 100644 index 0000000..8321109 --- /dev/null +++ b/packages/sdk/tests/__mocks__/accounts/account_signer.ts @@ -0,0 +1,55 @@ +export const seed_phrase = process.env.TEST_SEED_PHRASE || 'test'; + +export const addresses: any = { + mainnet: {}, + testnet: { + receiving: ['tmt1qycauu4rc92v80vpjrtkqjv2utr7jl5ygve28sdt'], + change: ['tmt1qxrwc3gy2lgf4kvqwwfa388vn3cavgrqyyrgswe6'], + }, +}; + +export const private_keys: any = { + tmt1qycauu4rc92v80vpjrtkqjv2utr7jl5ygve28sdt: new Uint8Array([ + 0, 139, 105, 103, 196, 210, 140, 25, 46, 11, 136, 102, 78, 67, 84, 72, 173, 232, 249, 67, 162, 63, 19, 205, 61, 111, + 80, 28, 151, 253, 179, 245, 236, + ]), +}; + +export const utxos: any = [ + { + outpoint: { + index: 0, + source_id: '5b43a37eb8c73e981c9a718c52706a897dac9b0093182da9af2997803c1508f1', + source_type: 'Transaction', + }, + utxo: { + destination: 'tmt1qycauu4rc92v80vpjrtkqjv2utr7jl5ygve28sdt', + type: 'Transfer', + value: { + amount: { + atoms: '2000000000000', + decimal: '20', + }, + type: 'Coin', + }, + }, + }, + { + outpoint: { + index: 0, + source_id: '9d214cf61b322cfb257e3c145cba5085763e7b82cc5a20e5e54821549778ee1a', + source_type: 'Transaction', + }, + utxo: { + destination: 'tmt1qycauu4rc92v80vpjrtkqjv2utr7jl5ygve28sdt', + type: 'Transfer', + value: { + amount: { + atoms: '4500000000000', + decimal: '45', + }, + type: 'Coin', + }, + }, + }, +]; diff --git a/packages/sdk/tests/signer.test.ts b/packages/sdk/tests/signer.test.ts new file mode 100644 index 0000000..fb2ff9c --- /dev/null +++ b/packages/sdk/tests/signer.test.ts @@ -0,0 +1,137 @@ +import { AccountProvider, Client, Signer } from '../src/mintlayer-connect-sdk'; +import fetchMock from 'jest-fetch-mock'; + +import { addresses, utxos, private_keys } from './__mocks__/accounts/account_signer' + +class TestAccountProvider implements AccountProvider { + async connect() { + return addresses; + } + + async restore() { + return addresses; + } + + async disconnect() { + return Promise.resolve() + } + + async request(method: any, params: any) { + if( method === 'signTransaction') { + const signer = new Signer(private_keys); + const transaction_signed = signer.sign(params.txData); + + return Promise.resolve(transaction_signed); + } + + return Promise.resolve('signed-transaction-custom-signer'); + } +} + +beforeEach(() => { + fetchMock.resetMocks(); + + fetchMock.doMock(); + + fetchMock.mockResponse(async req => { + const url = req.url; + + if (url.endsWith('/chain/tip')) { + return JSON.stringify({ height: 200000 }); + } + + 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 and sign it', async () => { + const client = await Client.create({ + network: 'testnet', + autoRestore: false, + accountProvider: new TestAccountProvider() + }); + + const spy = jest.spyOn(Client.prototype as any, 'buildTransaction'); + + await client.connect(); + + const tx = await client.transfer({ + to: 'tmt1q9mfg7d6ul2nt5yhmm7l7r6wwyqkd822rymr83uc', + amount: 10, + }); + + const expectedTx = '01000400009d214cf61b322cfb257e3c145cba5085763e7b82cc5a20e5e54821549778ee1a00000000080000070010a5d4e801769479bae7d535d097defdff0f4e7101669d4a1900000b008b5c222a030186ec450457d09ad9807393d89cec9c71d62060210401018d010003e499331885d47bf31b8c8706b69d5a0e839599f8247c008226a151f50365e749002c958e556919e75f467064a4f51ce27093de52d271512e6d064c524c49ac81086ddc4cf9a6766f8f28e699123e678f54496da7bfe34e30f08229ca96861881a4'; + + // compare first 280 characters of the transaction. TODO: use a more robust way to compare transactions + expect(tx.slice(0, 280)).toBe(expectedTx.slice(0, 280)); +});