btms-core
Version:
Tools for creating and managing UTXO-based tokens
1,133 lines (1,132 loc) • 65 kB
JavaScript
"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
});