UNPKG

btms-core

Version:

Tools for creating and managing UTXO-based tokens

1,485 lines (1,409 loc) 60.8 kB
import pushdrop from 'pushdrop' import { createAction, getTransactionOutputs, getPublicKey, submitDirectTransaction, listActions, CreateActionResult, CreateActionOutput, CreateActionInput, GetTransactionOutputResult, revealKeyLinkage, SpecificKeyLinkageResult, decrypt as SDKDecrypt, EnvelopeApi, ProtocolID, createSignature } from '@babbage/sdk-ts' import { Authrite } from 'authrite-js' import Tokenator from '@babbage/tokenator' import { BigNumber, Curve, LockingScript, P2PKH, PrivateKey, PublicKey, Transaction, ScriptTemplate, OP, UnlockingScript, TransactionSignature, Signature, Hash, Utils } from '@bsv/sdk' import { getPaymentPrivateKey } from 'sendover' import { decrypt as CWIDecrypt } from 'cwi-crypto' import stringify from 'json-stable-stringify' const ANYONE = '0000000000000000000000000000000000000000000000000000000000000001' /** * Given a Buffer as an input, returns a minimally-encoded version as a hex string, plus any pushdata that may be required. * @param buf - NodeJS Buffer containing an intended Bitcoin script stack element * @return {String} Minimally-encoded version, plus the correct opcodes required to push it with minimal encoding, if any, in hex string format. * @private */ const minimalEncoding = (buf): string => { if (!(buf instanceof Buffer)) { buf = Buffer.from(buf) } if (buf.byteLength === 0) { // Could have used OP_0. return '00' } if (buf.byteLength === 1 && buf[0] === 0) { // Could have used OP_0. return '00' } if (buf.byteLength === 1 && buf[0] > 0 && buf[0] <= 16) { // Could have used OP_0 .. OP_16. return `${(0x50 + (buf[0])).toString(16)}` } if (buf.byteLength === 1 && buf[0] === 0x81) { // Could have used OP_1NEGATE. return '4f' } if (buf.byteLength <= 75) { // Could have used a direct push (opcode indicating number of bytes // pushed + those bytes). return Buffer.concat([ Buffer.from([buf.byteLength]), buf ]).toString('hex') } if (buf.byteLength <= 255) { // Could have used OP_PUSHDATA. return Buffer.concat([ Buffer.from([0x4c]), Buffer.from([buf.byteLength]), buf ]).toString('hex') } if (buf.byteLength <= 65535) { // Could have used OP_PUSHDATA2. const len = Buffer.alloc(2) len.writeUInt16LE(buf.byteLength) return Buffer.concat([ Buffer.from([0x4d]), len, buf ]).toString('hex') } const len = Buffer.alloc(4) len.writeUInt32LE(buf.byteLength) return Buffer.concat([ Buffer.from([0x4e]), len, buf ]).toString('hex') } const OP_DROP = '75' const OP_2DROP = '6d' class BTMSToken implements ScriptTemplate { async lock(protocolID: ProtocolID, keyID: string, counterparty: string, assetId: string, amount: number, metadata: string, forSelf = false): Promise<LockingScript> { const publicKey = await getPublicKey({ protocolID, keyID, counterparty, forSelf }) const lockPart = new LockingScript([ { op: publicKey.length / 2, data: Utils.toArray(publicKey, 'hex') }, { op: OP.OP_CHECKSIG } ]).toHex() const fields: Array<string | Uint8Array> = [ assetId, String(amount), metadata ] const dataToSign = Buffer.concat(fields.map(x => Buffer.from(x))) const signature = await createSignature({ data: dataToSign, protocolID, keyID, counterparty, }) fields.push(signature) const pushPart = fields.reduce( (acc, el) => acc + minimalEncoding(el), '' ) let dropPart = '' let undropped = fields.length while (undropped > 1) { dropPart += OP_2DROP undropped -= 2 } if (undropped) { dropPart += OP_DROP } return LockingScript.fromHex(`${lockPart}${pushPart}${dropPart}`) } unlock( protocolID: ProtocolID, keyID: string, counterparty: string, sourceTXID?: string, sourceSatoshis?: number, lockingScript?: LockingScript, signOutputs: 'all' | 'none' | 'single' = 'all', anyoneCanPay = false ): { sign: (tx: Transaction, inputIndex: number) => Promise<UnlockingScript> estimateLength: () => Promise<72> } { return { sign: async (tx: Transaction, inputIndex: number): Promise<UnlockingScript> => { const input = tx.inputs[inputIndex] const otherInputs = tx.inputs.filter((_, index) => index !== inputIndex) sourceTXID = input.sourceTXID ? input.sourceTXID : input.sourceTransaction?.id('hex') as string if (!sourceTXID) { throw new Error( 'The input sourceTXID or sourceTransaction is required for transaction signing.' ) } sourceSatoshis ||= input.sourceTransaction?.outputs[input.sourceOutputIndex].satoshis if (!sourceSatoshis) { throw new Error( 'The sourceSatoshis or input sourceTransaction is required for transaction signing.' ) } lockingScript ||= input.sourceTransaction?.outputs[input.sourceOutputIndex].lockingScript if (!lockingScript) { throw new Error( 'The lockingScript or input sourceTransaction is required for transaction signing.' ) } let signatureScope = TransactionSignature.SIGHASH_FORKID if (signOutputs === 'all') { signatureScope |= TransactionSignature.SIGHASH_ALL } if (signOutputs === 'none') { signatureScope |= TransactionSignature.SIGHASH_NONE } if (signOutputs === 'single') { signatureScope |= TransactionSignature.SIGHASH_SINGLE } if (anyoneCanPay) { signatureScope |= TransactionSignature.SIGHASH_ANYONECANPAY } const preimage = TransactionSignature.format({ sourceTXID, sourceOutputIndex: input.sourceOutputIndex, sourceSatoshis, transactionVersion: tx.version, otherInputs, inputIndex, outputs: tx.outputs, inputSequence: input.sequence, subscript: lockingScript, lockTime: tx.lockTime, scope: signatureScope }) const preimageHash = Hash.sha256(preimage) const SDKSignature = await createSignature({ data: Uint8Array.from(preimageHash), protocolID, keyID, counterparty }) const rawSignature = Signature.fromDER([...SDKSignature]) const sig = new TransactionSignature( rawSignature.r, rawSignature.s, signatureScope ) const sigForScript = sig.toChecksigFormat() return new UnlockingScript([ { op: sigForScript.length, data: sigForScript } ]) }, estimateLength: async () => 72 } } } class BTMSFundingToken implements ScriptTemplate { async lock(protocolID: ProtocolID, keyID: string, counterparty: string): Promise<LockingScript> { const fundingPublicKeyString = await getPublicKey({ protocolID, keyID, counterparty }) const fundingAddress = PublicKey.fromString(fundingPublicKeyString).toAddress() return new P2PKH().lock(fundingAddress) } unlock( protocolID: ProtocolID, keyID: string, counterparty: string, ): { sign: (tx: Transaction, inputIndex: number) => Promise<UnlockingScript> estimateLength: () => Promise<106> } { return { sign: async (tx: Transaction, inputIndex: number): Promise<UnlockingScript> => { const input = tx.inputs[inputIndex] const otherInputs = tx.inputs.filter((_, index) => index !== inputIndex) const sourceTXID = input.sourceTXID ? input.sourceTXID : input.sourceTransaction?.id('hex') as string if (!sourceTXID) { throw new Error( 'The input sourceTXID or sourceTransaction is required for transaction signing.' ) } const sourceSatoshis = input.sourceTransaction?.outputs[input.sourceOutputIndex].satoshis if (!sourceSatoshis) { throw new Error( 'The sourceSatoshis or input sourceTransaction is required for transaction signing.' ) } const lockingScript = input.sourceTransaction?.outputs[input.sourceOutputIndex].lockingScript if (!lockingScript) { throw new Error( 'The lockingScript or input sourceTransaction is required for transaction signing.' ) } const signatureScope = TransactionSignature.SIGHASH_FORKID | TransactionSignature.SIGHASH_ALL const preimage = TransactionSignature.format({ sourceTXID, sourceOutputIndex: input.sourceOutputIndex, sourceSatoshis, transactionVersion: tx.version, otherInputs, inputIndex, outputs: tx.outputs, inputSequence: input.sequence, subscript: lockingScript, lockTime: tx.lockTime, scope: signatureScope }) const preimageHash = Hash.sha256(preimage) const SDKSignature = await createSignature({ data: Uint8Array.from(preimageHash), protocolID, keyID, counterparty }) const rawSignature = Signature.fromDER([...SDKSignature]) const sig = new TransactionSignature( rawSignature.r, rawSignature.s, signatureScope ) const sigForScript = sig.toChecksigFormat() const publicKeyString = await getPublicKey({ protocolID, keyID, counterparty, forSelf: true }) return new UnlockingScript([ { op: sigForScript.length, data: sigForScript }, { op: publicKeyString.length / 2, data: Utils.toArray(publicKeyString, 'hex') } ]) }, estimateLength: async () => 106 } } } export interface Asset { assetId: string balance: number name?: string iconURL?: string metadata?: string incoming?: boolean incomingAmount?: number new?: boolean } export interface TokenForRecipient { txid: string vout: number amount: number envelope: CreateActionResult keyID: string outputScript: string } export interface SubmitResult { status: 'auccess' topics: Record<string, number[]> } export interface OverlaySearchResult { inputs: string | null mapiResponses: string | null outputScript: string proof: string | null rawTx: string satoshis: number txid: string vout: number } export interface IncomingPayment { txid: string vout: number outputScript: string amount: number token: TokenForRecipient sender: string messageId: string keyID: string envelope: CreateActionResult } export interface OwnershipProof { prover: string verifier: string assetId: string amount: number tokens: { output: GetTransactionOutputResult linkage: SpecificKeyLinkageResult }[] } interface MarketplaceEntry { assetId: string amount: number seller: string description: string desiredAssets: Record<string, number> ownershipProof: OwnershipProof metadata: string } interface MarketplaceOffer { buyerOffersAssetId: string buyerOffersAmount: number buyerProof: OwnershipProof buyerPartialTX: string buyerFundingEnvelope: CreateActionResult | EnvelopeApi sellerEntry: MarketplaceEntry fundingKeyID: string messageId?: string rejected?: boolean isAsDesiredBySeller?: boolean desiredSellerKeyID?: string, desiredSellerChangeKeyID?: string desiredBuyerKeyID?: string desiredBuyerChangeKeyID?: string } interface BuyerOfferCustomInstructions { buyerProof: OwnershipProof buyerOfferedAssetId: string buyerOfferedAmount: number sellerEntry: MarketplaceEntry fundingKeyID: string } /** * Verify that the possibly undefined value currently has a value. */ function verifyTruthy<T>(v: T | null | undefined, description?: string): T { if (v == null) throw new Error(description ?? 'A truthy value is required.') return v } /** * The BTMS class provides an interface for managing and transacting assets using the Babbage SDK. * @class */ export class BTMS { confederacyHost: string peerServHost: string tokenator: Tokenator tokensMessageBox: string marketplaceMessageBox: string protocolID: string basket: string tokenTopic: string satoshis: number authrite: Authrite privateKey: string | undefined marketplaceTopic: string /** * BTMS constructor. * @constructor * @param {string} confederacyHost - The confederacy host URL. * @param {string} peerServHost - The peer service host URL. * @param {string} tokensMessageBox - The message box ID. * @param {string} protocolID - The protocol ID. * @param {string} basket - The asset basket ID. * @param {string} tokensTopic - The topic associated with the asset. * @param {number} satoshis - The number of satoshis involved in transactions. */ constructor( confederacyHost = 'https://confederacy.babbage.systems', peerServHost = 'https://peerserv.babbage.systems', tokensMessageBox = 'tokens-box', protocolID = 'tokens', basket = 'tokens', tokensTopic = 'tokens', satoshis = 1000, privateKey?: string, marketplaceMessageBox = 'marketplace', marketplaceTopic = 'marketplace' ) { this.confederacyHost = confederacyHost this.peerServHost = peerServHost this.tokensMessageBox = tokensMessageBox this.protocolID = protocolID this.basket = basket this.tokenTopic = tokensTopic this.satoshis = satoshis const authriteParams: { clientPrivateKey?: string } = {} const tokenatorParams: { peerServHost: string, clientPrivateKey?: string } = { peerServHost } if (privateKey) { authriteParams.clientPrivateKey = privateKey tokenatorParams.clientPrivateKey = privateKey } this.tokenator = new Tokenator(tokenatorParams) this.authrite = new Authrite(authriteParams) this.privateKey = privateKey this.marketplaceMessageBox = marketplaceMessageBox this.marketplaceTopic = marketplaceTopic } async listAssets(): Promise<Asset[]> { const tokens = await getTransactionOutputs({ // TODO: signing strategy basket: this.basket, spendable: true, includeEnvelope: false }) const assets: Record<string, Asset> = {} for (const token of tokens) { const decoded = pushdrop.decode({ script: token.outputScript, fieldFormat: 'utf8' }) let assetId = decoded.fields[0] if (assetId === 'ISSUE') { assetId = `${token.txid}.${token.vout}` } let parsedMetadata: { name: string } = { name: '' } try { parsedMetadata = JSON.parse(decoded.fields[2]) } catch (_) { } if (!parsedMetadata.name) { continue } if (!assets[assetId]) { assets[assetId] = { ...parsedMetadata, balance: Number(decoded.fields[1]), metadata: decoded.fields[2], assetId } } else { assets[assetId].balance += Number(decoded.fields[1]) } } // Now, we need to add in any incoming assets that we may have. const myIncomingMessages = await this.tokenator.listMessages({ messageBox: this.tokensMessageBox }) for (const message of myIncomingMessages) { let parsedBody, token try { parsedBody = JSON.parse(JSON.parse(message.body)) token = parsedBody.token const decodedToken = pushdrop.decode({ script: token.outputScript, fieldFormat: 'utf8' }) let decodedAssetId: string = decodedToken.fields[0] if (decodedAssetId === 'ISSUE') { decodedAssetId = `${token.txid}.${token.vout}` } const amount = Number(decodedToken.fields[1]) if (assets[decodedAssetId]) { assets[decodedAssetId].incoming = true if (!assets[decodedAssetId].incomingAmount) { assets[decodedAssetId].incomingAmount = amount } else { assets[decodedAssetId].incomingAmount = assets[decodedAssetId].incomingAmount as number + amount } } else { let parsedMetadata = {} try { parsedMetadata = JSON.parse(decodedToken.fields[2]) } catch (_) {/* ignore */ } assets[decodedAssetId] = { assetId: decodedAssetId, ...parsedMetadata, new: true, incoming: true, incomingAmount: amount, balance: 0, metadata: decodedToken.fields[2] } } } catch (e) { console.error('Error parsing incoming message', e) } } return Object.values(assets) } async issue(amount: number, name: string): Promise<SubmitResult> { const keyID = this.getRandomKeyID() const template = new BTMSToken() const tokenScript = (await template.lock(this.protocolID, keyID, 'self', 'ISSUE', amount, JSON.stringify({ name }))).toHex() const action = await createAction({ // TODO: signing strategy description: `Issue ${amount} ${name} ${amount === 1 ? 'token' : 'tokens'}`, // labels: ??? // TODO: Label the issuance transaction with the issuance ID after it is created. Currently, issuance transactions do not show up. outputs: [{ script: tokenScript, satoshis: this.satoshis, basket: this.basket, description: `${amount} new ${name}`, customInstructions: JSON.stringify({ keyID }) }] }) return await this.submitToTokenOverlay(action) } /** * Send tokens to a recipient. * @async * @param {string} assetId - The ID of the asset to be sent. * @param {string} recipient - The recipient's public key. * @param {number} sendAmount - The amount of the asset to be sent. * @returns {Promise<any>} Returns a promise that resolves to a transaction action object. * @throws {Error} Throws an error if the sender does not have enough tokens. */ async send( assetId: string, recipient: string, sendAmount: number, disablePeerServ = false, onPaymentSent = (payment: TokenForRecipient) => { } ): Promise<SubmitResult> { const myTokens = await this.getTokens(assetId, true) const myBalance = await this.getBalance(assetId, myTokens) // Make sure the amount is not more than what you have if (sendAmount > myBalance) { throw new Error('Not sufficient tokens.') } const myIdentityKey = await getPublicKey({ identityKey: true }) // TODO: signing strategy // We can decode the first token to extract the metadata needed in the outputs const { fields: [, , metadata] } = pushdrop.decode({ script: myTokens[0].outputScript, fieldFormat: 'utf8' }) let parsedMetadata: { name: string } = { name: 'Token' } try { parsedMetadata = JSON.parse(metadata) } catch (e) {/* ignore */ } // Create redeem scripts for your tokens const inputs: Record<string, CreateActionInput> = {} for (const t of myTokens) { const unlockingScript = await pushdrop.redeem({ // TODO: signing strategy prevTxId: t.txid, outputIndex: t.vout, lockingScript: t.outputScript, outputAmount: this.satoshis, protocolID: this.protocolID, keyID: this.getKeyIDFromInstructions(t.customInstructions), counterparty: this.getCounterpartyFromInstructions(t.customInstructions) }) if (!inputs[t.txid]) { inputs[t.txid] = { ...t.envelope as EnvelopeApi, inputs: typeof t.envelope?.inputs === 'string' ? JSON.parse(t.envelope?.inputs) : t.envelope?.inputs, mapiResponses: typeof t.envelope?.mapiResponses === 'string' ? JSON.parse(t.envelope.mapiResponses) : t.envelope?.mapiResponses, proof: typeof t.envelope?.proof === 'string' ? JSON.parse(t.envelope?.proof) : t.envelope?.proof, outputsToRedeem: [{ index: t.vout, spendingDescription: `Redeeming ${parsedMetadata.name}`, unlockingScript }] } } else { inputs[t.txid].outputsToRedeem.push({ index: t.vout, unlockingScript, spendingDescription: `Redeeming ${parsedMetadata.name}` }) } } // Create outputs for the recipient and your own change const outputs: CreateActionOutput[] = [] const recipientKeyID = this.getRandomKeyID() const template = new BTMSToken() const recipientScript = (await template.lock(this.protocolID, recipientKeyID, recipient, assetId, sendAmount, metadata)).toHex() outputs.push({ script: recipientScript, satoshis: this.satoshis, description: `Sending ${sendAmount} ${parsedMetadata.name}`, tags: [ myIdentityKey === recipient ? 'owner self' : `owner ${recipient}` ] }) if (myIdentityKey === recipient) { outputs[0].basket = this.basket outputs[0].customInstructions = JSON.stringify({ sender: myIdentityKey, keyID: recipientKeyID }) } let changeScript if (myBalance - sendAmount > 0) { const changeKeyID = this.getRandomKeyID() changeScript = (await template.lock(this.protocolID, changeKeyID, 'self', assetId, myBalance - sendAmount, metadata)).toHex() outputs.push({ script: changeScript, basket: this.basket, satoshis: this.satoshis, description: `Keeping ${String(myBalance - sendAmount)} ${parsedMetadata.name}`, tags: ['owner self'], customInstructions: JSON.stringify({ sender: myIdentityKey, keyID: changeKeyID }) }) } // Create the transaction const action = await createAction({ // TODO: signing strategy description: `Send ${sendAmount} ${parsedMetadata.name} to ${recipient}`, labels: [assetId.replace('.', ' ')], inputs, outputs }) const tokenForRecipient: TokenForRecipient = { txid: action.txid, vout: 0, amount: this.satoshis, envelope: { ...action }, keyID: recipientKeyID, outputScript: recipientScript } if (myIdentityKey !== recipient && !disablePeerServ) { // Send the transaction to the recipient await this.tokenator.sendMessage({ recipient, messageBox: this.tokensMessageBox, body: JSON.stringify({ token: tokenForRecipient }) }) } try { onPaymentSent(tokenForRecipient) } catch (e) { } return await this.submitToTokenOverlay(action) } /** * List incoming payments for a given asset. * @async * @param {string} assetId - The ID of the asset. * @returns {Promise<any[]>} Returns a promise that resolves to an array of payment objects. */ async listIncomingPayments(assetId: string): Promise<IncomingPayment[]> { const myIncomingMessages = await this.tokenator.listMessages({ messageBox: this.tokensMessageBox }) const payments: IncomingPayment[] = [] for (const message of myIncomingMessages) { let parsedBody, token: TokenForRecipient try { parsedBody = JSON.parse(JSON.parse(message.body)) token = parsedBody.token const decodedToken = pushdrop.decode({ script: token.outputScript, fieldFormat: 'utf8' }) let decodedAssetId = decodedToken.fields[0] if (decodedAssetId === 'ISSUE') { decodedAssetId = `${token.txid}.${token.vout}` } if (assetId !== decodedAssetId) continue const amount = Number(decodedToken.fields[1]) const newPayment = { ...token, txid: token.txid, vout: token.vout, outputScript: token.outputScript, amount, token, sender: message.sender, messageId: message.messageId, keyID: token.keyID } payments.push(newPayment) } catch (e) { console.error('Error parsing incoming message', e) } } return payments } async acceptIncomingPayment(assetId: string, payment: IncomingPayment): Promise<boolean> { // Verify the token is owned by the user const decodedToken = pushdrop.decode({ script: payment.outputScript, fieldFormat: 'utf8' }) let decodedAssetId = decodedToken.fields[0] if (decodedAssetId === 'ISSUE') { decodedAssetId = `${payment.txid}.${payment.vout}` } if (assetId !== decodedAssetId) { // Not something we can hope to fix, we acknowledge the message await this.tokenator.acknowledgeMessage({ messageIds: [payment.messageId] }) throw new Error(`This token is for the wrong asset ID. You are indicating you want to accept a token with asset ID ${assetId} but this token has assetId ${decodedAssetId}`) } const myKey = await getPublicKey({ // TODO: signing strategy protocolID: this.protocolID, keyID: payment.keyID || '1', counterparty: payment.sender, forSelf: true }) if (myKey !== decodedToken.lockingPublicKey) { // Not something we can hope to fix, we acknowledge the message await this.tokenator.acknowledgeMessage({ messageIds: [payment.messageId] }) throw new Error('Received token not belonging to me!') } // Verify the token is on the overlay const verified = await this.findFromTokenOverlay(payment) if (verified.length < 1) { // Try to put it on the overlay try { await this.submitToTokenOverlay(payment) } catch (e) { console.error('ERROR RE-SUBMITTING IN ACCEPT', e) } // Check again const verifiedAfterSubmit = await this.findFromTokenOverlay(payment) // If still not there, we cannot proceed. if (verifiedAfterSubmit) { // Not something we can hope to fix, we acknowledge the message await this.tokenator.acknowledgeMessage({ messageIds: [payment.messageId] }) throw new Error('Token is for me but not on the overlay!') } } let parsedMetadata: { name: string } = { name: 'Token' } try { parsedMetadata = JSON.parse(decodedToken.fields[2]) } catch (e) { } // Submit transaction await submitDirectTransaction({ // TODO: signing strategy senderIdentityKey: payment.sender, note: `Receive ${decodedToken.fields[1]} ${parsedMetadata.name} from ${payment.sender}`, amount: this.satoshis, labels: [assetId.replace('.', ' ')], transaction: { ...payment.envelope, outputs: [{ vout: 0, basket: this.basket, satoshis: this.satoshis, tags: ['owner self'], customInstructions: JSON.stringify({ sender: payment.sender, keyID: payment.keyID || '1' }) }] } }) if (payment.messageId) { await this.tokenator.acknowledgeMessage({ messageIds: [payment.messageId] }) } return true } async refundIncomingTransaction(assetId: string, payment: IncomingPayment): Promise<SubmitResult> { // We can decode the first token to extract the metadata needed in the outputs const { fields: [, , metadata] } = pushdrop.decode({ script: payment.outputScript, fieldFormat: 'utf8' }) // Create redeem scripts for your tokens const inputs: Record<string, CreateActionInput> = {} const unlockingScript = await pushdrop.redeem({ // TODO: signing strategy prevTxId: payment.txid, outputIndex: payment.vout, lockingScript: payment.outputScript, outputAmount: this.satoshis, protocolID: this.protocolID, keyID: payment.keyID || '1', counterparty: payment.sender }) inputs[payment.txid] = { ...payment.envelope, inputs: typeof payment.envelope.inputs === 'string' ? JSON.parse(payment.envelope.inputs) : payment.envelope.inputs, mapiResponses: typeof payment.envelope.mapiResponses === 'string' ? JSON.parse(payment.envelope.mapiResponses) : payment.envelope.mapiResponses, // proof: typeof payment.envelope.proof === 'string' // no proof ever, right? // ? JSON.parse(payment.envelope.proof) // : payment.envelope.proof, outputsToRedeem: [{ index: payment.vout, unlockingScript }] } // Create outputs for the recipient and your own change const outputs: CreateActionOutput[] = [] const refundKeyID = this.getRandomKeyID() const template = new BTMSToken() const recipientScript = (await template.lock(this.protocolID, refundKeyID, payment.sender, assetId, payment.amount, metadata)).toHex() outputs.push({ script: recipientScript, satoshis: this.satoshis }) // Create the transaction const action = await createAction({ // TODO: signing strategy labels: [assetId.replace('.', ' ')], description: `Returning ${payment.amount} tokens to ${payment.sender}`, inputs, outputs }) const tokenForRecipient: TokenForRecipient = { txid: action.txid, vout: 0, amount: this.satoshis, envelope: { ...action }, keyID: refundKeyID, outputScript: recipientScript } // Send the transaction to the recipient await this.tokenator.sendMessage({ recipient: payment.sender, messageBox: this.tokensMessageBox, body: JSON.stringify({ token: tokenForRecipient }) }) if (payment.messageId) { await this.tokenator.acknowledgeMessage({ messageIds: [payment.messageId] }) } return await this.submitToTokenOverlay(action) } /** * Get all tokens for a given asset. * @async * @param {string} assetId - The ID of the asset. * @param {boolean} includeEnvelope - Include the envelope in the result. * @returns {Promise<any[]>} Returns a promise that resolves to an array of token objects. */ async getTokens(assetId: string, includeEnvelope = true): Promise<GetTransactionOutputResult[]> { const tokens = await getTransactionOutputs({ // TODO: signing strategy basket: this.basket, spendable: true, includeEnvelope }) return tokens.filter(x => { const decoded = pushdrop.decode({ script: x.outputScript, fieldFormat: 'utf8' }) let decodedAssetId = decoded.fields[0] if (decodedAssetId === 'ISSUE') { decodedAssetId = `${x.txid}.${x.vout}` } return decodedAssetId === assetId }) } /** * Get the balance of a given asset. * @async * @param {string} assetId - The ID of the asset. * @param {any[]} myTokens - (Optional) An array of token objects owned by the caller. * @returns {Promise<number>} Returns a promise that resolves to the balance. */ async getBalance(assetId: string, myTokens?: GetTransactionOutputResult[]): Promise<number> { if (!Array.isArray(myTokens)) { myTokens = await this.getTokens(assetId, false) } let balance = 0 for (const x of myTokens) { const t = pushdrop.decode({ script: x.outputScript, fieldFormat: 'utf8' }) let tokenAssetId = t.fields[0] if (tokenAssetId === 'ISSUE') { tokenAssetId = `${x.txid}.${x.vout}` } if (tokenAssetId === assetId) { balance += Number(t.fields[1]) } } return balance } async getTransactions(assetId: string, limit: number, offset: number): Promise<{ transactions: { date: string, amount: number, txid: string, counterparty: string }[] }> { const actions = await listActions({ // TODO: signing strategy label: assetId.replace('.', ' '), limit, offset, addInputsAndOutputs: true, includeBasket: true, includeTags: true }) const txs = actions.transactions.map(a => { let selfIn = 0 let counterpartyIn = 'self' const inputs = verifyTruthy(a.inputs) for (let i = 0; i < inputs.length; i++) { const tags = verifyTruthy(inputs[i].tags) if (tags.some(x => x === 'owner self')) { const decoded = pushdrop.decode({ script: Buffer.from(inputs[i].outputScript).toString('hex'), fieldFormat: 'utf8' }) selfIn += Number(decoded.fields[1]) } else { const ownerTag = tags.find(x => x.startsWith('owner ')) if (ownerTag) { counterpartyIn = ownerTag.split(' ')[1] } } } let selfOut = 0 let counterpartyOut = 'self' const outputs = verifyTruthy(a.outputs) for (let i = 0; i < outputs.length; i++) { const tags = verifyTruthy(outputs[i].tags) if (tags.some(x => x === 'owner self')) { const decoded = pushdrop.decode({ script: Buffer.from(outputs[i].outputScript).toString('hex'), fieldFormat: 'utf8' }) selfOut += Number(decoded.fields[1]) } else { const ownerTag = tags.find(x => x.startsWith('owner ')) if (ownerTag) { counterpartyOut = ownerTag.split(' ')[1] } } } const amount = selfOut - selfIn return { date: a.created_at, amount, txid: a.txid, counterparty: amount < 0 ? counterpartyOut : counterpartyIn } }) return { ...actions, transactions: txs } } async proveOwnership(assetId: string, amount: number, verifier: string): Promise<OwnershipProof> { // Get a list of tokens const myTokens = await this.getTokens(assetId, true) let amountProven = 0 const provenTokens: { output: GetTransactionOutputResult; linkage: SpecificKeyLinkageResult; }[] = [] const myIdentityKey = await getPublicKey({ identityKey: true }) // Go through the list for (const token of myTokens) { // Obtain key linkage for each token const parsedInstructions = JSON.parse(token.customInstructions as string) const linkage = await revealKeyLinkage({ // TODO: signing strategy mode: 'specific', counterparty: this.getCounterpartyFromInstructions(parsedInstructions), protocolID: this.protocolID, keyID: this.getKeyIDFromInstructions(parsedInstructions), verifier, description: 'Prove token ownership' }) provenTokens.push({ output: token, linkage: linkage as SpecificKeyLinkageResult }) // Increment the amount counter each time const t = pushdrop.decode({ script: token.outputScript, fieldFormat: 'utf8' }) amountProven += Number(t.fields[1]) // Break if the amount counter goes above the amount to prove if (amountProven > amount) break } // After the loop check the counter // Error if we have not proven the full amount if (amountProven < amount) { throw new Error('User does not have amount of asset requested for ownership oroof.') } // Return the proof return { prover: myIdentityKey, verifier, tokens: provenTokens, amount, assetId } } async verifyOwnership(proof: OwnershipProof, useAnyoneKey = false): Promise<boolean> { // Keep count of amount proven let amountProven = 0 // Go through all tokens for (const token of proof.tokens) { // Increment the amount counter each time const t = pushdrop.decode({ script: token.output.outputScript, fieldFormat: 'utf8' }) amountProven += Number(t.fields[1]) // Ensure token linkage is verified for prover const valid = await this.verifyLinkageForProver(token.linkage, t.lockingPublicKey, useAnyoneKey) if (!valid) { throw new Error('Invalid key linkage for token prover.') } // Ensure the proof belongs to the prover if (token.linkage.prover !== proof.prover) { throw new Error('Prover tried to prove tokens that were not theirs.') } // Ensure token is on overlay const resultFromOverlay = await this.findFromTokenOverlay({ txid: token.output.txid, vout: token.output.vout }) if (resultFromOverlay.length < 1) { throw new Error('Claimed token is not on the overlay.') } } // Check amount in proof against total // Error if amounts mismatch if (amountProven !== proof.amount) { throw new Error('Amount of tokens in proof not as claimed.') } // Return true as proof is valid return true } /** * Checks that an asset ID is in the correct format * @param assetId Asset ID to validate * @returns a boolean indicating asset ID validity */ validateAssetId(assetId: string): boolean { if (typeof assetId !== 'string') { return false } const [first, second, third] = assetId.split('.') if (typeof first !== 'string' || typeof second !== 'string') { return false } if (typeof third !== 'undefined') { return false } if (!/^[0-9a-fA-F]{64}$/.test(first)) { return false } const secondNum = Number(second) if (!Number.isInteger(secondNum)) { return false } if (secondNum < 0) { return false } return true } /** * Lists an asset on the marketplace for sale * @param assetId The ID of the asset to list * @param amount The amount you want to sell * @param desiredAssets Assets you would desire to have in return so people can make you an offer * @param description Marketplace listing description * @returns Overlay network submission results */ async listAssetForSale( assetId: string, amount: number, desiredAssets: Record<string, number>, description?: string ): Promise<SubmitResult> { // Validate desired assets for (const key of Object.keys(desiredAssets)) { const validAssetId = this.validateAssetId(key) if (!validAssetId) { const e = new Error('Assset ID in desired assets structure invalid') console.error('Rejecting output for having an invalid asset ID in desired assets') throw e } } for (const val of Object.values(desiredAssets)) { if (typeof val !== 'number' || val < -1 || !Number.isInteger(val)) { const e = new Error('Amount in desired assets structure invalid') console.error('Rejecting output for having an invalid amount in desired assets') throw e } } // Creat a proof const anyonePub = new PrivateKey(ANYONE, 'hex').toPublicKey().toString() const proof = await this.proveOwnership(assetId, amount, anyonePub) // Compose a PushDrop token const token = await pushdrop.create({ fields: [ Buffer.from(JSON.stringify(proof), 'utf8'), Buffer.from(JSON.stringify(desiredAssets), 'utf8'), Buffer.from(description || '', 'utf8') ], protocolID: 'marketplace', keyID: '1', counterparty: anyonePub, ownedByCreator: true }) // Create a transaction const action = await createAction({ description: 'List assets on the marketplace', outputs: [{ satoshis: this.satoshis, script: token }] }) // Send the transaction to the oerlay return await this.submitToMarketplaceOverlay(action) } /** * Returns an array of all marketplace entries * @returns An array of all marketplace entries */ async findAllAssetsForSale(findMine = false): Promise<MarketplaceEntry[]> { const findParams: { seller?: string, findAll?: boolean } = {} if (findMine) { const myIdentity = await getPublicKey({ identityKey: true }) findParams.seller = myIdentity } else { findParams.findAll = true } const assets = await this.findFromMarketplaceOverlay(findParams) const results: MarketplaceEntry[] = [] for (const asset of assets) { const decoded = pushdrop.decode({ script: asset.outputScript, returnType: 'buffer' }) const parsedProof: OwnershipProof = JSON.parse(decoded.fields[0].toString('utf8')) const parsedDesiredAssets = JSON.parse(decoded.fields[1].toString('utf8')) const decodedAsset = pushdrop.redeem({ script: parsedProof.tokens[0].output.outputScript, returnType: 'utf8' }) results.push({ seller: parsedProof.prover, amount: parsedProof.amount, description: decoded.fields[2] ? decoded.fields[2].toString('utf8') : '', desiredAssets: parsedDesiredAssets, ownershipProof: parsedProof, assetId: parsedProof.assetId, metadata: decodedAsset.fields[2].toString('utf8') }) } return results } async makeOffer(entry: MarketplaceEntry, assetId: string, amount: number): Promise<void> { // Verify the assets are still available const verified = await this.verifyOwnership(entry.ownershipProof, true) if (!verified) { throw new Error('Item is no longer for sale.') } // Compose a proof of our assets for the seller const buyerProof = await this.proveOwnership(assetId, amount, entry.seller) // prepare a funding UTXO for the trade offer const fundingKeyID = this.getRandomKeyID() const fundingTemplate = new BTMSFundingToken() const fundingScript = await fundingTemplate.lock(this.protocolID, fundingKeyID, entry.seller) const buyerOfferCustomInstructions: BuyerOfferCustomInstructions = { buyerProof, buyerOfferedAssetId: assetId, buyerOfferedAmount: amount, sellerEntry: entry, fundingKeyID } const fundingAction = await createAction({ outputs: [{ satoshis: 1000, script: fundingScript.toHex(), description: 'Fund a trade offer', basket: `${this.basket} trades`, customInstructions: JSON.stringify(buyerOfferCustomInstructions) }], description: 'Offer a trade' }) // Extract buyer's asset metadata to forward in the new UTXO const decodedBuyerAsset = pushdrop.redeem({ script: buyerProof.tokens[0].output.outputScript, returnType: 'utf8' }) const metadata = decodedBuyerAsset.fields[2].toString('utf8') // Create scripts for both the buyer's and seller's new ownership const desiredBuyerKeyID = this.getRandomKeyID() const template = new BTMSToken() const desiredBuyerScript = (await template.lock(this.protocolID, desiredBuyerKeyID, entry.seller, assetId, entry.amount, metadata, true)).toHex() const desiredSellerKeyID = this.getRandomKeyID() const desiredSellerScript = (await template.lock(this.protocolID, desiredSellerKeyID, entry.seller, assetId, amount, metadata)).toHex() // Create a conditionally signed transaction paying the seller's assets to us const tx = new Transaction() // Add outputs tx.addOutput({ lockingScript: LockingScript.fromHex(desiredBuyerScript), satoshis: this.satoshis }) tx.addOutput({ lockingScript: LockingScript.fromHex(desiredSellerScript), satoshis: this.satoshis }) // TODO: Buyer and seller may both want change. Currently this is not implemented // Go through all seller inputs and add them to the list for (let i = 0; i < entry.ownershipProof.tokens.length; i++) { tx.addInput({ sourceTransaction: Transaction.fromHex(entry.ownershipProof.tokens[i].output.envelope?.rawTx as string), sourceOutputIndex: entry.ownershipProof.tokens[i].output.vout, sequence: 0xffffffff }) } // Add the funding input tx.addInput({ sourceTransaction: Transaction.fromHex(fundingAction.rawTx), sourceOutputIndex: 0, sequence: 0xffffffff, unlockingScriptTemplate: fundingTemplate.unlock(this.protocolID, fundingKeyID, entry.seller) }) // Go through all buyer inputs and sign them conditionally for (let i = 0; i < buyerProof.tokens.length; i++) { const token = buyerProof.tokens[i] const parsedInstructions = JSON.parse(token.output.customInstructions as string) const keyID = this.getKeyIDFromInstructions(parsedInstructions) const counterparty = this.getCounterpartyFromInstructions(parsedInstructions) tx.addInput({ sourceTransaction: Transaction.fromHex(token.output.envelope?.rawTx as string), sourceOutputIndex: token.output.vout, sequence: 0xffffffff, unlockingScriptTemplate: template.unlock(this.protocolID, keyID, counterparty) }) } // sign the transacton await tx.sign() // Send the proof to the seller as an offer const partialTX = tx.toHex() const offer: MarketplaceOffer = { buyerPartialTX: partialTX, buyerProof, buyerOffersAssetId: assetId, buyerOffersAmount: amount, sellerEntry: entry, buyerFundingEnvelope: fundingAction, fundingKeyID, desiredSellerKeyID, desiredSellerChangeKeyID: undefined, desiredBuyerKeyID, desiredBuyerChangeKeyID: undefined } await this.tokenator.sendMessage({ recipient: entry.seller, messageBox: this.marketplaceMessageBox, body: JSON.stringify(offer) }) } // List outgoing offers // TODO: support forAsset using output tags async listOutgoingOffers(): Promise<MarketplaceOffer[]> { const basketEntries = await getTransactionOutputs({ basket: `${this.basket} trades`, spendable: true, includeEnvelope: true, includeCustomInstructions: true }) const rejectionMessages = await this.tokenator.listMessages({ messageBox: `${this.marketplaceMessageBox}_rejection` }) const results: MarketplaceOffer[] = [] for (let i = 0; i < basketEntries.length; i++) { const parsedInstructions: BuyerOfferCustomInstructions = JSON.parse(basketEntries[i].customInstructions as string) // Check if the offer is rejected const rejected = rejectionMessages.some(x => x.sender === parsedInstructions.sellerEntry.seller && x.body === basketEntries[i].txid) results.push({ buyerFundingEnvelope: verifyTruthy(basketEntries[i].envelope), buyerOffersAssetId: parsedInstructions.buyerOfferedAssetId, buyerOffersAmount: parsedInstructions.buyerOfferedAmount, buyerProof: parsedInstructions.buyerProof, buyerPartialTX: '', // The TX could not have been stored in custom instructions. // HOwever, the buyer does not need the TX to cancel the offer. // The buyer would just need to spend the funding UTXO. sellerEntry: parsedInstructions.sellerEntry, fundingKeyID: parsedInstructions.fundingKeyID, rejected }) } return results } // cancel outgoing offer async cancelOutgoingOffer(offer: MarketplaceOffer): Promise<void> { // Compute an unlocking script const fundingTX = Transaction.fromHex(offer.buyerFundingEnvelope.rawTx) const fundingTXID = offer.buyerFundingEnvelope.txid || fundingTX.id('hex') as string const signatureScope = TransactionSignature.SIGHASH_FORKID | TransactionSignature.SIGHASH_NONE | TransactionSignature.SIGHASH_ANYONECANPAY const preimage = TransactionSignature.format({ sourceTXID: fundingTXID, sourceOutputIndex: 0, sourceSatoshis: fundingTX.outputs[0].satoshis as number, transactionVersion: 1, otherInputs: [], inputIndex: 0, outputs: [], inputSequence: 0xffffffff, subscript: fundingTX.outputs[0].lockingScript, lockTime: 0, scope: signatureScope }) const preimageHash = Hash.sha256(preimage) const SDKSignature = await createSignature({ data: Uint8Array.from(preimageHash), protocolID: this.protocolID, keyID: offer.fundingKeyID, counterparty: offer.sellerEntry.seller }) const rawSignature = Signature.fromDER([...SDKSignature]) const sig = new TransactionSignature( rawSignature.r, rawSignature.s, signatureScope ) const sigForScript = sig.toChecksigFormat() const publicKeyString = await getPublicKey({ protocolID: this.protocolID, keyID: offer.fundingKeyID, counterparty: offer.sellerEntry.seller, forSelf: true }) const unlockingScript = new UnlockingScript([ { op: sigForScript.length, data: sigForScript }, { op: publicKeyString.length / 2, data: Utils.toArray(publicKeyString, 'hex') } ]).toHex() // Spend the offer's funding input in a transaction await createAction({ description: 'cancel an offer', inputs: { [fundingTXID]: { ...verifyTruthy(offer.buyerFundingEnvelope), outputsToRedeem: [{ index: 0, unlockingScript }] } } }) } async listIncomingOffers(forEntry?: MarketplaceEntry): Promise<MarketplaceOffer[]> { const offerMessages = await this.tokenator.listMessages({ messageBox: this.marketplaceMessageBox }) const results: MarketplaceOffer[] = [] let forEntryString: string | undefined = undefined if (typeof forEntry !== 'undefined') { forEntryString = stringify(forEntry) } const myEntries = await this.findAllAssetsForSale(true) const myEntriesStrings: string[] = myEntries.map(x => stringify(x)) for (let i = 0; i < offerMessages.length; i++) { try { const parsedOffer: MarketplaceOffer = JSON.parse(offerMessages[i].body) const sellerEntryString = stringify(parsedOffer.sellerEntry) if (!myEntriesStrings.some(x => x === sellerEntryString)) { continue } if (forEntryString && sellerEntryString !== forEntryString) { continue