UNPKG

btms-core

Version:

Tools for creating and managing UTXO-based tokens

1,133 lines (1,132 loc) 65 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.BTMS = void 0; const pushdrop_1 = __importDefault(require("pushdrop")); const sdk_ts_1 = require("@babbage/sdk-ts"); const authrite_js_1 = require("authrite-js"); const tokenator_1 = __importDefault(require("@babbage/tokenator")); const sdk_1 = require("@bsv/sdk"); const sendover_1 = require("sendover"); const cwi_crypto_1 = require("cwi-crypto"); const json_stable_stringify_1 = __importDefault(require("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) => { 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 { async lock(protocolID, keyID, counterparty, assetId, amount, metadata, forSelf = false) { const publicKey = await (0, sdk_ts_1.getPublicKey)({ protocolID, keyID, counterparty, forSelf }); const lockPart = new sdk_1.LockingScript([ { op: publicKey.length / 2, data: sdk_1.Utils.toArray(publicKey, 'hex') }, { op: sdk_1.OP.OP_CHECKSIG } ]).toHex(); const fields = [ assetId, String(amount), metadata ]; const dataToSign = Buffer.concat(fields.map(x => Buffer.from(x))); const signature = await (0, sdk_ts_1.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 sdk_1.LockingScript.fromHex(`${lockPart}${pushPart}${dropPart}`); } unlock(protocolID, keyID, counterparty, sourceTXID, sourceSatoshis, lockingScript, signOutputs = 'all', anyoneCanPay = false) { return { sign: async (tx, inputIndex) => { var _a, _b, _c; const input = tx.inputs[inputIndex]; const otherInputs = tx.inputs.filter((_, index) => index !== inputIndex); sourceTXID = input.sourceTXID ? input.sourceTXID : (_a = input.sourceTransaction) === null || _a === void 0 ? void 0 : _a.id('hex'); if (!sourceTXID) { throw new Error('The input sourceTXID or sourceTransaction is required for transaction signing.'); } sourceSatoshis || (sourceSatoshis = (_b = input.sourceTransaction) === null || _b === void 0 ? void 0 : _b.outputs[input.sourceOutputIndex].satoshis); if (!sourceSatoshis) { throw new Error('The sourceSatoshis or input sourceTransaction is required for transaction signing.'); } lockingScript || (lockingScript = (_c = input.sourceTransaction) === null || _c === void 0 ? void 0 : _c.outputs[input.sourceOutputIndex].lockingScript); if (!lockingScript) { throw new Error('The lockingScript or input sourceTransaction is required for transaction signing.'); } let signatureScope = sdk_1.TransactionSignature.SIGHASH_FORKID; if (signOutputs === 'all') { signatureScope |= sdk_1.TransactionSignature.SIGHASH_ALL; } if (signOutputs === 'none') { signatureScope |= sdk_1.TransactionSignature.SIGHASH_NONE; } if (signOutputs === 'single') { signatureScope |= sdk_1.TransactionSignature.SIGHASH_SINGLE; } if (anyoneCanPay) { signatureScope |= sdk_1.TransactionSignature.SIGHASH_ANYONECANPAY; } const preimage = sdk_1.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 = sdk_1.Hash.sha256(preimage); const SDKSignature = await (0, sdk_ts_1.createSignature)({ data: Uint8Array.from(preimageHash), protocolID, keyID, counterparty }); const rawSignature = sdk_1.Signature.fromDER([...SDKSignature]); const sig = new sdk_1.TransactionSignature(rawSignature.r, rawSignature.s, signatureScope); const sigForScript = sig.toChecksigFormat(); return new sdk_1.UnlockingScript([ { op: sigForScript.length, data: sigForScript } ]); }, estimateLength: async () => 72 }; } } class BTMSFundingToken { async lock(protocolID, keyID, counterparty) { const fundingPublicKeyString = await (0, sdk_ts_1.getPublicKey)({ protocolID, keyID, counterparty }); const fundingAddress = sdk_1.PublicKey.fromString(fundingPublicKeyString).toAddress(); return new sdk_1.P2PKH().lock(fundingAddress); } unlock(protocolID, keyID, counterparty) { return { sign: async (tx, inputIndex) => { var _a, _b, _c; const input = tx.inputs[inputIndex]; const otherInputs = tx.inputs.filter((_, index) => index !== inputIndex); const sourceTXID = input.sourceTXID ? input.sourceTXID : (_a = input.sourceTransaction) === null || _a === void 0 ? void 0 : _a.id('hex'); if (!sourceTXID) { throw new Error('The input sourceTXID or sourceTransaction is required for transaction signing.'); } const sourceSatoshis = (_b = input.sourceTransaction) === null || _b === void 0 ? void 0 : _b.outputs[input.sourceOutputIndex].satoshis; if (!sourceSatoshis) { throw new Error('The sourceSatoshis or input sourceTransaction is required for transaction signing.'); } const lockingScript = (_c = input.sourceTransaction) === null || _c === void 0 ? void 0 : _c.outputs[input.sourceOutputIndex].lockingScript; if (!lockingScript) { throw new Error('The lockingScript or input sourceTransaction is required for transaction signing.'); } const signatureScope = sdk_1.TransactionSignature.SIGHASH_FORKID | sdk_1.TransactionSignature.SIGHASH_ALL; const preimage = sdk_1.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 = sdk_1.Hash.sha256(preimage); const SDKSignature = await (0, sdk_ts_1.createSignature)({ data: Uint8Array.from(preimageHash), protocolID, keyID, counterparty }); const rawSignature = sdk_1.Signature.fromDER([...SDKSignature]); const sig = new sdk_1.TransactionSignature(rawSignature.r, rawSignature.s, signatureScope); const sigForScript = sig.toChecksigFormat(); const publicKeyString = await (0, sdk_ts_1.getPublicKey)({ protocolID, keyID, counterparty, forSelf: true }); return new sdk_1.UnlockingScript([ { op: sigForScript.length, data: sigForScript }, { op: publicKeyString.length / 2, data: sdk_1.Utils.toArray(publicKeyString, 'hex') } ]); }, estimateLength: async () => 106 }; } } /** * Verify that the possibly undefined value currently has a value. */ function verifyTruthy(v, description) { if (v == null) throw new Error(description !== null && description !== void 0 ? description : 'A truthy value is required.'); return v; } /** * The BTMS class provides an interface for managing and transacting assets using the Babbage SDK. * @class */ class BTMS { /** * 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, 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 = {}; const tokenatorParams = { peerServHost }; if (privateKey) { authriteParams.clientPrivateKey = privateKey; tokenatorParams.clientPrivateKey = privateKey; } this.tokenator = new tokenator_1.default(tokenatorParams); this.authrite = new authrite_js_1.Authrite(authriteParams); this.privateKey = privateKey; this.marketplaceMessageBox = marketplaceMessageBox; this.marketplaceTopic = marketplaceTopic; } async listAssets() { const tokens = await (0, sdk_ts_1.getTransactionOutputs)({ basket: this.basket, spendable: true, includeEnvelope: false }); const assets = {}; for (const token of tokens) { const decoded = pushdrop_1.default.decode({ script: token.outputScript, fieldFormat: 'utf8' }); let assetId = decoded.fields[0]; if (assetId === 'ISSUE') { assetId = `${token.txid}.${token.vout}`; } let parsedMetadata = { name: '' }; try { parsedMetadata = JSON.parse(decoded.fields[2]); } catch (_) { } if (!parsedMetadata.name) { continue; } if (!assets[assetId]) { assets[assetId] = Object.assign(Object.assign({}, 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_1.default.decode({ script: token.outputScript, fieldFormat: 'utf8' }); let decodedAssetId = 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 + amount; } } else { let parsedMetadata = {}; try { parsedMetadata = JSON.parse(decodedToken.fields[2]); } catch (_) { /* ignore */ } assets[decodedAssetId] = Object.assign(Object.assign({ 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, name) { 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 (0, sdk_ts_1.createAction)({ 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, recipient, sendAmount, disablePeerServ = false, onPaymentSent = (payment) => { }) { var _a, _b, _c, _d, _e, _f, _g, _h; 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 (0, sdk_ts_1.getPublicKey)({ identityKey: true }); // TODO: signing strategy // We can decode the first token to extract the metadata needed in the outputs const { fields: [, , metadata] } = pushdrop_1.default.decode({ script: myTokens[0].outputScript, fieldFormat: 'utf8' }); let parsedMetadata = { name: 'Token' }; try { parsedMetadata = JSON.parse(metadata); } catch (e) { /* ignore */ } // Create redeem scripts for your tokens const inputs = {}; for (const t of myTokens) { const unlockingScript = await pushdrop_1.default.redeem({ 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] = Object.assign(Object.assign({}, t.envelope), { inputs: typeof ((_a = t.envelope) === null || _a === void 0 ? void 0 : _a.inputs) === 'string' ? JSON.parse((_b = t.envelope) === null || _b === void 0 ? void 0 : _b.inputs) : (_c = t.envelope) === null || _c === void 0 ? void 0 : _c.inputs, mapiResponses: typeof ((_d = t.envelope) === null || _d === void 0 ? void 0 : _d.mapiResponses) === 'string' ? JSON.parse(t.envelope.mapiResponses) : (_e = t.envelope) === null || _e === void 0 ? void 0 : _e.mapiResponses, proof: typeof ((_f = t.envelope) === null || _f === void 0 ? void 0 : _f.proof) === 'string' ? JSON.parse((_g = t.envelope) === null || _g === void 0 ? void 0 : _g.proof) : (_h = t.envelope) === null || _h === void 0 ? void 0 : _h.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 = []; 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 (0, sdk_ts_1.createAction)({ description: `Send ${sendAmount} ${parsedMetadata.name} to ${recipient}`, labels: [assetId.replace('.', ' ')], inputs, outputs }); const tokenForRecipient = { txid: action.txid, vout: 0, amount: this.satoshis, envelope: Object.assign({}, 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) { const myIncomingMessages = await this.tokenator.listMessages({ messageBox: this.tokensMessageBox }); const payments = []; for (const message of myIncomingMessages) { let parsedBody, token; try { parsedBody = JSON.parse(JSON.parse(message.body)); token = parsedBody.token; const decodedToken = pushdrop_1.default.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 = Object.assign(Object.assign({}, 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, payment) { // Verify the token is owned by the user const decodedToken = pushdrop_1.default.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 (0, sdk_ts_1.getPublicKey)({ 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: 'Token' }; try { parsedMetadata = JSON.parse(decodedToken.fields[2]); } catch (e) { } // Submit transaction await (0, sdk_ts_1.submitDirectTransaction)({ senderIdentityKey: payment.sender, note: `Receive ${decodedToken.fields[1]} ${parsedMetadata.name} from ${payment.sender}`, amount: this.satoshis, labels: [assetId.replace('.', ' ')], transaction: Object.assign(Object.assign({}, 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, payment) { // We can decode the first token to extract the metadata needed in the outputs const { fields: [, , metadata] } = pushdrop_1.default.decode({ script: payment.outputScript, fieldFormat: 'utf8' }); // Create redeem scripts for your tokens const inputs = {}; const unlockingScript = await pushdrop_1.default.redeem({ 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] = Object.assign(Object.assign({}, 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 = []; 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 (0, sdk_ts_1.createAction)({ labels: [assetId.replace('.', ' ')], description: `Returning ${payment.amount} tokens to ${payment.sender}`, inputs, outputs }); const tokenForRecipient = { txid: action.txid, vout: 0, amount: this.satoshis, envelope: Object.assign({}, 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, includeEnvelope = true) { const tokens = await (0, sdk_ts_1.getTransactionOutputs)({ basket: this.basket, spendable: true, includeEnvelope }); return tokens.filter(x => { const decoded = pushdrop_1.default.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, myTokens) { if (!Array.isArray(myTokens)) { myTokens = await this.getTokens(assetId, false); } let balance = 0; for (const x of myTokens) { const t = pushdrop_1.default.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, limit, offset) { const actions = await (0, sdk_ts_1.listActions)({ 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_1.default.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_1.default.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 Object.assign(Object.assign({}, actions), { transactions: txs }); } async proveOwnership(assetId, amount, verifier) { // Get a list of tokens const myTokens = await this.getTokens(assetId, true); let amountProven = 0; const provenTokens = []; const myIdentityKey = await (0, sdk_ts_1.getPublicKey)({ identityKey: true }); // Go through the list for (const token of myTokens) { // Obtain key linkage for each token const parsedInstructions = JSON.parse(token.customInstructions); const linkage = await (0, sdk_ts_1.revealKeyLinkage)({ mode: 'specific', counterparty: this.getCounterpartyFromInstructions(parsedInstructions), protocolID: this.protocolID, keyID: this.getKeyIDFromInstructions(parsedInstructions), verifier, description: 'Prove token ownership' }); provenTokens.push({ output: token, linkage: linkage }); // Increment the amount counter each time const t = pushdrop_1.default.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, useAnyoneKey = false) { // 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_1.default.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) { 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, amount, desiredAssets, description) { // 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 sdk_1.PrivateKey(ANYONE, 'hex').toPublicKey().toString(); const proof = await this.proveOwnership(assetId, amount, anyonePub); // Compose a PushDrop token const token = await pushdrop_1.default.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 (0, sdk_ts_1.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) { const findParams = {}; if (findMine) { const myIdentity = await (0, sdk_ts_1.getPublicKey)({ identityKey: true }); findParams.seller = myIdentity; } else { findParams.findAll = true; } const assets = await this.findFromMarketplaceOverlay(findParams); const results = []; for (const asset of assets) { const decoded = pushdrop_1.default.decode({ script: asset.outputScript, returnType: 'buffer' }); const parsedProof = JSON.parse(decoded.fields[0].toString('utf8')); const parsedDesiredAssets = JSON.parse(decoded.fields[1].toString('utf8')); const decodedAsset = pushdrop_1.default.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, assetId, amount) { var _a, _b; // 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 = { buyerProof, buyerOfferedAssetId: assetId, buyerOfferedAmount: amount, sellerEntry: entry, fundingKeyID }; const fundingAction = await (0, sdk_ts_1.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_1.default.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 sdk_1.Transaction(); // Add outputs tx.addOutput({ lockingScript: sdk_1.LockingScript.fromHex(desiredBuyerScript), satoshis: this.satoshis }); tx.addOutput({ lockingScript: sdk_1.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: sdk_1.Transaction.fromHex((_a = entry.ownershipProof.tokens[i].output.envelope) === null || _a === void 0 ? void 0 : _a.rawTx), sourceOutputIndex: entry.ownershipProof.tokens[i].output.vout, sequence: 0xffffffff }); } // Add the funding input tx.addInput({ sourceTransaction: sdk_1.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); const keyID = this.getKeyIDFromInstructions(parsedInstructions); const counterparty = this.getCounterpartyFromInstructions(parsedInstructions); tx.addInput({ sourceTransaction: sdk_1.Transaction.fromHex((_b = token.output.envelope) === null || _b === void 0 ? void 0 : _b.rawTx), 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 = { 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() { const basketEntries = await (0, sdk_ts_1.getTransactionOutputs)({ basket: `${this.basket} trades`, spendable: true, includeEnvelope: true, includeCustomInstructions: true }); const rejectionMessages = await this.tokenator.listMessages({ messageBox: `${this.marketplaceMessageBox}_rejection` }); const results = []; for (let i = 0; i < basketEntries.length; i++) { const parsedInstructions = JSON.parse(basketEntries[i].customInstructions); // 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: '', // 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 });