diff --git a/example/safe_inscription.js b/example/safe_inscription.js index cb7f47fb..01b5c016 100644 --- a/example/safe_inscription.js +++ b/example/safe_inscription.js @@ -1,6 +1,5 @@ const { MixinApi } = require('..'); const keystore = require('../keystore.json'); -const { v4 } = require('uuid'); const main = async () => { console.log(keystore); @@ -15,6 +14,25 @@ const main = async () => { // for (const item of items) { // console.log('item: ', item); // } + + // the spend private key defaults to keystore.spend_private_key + // const results = await client.safe.transferInscription({ + // inscriptionHash: '94d20f04829dcfb2c6d3cdb7ba94b3f6b402eb0537d6aa48f76e14d21e84c784', + // receivers: ['7766b24c-1a03-4c3a-83a3-b4358266875d'], + // threshold: 1, + // memo: 'enjoy your collectible', + // }); + // console.log('transfer results: ', results); + + // or spend a known inscription output directly + // const outputs = await client.utxo.safeOutputs({ state: 'unspent' }); + // const utxo = outputs.find(o => o.inscription_hash); + // const results = await client.safe.transferInscription({ + // utxo, + // receivers: ['7766b24c-1a03-4c3a-83a3-b4358266875d'], + // threshold: 1, + // }); + // console.log('transfer results: ', results); }; main(); diff --git a/src/client/safe.ts b/src/client/safe.ts index 695d99c8..987a6b44 100644 --- a/src/client/safe.ts +++ b/src/client/safe.ts @@ -12,8 +12,10 @@ import type { SafeSnapshot, SafeSnapshotsRequest, SafeWithdrawalFee, + SequencerTransactionRequest, } from './types'; -import { buildClient, signEd25519PIN, signSafeRegistration } from './utils'; +import { buildClient, signEd25519PIN, signSafeRegistration, transferInscription, type SafeTransferInscriptionRequest } from './utils'; +import { UtxoKeystoreClient } from './utxo'; export const SafeKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore | undefined) => ({ /** If you want to register safe user, you need to upgrade TIP PIN first. */ @@ -59,6 +61,16 @@ export const SafeKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keyst axiosInstance.get(`/safe/inscriptions/collections/${collectionHash}/items`, { params: offset && offset > 0 ? { offset } : undefined, }), + + /** + * Transfer an inscription (collectible) to a recipient. The spend private + * key defaults to the spend_private_key in the keystore. + */ + transferInscription: (data: SafeTransferInscriptionRequest): Promise => { + const spendPrivateKey = data.spendPrivateKey ?? (keystore as { spend_private_key?: string } | undefined)?.spend_private_key; + if (!spendPrivateKey) return Promise.reject(new Error('spend private key is required, set it in the keystore or pass spendPrivateKey')); + return transferInscription(UtxoKeystoreClient(axiosInstance), { ...data, spendPrivateKey }); + }, }); export const SafeClient = buildClient(SafeKeystoreClient); diff --git a/src/client/types/index.ts b/src/client/types/index.ts index 3d74aa08..2e6570be 100644 --- a/src/client/types/index.ts +++ b/src/client/types/index.ts @@ -10,6 +10,7 @@ export * from './conversation'; export * from './error'; export * from './external'; export * from './invoice'; +export * from './inscription'; export * from './keystore'; export * from './message'; export * from './mixin_transaction'; diff --git a/src/client/types/inscription.ts b/src/client/types/inscription.ts new file mode 100644 index 00000000..3cc283d4 --- /dev/null +++ b/src/client/types/inscription.ts @@ -0,0 +1,37 @@ +export type InscriptionMode = 1 | 2; + +export interface InscriptionTreasury { + ratio: string; + recipient: string; +} + +export interface InscriptionDeploy { + version: 1; + mode: InscriptionMode; + supply: string; + unit: string; + symbol: string; + name: string; + icon: string; + checksum?: string; + treasury?: InscriptionTreasury; +} + +export interface InscriptionInscribe { + operation: 'inscribe'; + recipient: string; + content?: string; +} + +export interface InscriptionDistribute { + /** the JSON key aligns with the Go SDK tag json:"distribute", the value must be the literal "distribute" */ + distribute: 'distribute'; + sequence: number; +} + +export interface InscriptionOccupy { + operation: 'occupy'; + sequence: number; +} + +export type InscriptionOperation = InscriptionDeploy | InscriptionInscribe | InscriptionDistribute | InscriptionOccupy; diff --git a/src/client/types/keystore.ts b/src/client/types/keystore.ts index f52082d9..7f578963 100644 --- a/src/client/types/keystore.ts +++ b/src/client/types/keystore.ts @@ -3,6 +3,7 @@ export interface AppKeystore { session_id: string; server_public_key: string; session_private_key: string; + spend_private_key?: string; } export interface OAuthKeystore { diff --git a/src/client/types/message.ts b/src/client/types/message.ts index c5c470ca..cf20a021 100644 --- a/src/client/types/message.ts +++ b/src/client/types/message.ts @@ -15,7 +15,9 @@ export type MessageCategory = | 'APP_BUTTON_GROUP' | 'MESSAGE_RECALL' | 'SYSTEM_CONVERSATION' - | 'SYSTEM_ACCOUNT_SNAPSHOT'; + | 'SYSTEM_ACCOUNT_SNAPSHOT' + | 'SYSTEM_SAFE_SNAPSHOT' + | 'SYSTEM_SAFE_INSCRIPTION'; export type EncryptedMessageStatus = 'SUCCESS' | 'FAILED'; diff --git a/src/client/types/multisig.ts b/src/client/types/multisig.ts index 6df0533f..d16e99da 100644 --- a/src/client/types/multisig.ts +++ b/src/client/types/multisig.ts @@ -104,4 +104,5 @@ export interface SafeMultisigsResponse { created_at: string; updated_at: string; views: string[]; + inscription_hash?: string; } diff --git a/src/client/types/safe.ts b/src/client/types/safe.ts index b98af739..50480f70 100644 --- a/src/client/types/safe.ts +++ b/src/client/types/safe.ts @@ -1,4 +1,5 @@ import type { MixAddress } from './address'; +import type { InscriptionTreasury } from './inscription'; // field for: // GET safe/assets @@ -77,6 +78,7 @@ export interface SafeSnapshot { closing_balance: string | null; deposit: SafeDeposit | null; withdrawal: SafeWithdrawal | null; + inscription_hash?: string; } export interface SafeDeposit { @@ -120,6 +122,7 @@ export interface SafeCollection { symbol: string; type: string; unit: string; + treasury?: InscriptionTreasury; created_at: string; updated_at: string; } diff --git a/src/client/types/utxo.ts b/src/client/types/utxo.ts index 0438a33d..cad29190 100644 --- a/src/client/types/utxo.ts +++ b/src/client/types/utxo.ts @@ -119,4 +119,8 @@ export interface PaymentParams { memo?: string; trace?: string; returnTo?: string; + /** hash of the inscription (NFT collectible) to be paid to the destination */ + inscription?: string; + /** hash of the inscription collection, lets the payer pick a collectible from it */ + inscriptionCollection?: string; } diff --git a/src/client/utils/index.ts b/src/client/utils/index.ts index 374b98a9..3892dde5 100644 --- a/src/client/utils/index.ts +++ b/src/client/utils/index.ts @@ -7,6 +7,7 @@ export * from './computer'; export * from './decoder'; export * from './ed25519'; export * from './encoder'; +export * from './inscription'; export * from './invoice'; export * from './multisigs'; export * from './nfo'; diff --git a/src/client/utils/inscription.ts b/src/client/utils/inscription.ts new file mode 100644 index 00000000..42e001a1 --- /dev/null +++ b/src/client/utils/inscription.ts @@ -0,0 +1,125 @@ +import { v4 } from 'uuid'; +import type { + GhostKey, + InscriptionDeploy, + InscriptionDistribute, + InscriptionInscribe, + InscriptionOccupy, + InscriptionOperation, + SafeOutputsRequest, + SafeTransactionRecipient, + SafeUtxoOutput, + SequencerTransactionRequest, + TransactionRequest, +} from '../types'; +import { buildSafeTransaction, buildSafeTransactionRecipient, encodeSafeTransaction, signSafeTransaction } from './safe'; + +export const InscriptionModeInstant = 1; +export const InscriptionModeDone = 2; + +export interface InscriptionUtxoClient { + safeOutputs: (params: SafeOutputsRequest) => Promise; + ghostKey: (recipients: SafeTransactionRecipient[], trace: string, spendPrivateKey: string) => Promise<(GhostKey | undefined)[]>; + verifyTransaction: (params: TransactionRequest[]) => Promise; + sendTransactions: (params: TransactionRequest[]) => Promise; +} + +export const buildInscriptionOperationExtra = (operation: InscriptionOperation): Buffer => Buffer.from(JSON.stringify(operation), 'utf8'); + +export const decodeInscriptionOperationExtra = (extra: Buffer | string): InscriptionOperation => { + const data = typeof extra === 'string' ? Buffer.from(extra, 'hex') : extra; + let operation: unknown; + try { + operation = JSON.parse(data.toString('utf8')); + } catch { + throw new Error('invalid inscription operation extra'); + } + if (!operation || typeof operation !== 'object') throw new Error('invalid inscription operation extra'); + + const op = operation as Record; + if (op.operation === 'inscribe' && typeof op.recipient === 'string') return operation as InscriptionInscribe; + if (op.distribute === 'distribute' && Number.isInteger(op.sequence)) return operation as InscriptionDistribute; + if (op.operation === 'occupy' && Number.isInteger(op.sequence)) return operation as InscriptionOccupy; + if (op.version === 1 && (op.mode === InscriptionModeInstant || op.mode === InscriptionModeDone)) return operation as InscriptionDeploy; + throw new Error('unknown inscription operation'); +}; + +export interface InscriptionOutputRequest { + members?: string[]; + threshold?: number; + limit?: number; +} + +export const getInscriptionOutput = async (utxo: InscriptionUtxoClient, inscriptionHash: string, request: InscriptionOutputRequest = {}): Promise => { + const { members, threshold, limit = 500 } = request; + + let offset: number | undefined; + for (;;) { + const outputs = await utxo.safeOutputs({ + members, + threshold, + state: 'unspent', + offset, + limit, + }); + + const found = outputs.find(o => o.inscription_hash === inscriptionHash); + if (found) return found; + + if (outputs.length < limit) break; + offset = outputs[outputs.length - 1].sequence; + } + + throw new Error(`unspent inscription output not found: ${inscriptionHash}`); +}; + +export interface TransferInscriptionParams { + inscriptionHash?: string; + utxo?: SafeUtxoOutput; + receivers: string[]; + threshold: number; + spendPrivateKey: string; + memo?: string; + signerIndex?: number; + request_id?: string; + members?: string[]; + ownershipThreshold?: number; +} + +export type SafeTransferInscriptionRequest = Omit & { + /** defaults to the spend_private_key in the keystore */ + spendPrivateKey?: string; +}; + +/** + * Transfer an inscription to a recipient by spending the inscription output + * entirely: inscriptions can't be split or merged, and no change is allowed. + */ +export const transferInscription = async (utxo: InscriptionUtxoClient, params: TransferInscriptionParams): Promise => { + let utxoOutput = params.utxo; + if (!utxoOutput) { + if (!params.inscriptionHash) throw new Error('either utxo or inscriptionHash is required to transfer an inscription'); + utxoOutput = await getInscriptionOutput(utxo, params.inscriptionHash, { + members: params.members, + threshold: params.ownershipThreshold, + }); + } + + if (!utxoOutput.inscription_hash) throw new Error('the output does not carry an inscription'); + if (params.inscriptionHash && utxoOutput.inscription_hash !== params.inscriptionHash) throw new Error('inscription hash mismatch'); + if (utxoOutput.state !== 'unspent') throw new Error(`the inscription output is ${utxoOutput.state}`); + + const request_id = params.request_id ?? v4(); + const extra = params.memo ? Buffer.from(params.memo, 'utf8') : Buffer.alloc(0); + const recipients = [buildSafeTransactionRecipient(params.receivers, params.threshold, utxoOutput.amount)]; + + const ghosts = await utxo.ghostKey(recipients, request_id, params.spendPrivateKey); + const tx = buildSafeTransaction([utxoOutput], recipients, ghosts, extra); + const raw = encodeSafeTransaction(tx); + + const verified = await utxo.verifyTransaction([{ raw, request_id }]); + if (!verified[0]?.views || verified[0].views.length < tx.inputs.length) throw new Error('invalid views to sign the inscription transaction'); + + const signedRaw = signSafeTransaction(tx, verified[0].views, params.spendPrivateKey, params.signerIndex ?? 0); + return utxo.sendTransactions([{ raw: signedRaw, request_id }]); +}; diff --git a/src/client/utils/safe.ts b/src/client/utils/safe.ts index ac884341..dc005310 100644 --- a/src/client/utils/safe.ts +++ b/src/client/utils/safe.ts @@ -56,6 +56,8 @@ export const buildMixinOneSafePaymentUri = (params: PaymentParams) => { asset: params.asset, amount: params.amount, memo: params.memo, + inscription: params.inscription, + inscription_collection: params.inscriptionCollection, trace: params.trace ?? v4(), return_to: params.returnTo && encodeURIComponent(params.returnTo), }; @@ -91,13 +93,14 @@ export const deriveGhostPublicKey = (r: Buffer, A: Buffer, B: Buffer, index: num return Buffer.from(p4.toBytes()); }; -export const getUnspentOutputsForRecipients = (outputs: SafeUtxoOutput[], rs: SafeTransactionRecipient[]) => { +export const getUnspentOutputsForRecipients = (outputs: SafeUtxoOutput[], rs: SafeTransactionRecipient[], options: { includeInscriptions?: boolean } = {}) => { const totalOutput = rs.reduce((prev, cur) => prev.plus(BigNumber(cur.amount)), BigNumber('0')); let totalInput = BigNumber('0'); const utxos: SafeUtxoOutput[] = []; for (const o of outputs) { if (o.state !== 'unspent') continue; + if (!options.includeInscriptions && o.inscription_hash) continue; utxos.push(o); totalInput = totalInput.plus(BigNumber(o.amount)); if (totalInput.minus(totalOutput).isNegative()) continue; diff --git a/test/mixin/inscription-utils.test.ts b/test/mixin/inscription-utils.test.ts new file mode 100644 index 00000000..67ba0933 --- /dev/null +++ b/test/mixin/inscription-utils.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SafeUtxoOutput, SequencerTransactionRequest } from '../../src/client/types'; +import type { InscriptionUtxoClient } from '../../src/client/utils/inscription'; +import { + buildInscriptionOperationExtra, + decodeInscriptionOperationExtra, + getInscriptionOutput, + InscriptionModeDone, + InscriptionModeInstant, + transferInscription, +} from '../../src/client/utils/inscription'; +import { buildSafeTransactionRecipient, decodeSafeTransaction, getUnspentOutputsForRecipients } from '../../src/client/utils/safe'; +import { SafeKeystoreClient } from '../../src/client/safe'; + +const userID = '67a87828-18f5-46a1-b6cc-c72a97a77c43'; +const spendPrivateKey = '11'.repeat(32); +const viewKey = '01'.repeat(32); + +const inscriptionOutput = (sequence: number, inscriptionHash?: string): SafeUtxoOutput => + ({ + state: 'unspent', + amount: '1000', + output_index: 0, + sequence, + asset: 'aa'.repeat(32), + transaction_hash: `${sequence}`.padStart(64, '0'), + inscription_hash: inscriptionHash, + }) as SafeUtxoOutput; + +describe('inscription operation extras', () => { + it('encodes operation payloads as JSON extra', () => { + const extra = buildInscriptionOperationExtra({ + operation: 'inscribe', + recipient: 'MIX...', + content: 'text/plain;charset=UTF-8,cedric.mao', + }); + expect(JSON.parse(extra.toString())).toEqual({ + operation: 'inscribe', + recipient: 'MIX...', + content: 'text/plain;charset=UTF-8,cedric.mao', + }); + }); + + it('keeps the deploy modes aligned with the Go SDK', () => { + expect(InscriptionModeInstant).toBe(1); + expect(InscriptionModeDone).toBe(2); + }); + + it('decodes operation extras from buffer or hex string', () => { + const inscribe = { + operation: 'inscribe', + recipient: 'MIX...', + content: 'text/plain;charset=UTF-8,cedric.mao', + } as const; + expect(decodeInscriptionOperationExtra(buildInscriptionOperationExtra(inscribe))).toEqual(inscribe); + + const deploy = { + version: 1, + mode: InscriptionModeInstant, + supply: '1000000000', + unit: '1000000', + symbol: 'MAO', + name: 'Mixin Advanced Ordinals', + icon: 'image/webp;base64,IVVB===', + } as const; + expect(decodeInscriptionOperationExtra(buildInscriptionOperationExtra(deploy).toString('hex'))).toEqual(deploy); + + const distribute = { distribute: 'distribute', sequence: 0 } as const; + expect(decodeInscriptionOperationExtra(buildInscriptionOperationExtra(distribute))).toEqual(distribute); + expect(buildInscriptionOperationExtra(distribute).toString()).toBe(JSON.stringify({ distribute: 'distribute', sequence: 0 })); + + const occupy = { operation: 'occupy', sequence: 3 } as const; + expect(decodeInscriptionOperationExtra(buildInscriptionOperationExtra(occupy))).toEqual(occupy); + }); + + it('rejects invalid or unknown operation extras', () => { + expect(() => decodeInscriptionOperationExtra('00ff')).toThrow('invalid inscription operation extra'); + expect(() => decodeInscriptionOperationExtra(Buffer.from('"text"'))).toThrow('invalid inscription operation extra'); + expect(() => decodeInscriptionOperationExtra(Buffer.from('{}'))).toThrow('unknown inscription operation'); + expect(() => decodeInscriptionOperationExtra(Buffer.from(JSON.stringify({ operation: 'inscribe' })))).toThrow('unknown inscription operation'); + expect(() => decodeInscriptionOperationExtra(Buffer.from(JSON.stringify({ operation: 'distribute', sequence: 0 })))).toThrow('unknown inscription operation'); + expect(() => decodeInscriptionOperationExtra(Buffer.from(JSON.stringify({ operation: 'distribute', sequence: 'x' })))).toThrow('unknown inscription operation'); + expect(() => decodeInscriptionOperationExtra(Buffer.from(JSON.stringify({ distribute: 'distribute', sequence: 'x' })))).toThrow('unknown inscription operation'); + expect(() => decodeInscriptionOperationExtra(Buffer.from(JSON.stringify({ version: 2, mode: 1 })))).toThrow('unknown inscription operation'); + }); +}); + +describe('inscription aware output selection', () => { + it('skips inscription outputs by default', () => { + const inscription = inscriptionOutput(0, 'bb'.repeat(32)); + const normal = inscriptionOutput(1); + const recipients = [buildSafeTransactionRecipient([userID], 1, '1000')]; + + const result = getUnspentOutputsForRecipients([inscription, normal], recipients); + + expect(result.utxos).toEqual([normal]); + expect(result.change.toString()).toBe('0'); + }); + + it('throws when only inscription outputs can cover the recipients', () => { + const inscription = inscriptionOutput(0, 'bb'.repeat(32)); + const recipients = [buildSafeTransactionRecipient([userID], 1, '1000')]; + + expect(() => getUnspentOutputsForRecipients([inscription], recipients)).toThrow('insufficient total input outputs'); + }); + + it('includes inscription outputs when explicitly requested', () => { + const inscription = inscriptionOutput(0, 'bb'.repeat(32)); + const recipients = [buildSafeTransactionRecipient([userID], 1, '1000')]; + + const result = getUnspentOutputsForRecipients([inscription], recipients, { includeInscriptions: true }); + + expect(result.utxos).toEqual([inscription]); + }); +}); + +describe('getInscriptionOutput', () => { + it('paginates outputs until the inscription is found', async () => { + const target = inscriptionOutput(0, 'dd'.repeat(32)); + const pages = [[inscriptionOutput(3), inscriptionOutput(2), inscriptionOutput(1)], [target]]; + const safeOutputs = vi.fn(async () => pages.shift() ?? []); + + const found = await getInscriptionOutput({ safeOutputs } as InscriptionUtxoClient, 'dd'.repeat(32), { limit: 3 }); + + expect(found).toEqual(target); + expect(safeOutputs).toHaveBeenCalledTimes(2); + expect(safeOutputs).toHaveBeenLastCalledWith(expect.objectContaining({ offset: 1, state: 'unspent' })); + }); + + it('throws when the inscription is nowhere unspent', async () => { + const safeOutputs = vi.fn(async () => [inscriptionOutput(0)]); + + await expect(getInscriptionOutput({ safeOutputs } as InscriptionUtxoClient, 'dd'.repeat(32))).rejects.toThrow('unspent inscription output not found'); + }); +}); + +describe('transferInscription', () => { + const ghost = { mask: 'ee'.repeat(32), keys: ['ff'.repeat(32)] }; + + const transferClient = (utxo: SafeUtxoOutput) => { + const sendTransactions = vi.fn(async (params: { raw: string; request_id: string }[]) => params.map(p => ({ request_id: p.request_id }) as SequencerTransactionRequest)); + return { + client: { + safeOutputs: vi.fn(async () => [utxo]), + ghostKey: vi.fn(async () => [ghost]), + verifyTransaction: vi.fn(async () => [{ views: [viewKey] }]) as unknown as Promise, + sendTransactions, + } as InscriptionUtxoClient, + sendTransactions, + }; + }; + + it('spends the whole inscription output to the recipient', async () => { + const utxo = inscriptionOutput(7, 'dd'.repeat(32)); + const { client, sendTransactions } = transferClient(utxo); + + const results = await transferInscription(client, { + inscriptionHash: 'dd'.repeat(32), + receivers: [userID], + threshold: 1, + spendPrivateKey, + memo: 'enjoy', + request_id: '00000000-0000-4000-8000-000000000001', + }); + + expect(results[0].request_id).toBe('00000000-0000-4000-8000-000000000001'); + expect(client.safeOutputs).toHaveBeenCalledWith({ members: undefined, threshold: undefined, state: 'unspent', offset: undefined, limit: 500 }); + + const raw = sendTransactions.mock.calls[0][0][0].raw as string; + const tx = decodeSafeTransaction(raw); + expect(tx.inputs).toEqual([{ hash: utxo.transaction_hash, index: utxo.output_index }]); + expect(tx.outputs).toHaveLength(1); + expect(tx.outputs[0].amount).toBe('1000'); + expect(tx.outputs[0].keys).toEqual(ghost.keys); + expect(Buffer.from(tx.extra).toString()).toBe('enjoy'); + expect(tx.signatureMap?.[0]?.[0]).toHaveLength(128); + }); + + it('rejects outputs without inscription or with mismatched hash', async () => { + const plain = inscriptionOutput(0); + await expect(transferInscription(transferClient(plain).client, { utxo: plain, receivers: [userID], threshold: 1, spendPrivateKey })).rejects.toThrow( + 'does not carry an inscription', + ); + + const inscribed = inscriptionOutput(0, 'dd'.repeat(32)); + await expect( + transferInscription(transferClient(inscribed).client, { + utxo: inscribed, + inscriptionHash: 'ee'.repeat(32), + receivers: [userID], + threshold: 1, + spendPrivateKey, + }), + ).rejects.toThrow('inscription hash mismatch'); + }); + + it('requires either utxo or inscriptionHash', async () => { + await expect(transferInscription({} as InscriptionUtxoClient, { receivers: [userID], threshold: 1, spendPrivateKey })).rejects.toThrow( + 'either utxo or inscriptionHash is required', + ); + }); +}); + +describe('client.safe.transferInscription', () => { + it('rejects when no spend private key is available', async () => { + const axiosInstance = { get: vi.fn(), post: vi.fn() } as never; + const client = SafeKeystoreClient(axiosInstance, { app_id: 'app', session_id: 'session', server_public_key: 'server', session_private_key: 'private' }); + + await expect(client.transferInscription({ inscriptionHash: 'dd'.repeat(32), receivers: [userID], threshold: 1 })).rejects.toThrow('spend private key is required'); + }); +}); diff --git a/test/mixin/utils.test.ts b/test/mixin/utils.test.ts index bad22dda..85b5a338 100644 --- a/test/mixin/utils.test.ts +++ b/test/mixin/utils.test.ts @@ -134,6 +134,52 @@ describe('Tests for utils', () => { expect(parseMixAddress(address)?.threshold).toBe(2); }); + test('builds an inscription payment URI for NFT collectibles', () => { + const inscriptionHash = '7ecf9fc49ff4d2e36424b8e53e67aed8cc4e9d08d7cbdca7d8bdb153ed2fcdde'; + const trace = '772e6bef-3bff-4fcc-987d-29bafca74d63'; + const uuid = '06bb6333-26d1-48a4-b775-89e0e4b609ea'; + + const uri = buildMixinOneSafePaymentUri({ + uuid, + inscription: inscriptionHash, + trace, + }); + const url = new URL(uri); + expect(`${url.origin}${url.pathname}`).toBe(`https://mixin.one/pay/${uuid}`); + expect(url.searchParams.get('inscription')).toBe(inscriptionHash); + expect(url.searchParams.get('trace')).toBe(trace); + expect(url.searchParams.get('asset')).toBeNull(); + expect(url.searchParams.get('amount')).toBeNull(); + + // without inscription, the param should be absent as before + const plainUri = buildMixinOneSafePaymentUri({ + uuid, + asset: 'c6d0c728-2624-429b-8e0d-d9d19b6592fa', + amount: '1', + trace, + }); + expect(new URL(plainUri).searchParams.get('inscription')).toBeNull(); + }); + + test('builds an inscription_collection payment URI to pick a collectible from a collection', () => { + const collectionHash = '4a5f79c76872524c6a4a81b174338584e790f09fb059c39cf2a894de1b3c31c6'; + const trace = '3552d116-b29d-4d72-9b24-3ca3b2e0f9c2'; + const uuid = '06bb6333-26d1-48a4-b775-89e0e4b609ea'; + + const uri = buildMixinOneSafePaymentUri({ + uuid, + inscriptionCollection: collectionHash, + memo: 'pick one from the collection', + trace, + }); + const url = new URL(uri); + expect(`${url.origin}${url.pathname}`).toBe(`https://mixin.one/pay/${uuid}`); + expect(url.searchParams.get('inscription_collection')).toBe(collectionHash); + expect(url.searchParams.get('inscription')).toBeNull(); + expect(url.searchParams.get('trace')).toBe(trace); + expect(url.searchParams.get('memo')).toBe('pick one from the collection'); + }); + test('tests for invoice', () => { const BTC = 'c6d0c728-2624-429b-8e0d-d9d19b6592fa'; const ETH = '43d61dcd-e413-450d-80b8-101d5e903357';