UNPKG

ecash-agora

Version:

Library for interacting with the eCash Agora protocol

642 lines 28 kB
"use strict"; // Copyright (c) 2024 The Bitcoin developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. Object.defineProperty(exports, "__esModule", { value: true }); exports.scriptOps = exports.Agora = exports.AgoraOffer = void 0; const ecash_lib_1 = require("ecash-lib"); const oneshot_js_1 = require("./oneshot.js"); const partial_js_1 = require("./partial.js"); const TOKEN_ID_PREFIX = (0, ecash_lib_1.toHex)((0, ecash_lib_1.strToBytes)('T')); const PUBKEY_PREFIX = (0, ecash_lib_1.toHex)((0, ecash_lib_1.strToBytes)('P')); const FUNGIBLE_TOKEN_ID_PREFIX = (0, ecash_lib_1.toHex)((0, ecash_lib_1.strToBytes)('F')); const GROUP_TOKEN_ID_PREFIX = (0, ecash_lib_1.toHex)((0, ecash_lib_1.strToBytes)('G')); const PLUGIN_NAME = 'agora'; const ONESHOT_HEX = (0, ecash_lib_1.toHex)((0, ecash_lib_1.strToBytes)(oneshot_js_1.AgoraOneshot.COVENANT_VARIANT)); const PARTIAL_HEX = (0, ecash_lib_1.toHex)((0, ecash_lib_1.strToBytes)(partial_js_1.AgoraPartial.COVENANT_VARIANT)); const PLUGIN_GROUPS_MAX_PAGE_SIZE = 50; /** * Individual token offer on the Agora, i.e. one UTXO offering tokens. * * It can be used to accept or cancel the offer. */ class AgoraOffer { constructor(params) { this.variant = params.variant; this.outpoint = params.outpoint; this.txBuilderInput = params.txBuilderInput; this.token = params.token; this.status = params.status; if (typeof this.takenInfo !== 'undefined') { this.takenInfo = params.takenInfo; } } /** * Build a tx accepting this offer. * * Agora offers are UTXOs on the blockchain that can be accepted by anyone * sending sufficient satoshis to a required output. * * `fuelInputs` has to provide enough sats for this offer to cover ask + tx fee. * */ acceptTx(params) { const dustSats = params.dustSats ?? ecash_lib_1.DEFAULT_DUST_SATS; const feePerKb = params.feePerKb ?? ecash_lib_1.DEFAULT_FEE_SATS_PER_KB; const allowUnspendable = params.allowUnspendable ?? false; const txBuild = this._acceptTxBuilder({ covenantSk: params.covenantSk, covenantPk: params.covenantPk, fuelInputs: params.fuelInputs, extraOutputs: [ { sats: dustSats, script: params.recipientScript, }, params.recipientScript, ], acceptedAtoms: params.acceptedAtoms, allowUnspendable, }); return txBuild.sign({ feePerKb, dustSats }); } /** * How many extra satoshis are required to fuel this offer so it can be * broadcast on the network, excluding the asked sats. * This should be displayed to the user as network fee. * The total required input amount is askedSats + acceptFeeSats. **/ acceptFeeSats(params) { const feePerKb = params.feePerKb ?? ecash_lib_1.DEFAULT_FEE_SATS_PER_KB; const txBuild = this._acceptTxBuilder({ covenantSk: new Uint8Array(32), covenantPk: new Uint8Array(33), fuelInputs: params.extraInputs ?? [], extraOutputs: [ { sats: 0n, script: params.recipientScript, }, ], acceptedAtoms: params.acceptedAtoms, /** We do not need to validate for this condition when we get the fee */ allowUnspendable: true, }); const measureTx = txBuild.sign({ ecc: new ecash_lib_1.EccDummy() }); return BigInt(Math.ceil((measureTx.serSize() * Number(feePerKb)) / 1000)); } _acceptTxBuilder(params) { switch (this.variant.type) { case 'ONESHOT': return new ecash_lib_1.TxBuilder({ inputs: [ ...params.fuelInputs, { input: this.txBuilderInput, signatory: (0, oneshot_js_1.AgoraOneshotSignatory)(params.covenantSk, params.covenantPk, this.variant.params.enforcedOutputs.length), }, ], outputs: [ ...this.variant.params.enforcedOutputs, ...params.extraOutputs, ], }); case 'PARTIAL': { if (params.acceptedAtoms === undefined) { throw new Error('Must set acceptedAtoms for partial offers'); } const txBuild = new ecash_lib_1.TxBuilder(); const agoraPartial = this.variant.params; const truncFactor = 1n << BigInt(8 * agoraPartial.numAtomsTruncBytes); if (params.acceptedAtoms % truncFactor != 0n) { throw new Error(`Must acceptedAtoms must be a multiple of ${truncFactor}`); } if (params.allowUnspendable === false || typeof params.allowUnspendable === 'undefined') { // Prevent creation of unacceptable offer agoraPartial.preventUnacceptableRemainder(params.acceptedAtoms); } txBuild.inputs.push({ input: this.txBuilderInput, signatory: (0, partial_js_1.AgoraPartialSignatory)(agoraPartial, params.acceptedAtoms / truncFactor, params.covenantSk, params.covenantPk), }); txBuild.inputs.push(...params.fuelInputs); const sendAtomsArray = [0n]; const offeredAtoms = this.token.atoms; if (offeredAtoms > params.acceptedAtoms) { sendAtomsArray.push(offeredAtoms - params.acceptedAtoms); } sendAtomsArray.push(params.acceptedAtoms); if (agoraPartial.tokenProtocol === 'SLP') { txBuild.outputs.push({ sats: 0n, script: (0, ecash_lib_1.slpSend)(this.token.tokenId, this.token.tokenType.number, sendAtomsArray), }); } else if (agoraPartial.tokenProtocol === 'ALP') { txBuild.outputs.push({ sats: 0n, script: (0, ecash_lib_1.emppScript)([ agoraPartial.adPushdata(), (0, ecash_lib_1.alpSend)(this.token.tokenId, this.token.tokenType.number, sendAtomsArray), ]), }); } else { throw new Error('Not implemented'); } txBuild.outputs.push({ sats: agoraPartial.askedSats(params.acceptedAtoms), script: ecash_lib_1.Script.p2pkh((0, ecash_lib_1.shaRmd160)(agoraPartial.makerPk)), }); if (offeredAtoms > params.acceptedAtoms) { const newAgoraPartial = new partial_js_1.AgoraPartial({ ...agoraPartial, truncAtoms: (offeredAtoms - params.acceptedAtoms) / truncFactor, }); txBuild.outputs.push({ sats: agoraPartial.dustSats, script: ecash_lib_1.Script.p2sh((0, ecash_lib_1.shaRmd160)(newAgoraPartial.script().bytecode)), }); } txBuild.outputs.push(...params.extraOutputs); txBuild.locktime = agoraPartial.enforcedLockTime; return txBuild; } default: throw new Error('Not implemented'); } } /** * Build a tx canceling the offer. * * An offer can only be cancelled using the secret key that created it. * * `fuelInputs` must cover the tx fee, you can calculate it with cancelFeeSats. **/ cancelTx(params) { const dustSats = params.dustSats ?? ecash_lib_1.DEFAULT_DUST_SATS; const feePerKb = params.feePerKb ?? ecash_lib_1.DEFAULT_FEE_SATS_PER_KB; const txBuild = this._cancelTxBuilder({ cancelSk: params.cancelSk, fuelInputs: params.fuelInputs, extraOutputs: [ { sats: dustSats, script: params.recipientScript, }, params.recipientScript, ], }); return txBuild.sign({ feePerKb, dustSats }); } /** * How many extra satoshis are required to fuel cancelling this offer, * so the cancel tx can be broadcast on the network, excluding the asked * sats and a dust amount to receive the tokens. * * extraInputs can be used to add an ad input so we have the correct * estimate in case of a cancel + reoffer. * * This should be displayed to the user as cancellation network fee. * The total required sats input amount is returned by this function. **/ cancelFeeSats(params) { const feePerKb = params.feePerKb ?? ecash_lib_1.DEFAULT_FEE_SATS_PER_KB; const txBuild = this._cancelTxBuilder({ cancelSk: new Uint8Array(32), fuelInputs: params.extraInputs ?? [], extraOutputs: [ { sats: 0n, script: params.recipientScript, }, ], }); const measureTx = txBuild.sign({ ecc: new ecash_lib_1.EccDummy() }); return BigInt(Math.ceil((measureTx.serSize() * Number(feePerKb)) / 1000)); } _cancelTxBuilder(params) { let signatory; let tokenProtocol; switch (this.variant.type) { case 'ONESHOT': signatory = (0, oneshot_js_1.AgoraOneshotCancelSignatory)(params.cancelSk); tokenProtocol = 'SLP'; break; case 'PARTIAL': tokenProtocol = this.variant.params.tokenProtocol; signatory = (0, partial_js_1.AgoraPartialCancelSignatory)(params.cancelSk, tokenProtocol); break; default: throw new Error('Not implemented'); } const outputs = []; switch (tokenProtocol) { case 'SLP': outputs.push({ sats: 0n, script: (0, ecash_lib_1.slpSend)(this.token.tokenId, this.token.tokenType.number, [this.token.atoms]), }); break; case 'ALP': outputs.push({ sats: 0n, script: (0, ecash_lib_1.emppScript)([ (0, ecash_lib_1.alpSend)(this.token.tokenId, this.token.tokenType.number, [this.token.atoms]), ]), }); break; } outputs.push(...params.extraOutputs); return new ecash_lib_1.TxBuilder({ inputs: [ ...params.fuelInputs, { input: this.txBuilderInput, signatory, }, ], outputs, }); } /** * How many satoshis are asked to accept this offer, excluding tx fees. * This is what should be displayed to the user as the price. **/ askedSats(acceptedAtoms) { switch (this.variant.type) { case 'ONESHOT': return this.variant.params.askedSats(); case 'PARTIAL': if (acceptedAtoms === undefined) { throw new Error('Must provide acceptedAtoms for PARTIAL offers'); } return this.variant.params.askedSats(acceptedAtoms); default: throw new Error('Not implemented'); } } } exports.AgoraOffer = AgoraOffer; /** * Enables access to Agora, via Chronik instances that have the "agora" plugin * loaded. * * See agora.py. **/ class Agora { /** * Create an Agora instance. The provided Chronik instance must have the * "agora" plugin loaded. **/ constructor(chronik, dustSats) { this.chronik = chronik; this.plugin = chronik.plugin(PLUGIN_NAME); this.dustSats = dustSats ?? ecash_lib_1.DEFAULT_DUST_SATS; } /** * Query all the token IDs, fungible and non-fungible ones, that have active * Agora offers. **/ async allOfferedTokenIds() { return await this._allTokenIdsByPrefix(TOKEN_ID_PREFIX); } /** Query all fungible token IDs that have active Agora offers. */ async offeredFungibleTokenIds() { return await this._allTokenIdsByPrefix(FUNGIBLE_TOKEN_ID_PREFIX); } /** * Query all token IDs of groups of non-fungible tokens that have active * Agora offers. **/ async offeredGroupTokenIds() { return await this._allTokenIdsByPrefix(GROUP_TOKEN_ID_PREFIX); } /** Query all active offers by token ID. */ async activeOffersByTokenId(tokenId) { return await this._activeOffersByGroup(TOKEN_ID_PREFIX + tokenId); } /** Query all active offers by group token ID. */ async activeOffersByGroupTokenId(groupTokenId) { return await this._activeOffersByGroup(GROUP_TOKEN_ID_PREFIX + groupTokenId); } /** Query all active offers with the given cancel pubkey. */ async activeOffersByPubKey(pubkeyHex) { return await this._activeOffersByGroup(PUBKEY_PREFIX + pubkeyHex); } /** * Query historic offers (paginated) * * These are basically the "candlesticks" of a specific token (and also the * cancelled offers, but those would have to be ignored). * Offers can also be queried by pubkey, giving a history of user's offers. **/ async historicOffers(params) { const groupHex = this._groupHex(params); let result; switch (params.table) { case 'CONFIRMED': result = await this.plugin.confirmedTxs(groupHex, params.page, params.pageSize); break; case 'UNCONFIRMED': result = await this.plugin.unconfirmedTxs(groupHex, params.page, params.pageSize); break; case 'HISTORY': result = await this.plugin.history(groupHex, params.page, params.pageSize); break; default: throw new Error('Unsupported table'); } const offers = result.txs.flatMap(tx => { return tx.inputs.flatMap(input => { if (input.plugins === undefined || input.plugins[PLUGIN_NAME] === undefined) { return []; } const ops = scriptOps(new ecash_lib_1.Script((0, ecash_lib_1.fromHex)(input.inputScript))); // isCanceled is always the last pushop (before redeemScript) const opIsCanceled = ops[ops.length - 2]; const isCanceled = opIsCanceled === ecash_lib_1.OP_0; // If isCanceled, then offer.token.atoms is the canceled amount let takenInfo; if (!isCanceled) { // If this tx is not canceling an agora offer if (typeof tx.outputs[1].plugins !== 'undefined' && 'agora' in tx.outputs[1].plugins) { // If this tx creates a new agora offer at index 1 of outputs // then it is NOT creating the "change" offer from a partial accept // It is creating a new offer and just happens to have a spent agora utxo // from a full accept in its inputs // So, we do not parse in historicOffers; we parse this offer in the tx that // accepts or cancels it // If this is an offer tx with a TAKEN inputScript // but it was not created by a buy or cancel // // We can get an offer with a buy or cancel as an input even tho // that particular input does not mean this tx is the buy or cancel // just the way utxos have worked out // Examples // a156d668965294aee6d8ebbe2b7b6e2c574bdf006b19842b3836b4e580825aca // ec73d56ba0b5acf9f3023124a227a19e0794c2da6e4860fb76181b2737e62c61 // fbd288aad796753c672c9bbce9badbf02ff62f12f023163bbc034608b249d21a // Do not include this in historicOffers, we will pick it up by its buy or cancel return []; } // If this is TAKEN, provide useful parsed info from the tx // The taken qty is the token amount in the outputs that is not rolled into another agora offer // i.e. the token amount that goes to a p2pkh address // The price paid is the XEC that goes to the offer creator // In practice, we can get these amounts by following the rules below // Note we may see AgoraOffer change in the future and need to update this parsing // The purchase price is satoshis that go to the offer creator // Index 1 output const sats = tx.outputs[1].sats; // The taker receives the purchased tokens at a p2pkh address // This is at index 2 for a buy of the full offer and index 3 for a partial buy // If tx.outputs[2].outputScript is p2sh, that means partialbuy and takerBuyIndex is 3 const takerBuyIndex = tx.outputs[2].outputScript.startsWith('76a914') ? 2 : 3; const takerScriptHex = tx.outputs[takerBuyIndex].outputScript; const atoms = tx.outputs[takerBuyIndex].token?.atoms; if (typeof atoms === 'bigint') { // Should always be true but we may have different kinds of agora // offers in the future // So, we only set if we have the info we expect takenInfo = { sats, atoms, takerScriptHex, }; } } delete input.token?.entryIdx; // UTXO token has no entryIdx const offer = this._parseOfferUtxo({ outpoint: input.prevOut, blockHeight: tx.block?.height ?? -1, isCoinbase: tx.isCoinbase, sats: input.sats, script: input.outputScript, isFinal: false, plugins: input.plugins, token: input.token, }, isCanceled ? 'CANCELED' : 'TAKEN'); if (offer === undefined) { return []; } if (typeof takenInfo !== 'undefined') { // Add takenInfo for taken offers offer.takenInfo = takenInfo; } return [offer]; }); }); return { offers, numTxs: result.numTxs, numPages: result.numPages, }; } /** Subscribe to updates from the websocket for some params */ subscribeWs(ws, params) { const groupHex = this._groupHex(params); ws.subscribeToPlugin(PLUGIN_NAME, groupHex); } /** Unsubscribe from updates from the websocket for some params */ unsubscribeWs(ws, params) { const groupHex = this._groupHex(params); ws.unsubscribeFromPlugin(PLUGIN_NAME, groupHex); } /** * Build a safe AgoraPartial for the given parameters. * * This looks at the blockchain to avoid creating an identical offer, by * tweaking the enforcedLockTime. */ async selectParams(params) { // Assumes MTP is not more than 14 days in the past const maxLockTime = new Date().getTime() / 1000 - 14 * 24 * 3600; const minLockTime = 500000000; // The probability of requiring a re-roll is only ~10^-9, but that's // still high enough so we have to do it. // If someone were to create 1000 identical offers, the probability of // picking two conflicting locktimes would be 0.04%, and for 10000 it's // even 4%, so we definitely have to check for duplicates. // See https://www.bdayprob.com/, where D = 1200000000 and N = 1000 // (or 10000), and solve for P(D,N). for (;;) { const enforcedLockTime = Math.floor(Math.random() * (maxLockTime - minLockTime)) + minLockTime; const newParams = params instanceof partial_js_1.AgoraPartial ? new partial_js_1.AgoraPartial({ ...params, enforcedLockTime }) : partial_js_1.AgoraPartial.approximateParams({ ...params, enforcedLockTime, }); const agoraScript = newParams.script(); const utxos = await this.chronik .script('p2sh', (0, ecash_lib_1.toHex)((0, ecash_lib_1.shaRmd160)(agoraScript.bytecode))) .utxos(); if (utxos.utxos.length === 0) { return newParams; } } } _groupHex(params) { switch (params.type) { case 'TOKEN_ID': return TOKEN_ID_PREFIX + params.tokenId; case 'GROUP_TOKEN_ID': return GROUP_TOKEN_ID_PREFIX + params.groupTokenId; case 'PUBKEY': return PUBKEY_PREFIX + params.pubkeyHex; default: throw new Error('Unsupported type'); } } async _allTokenIdsByPrefix(prefixHex) { const tokenIds = []; let nextStart = undefined; while (nextStart !== '') { const groups = await this.plugin.groups(prefixHex, nextStart, PLUGIN_GROUPS_MAX_PAGE_SIZE); tokenIds.push(...groups.groups.map(({ group }) => group.substring(prefixHex.length))); nextStart = groups.nextStart; } return tokenIds; } async _activeOffersByGroup(groupHex) { const utxos = await this.plugin.utxos(groupHex); return utxos.utxos.flatMap(utxo => { const offer = this._parseOfferUtxo(utxo, 'OPEN'); return offer ? [offer] : []; }); } _parseOfferUtxo(utxo, status) { if (utxo.plugins === undefined) { return undefined; } const plugin = utxo.plugins[PLUGIN_NAME]; if (plugin === undefined) { return undefined; } const covenantVariant = plugin.data[0]; switch (covenantVariant) { case ONESHOT_HEX: return this._parseOneshotOfferUtxo(utxo, plugin, status); case PARTIAL_HEX: return this._parsePartialOfferUtxo(utxo, plugin, status); default: return undefined; } } _parseOneshotOfferUtxo(utxo, plugin, status) { if (utxo.token?.tokenType.protocol !== 'SLP') { // Currently only SLP supported return undefined; } const outputsSerHex = plugin.data[1]; const outputsSerBytes = new ecash_lib_1.Bytes((0, ecash_lib_1.fromHex)(outputsSerHex)); const enforcedOutputs = [ { sats: 0n, script: (0, ecash_lib_1.slpSend)(utxo.token.tokenId, utxo.token.tokenType.number, [0n, utxo.token.atoms]), }, ]; while (outputsSerBytes.data.length > outputsSerBytes.idx) { enforcedOutputs.push((0, ecash_lib_1.readTxOutput)(outputsSerBytes)); } const cancelPkGroupHex = plugin.groups.find(group => group.startsWith(PUBKEY_PREFIX)); if (cancelPkGroupHex === undefined) { return undefined; } const cancelPk = (0, ecash_lib_1.fromHex)(cancelPkGroupHex.substring(PUBKEY_PREFIX.length)); const agoraOneshot = new oneshot_js_1.AgoraOneshot({ enforcedOutputs, cancelPk, }); return new AgoraOffer({ variant: { type: 'ONESHOT', params: agoraOneshot, }, outpoint: utxo.outpoint, txBuilderInput: { prevOut: utxo.outpoint, signData: { sats: utxo.sats, redeemScript: agoraOneshot.script(), }, }, token: utxo.token, status, }); } _parsePartialOfferUtxo(utxo, plugin, status) { if (utxo.token === undefined) { return undefined; } // Plugin gives us the offer data in this form const [_, numAtomsTruncBytesHex, numSatsTruncBytesHex, atomsScaleFactorHex, scaledTruncAtomsPerTruncSatHex, minAcceptedScaledTruncAtomsHex, enforcedLockTimeHex,] = plugin.data; if (enforcedLockTimeHex === undefined) { throw new Error('Outdated plugin'); } const numAtomsTruncBytes = (0, ecash_lib_1.fromHex)(numAtomsTruncBytesHex)[0]; const numSatsTruncBytes = (0, ecash_lib_1.fromHex)(numSatsTruncBytesHex)[0]; const atomsScaleFactor = new ecash_lib_1.Bytes((0, ecash_lib_1.fromHex)(atomsScaleFactorHex)).readU64(); const scaledTruncAtomsPerTruncSat = new ecash_lib_1.Bytes((0, ecash_lib_1.fromHex)(scaledTruncAtomsPerTruncSatHex)).readU64(); const minAcceptedScaledTruncAtoms = new ecash_lib_1.Bytes((0, ecash_lib_1.fromHex)(minAcceptedScaledTruncAtomsHex)).readU64(); const enforcedLockTime = new ecash_lib_1.Bytes((0, ecash_lib_1.fromHex)(enforcedLockTimeHex)).readU32(); const makerPkGroupHex = plugin.groups.find(group => group.startsWith(PUBKEY_PREFIX)); if (makerPkGroupHex === undefined) { return undefined; } if (utxo.token.tokenType.protocol == 'UNKNOWN') { return undefined; } const makerPk = (0, ecash_lib_1.fromHex)(makerPkGroupHex.substring(PUBKEY_PREFIX.length)); const agoraPartial = new partial_js_1.AgoraPartial({ truncAtoms: utxo.token.atoms >> (8n * BigInt(numAtomsTruncBytes)), numAtomsTruncBytes, atomsScaleFactor, scaledTruncAtomsPerTruncSat, numSatsTruncBytes, makerPk, minAcceptedScaledTruncAtoms, tokenId: utxo.token.tokenId, tokenType: utxo.token.tokenType.number, tokenProtocol: utxo.token.tokenType.protocol, scriptLen: 0x7f, enforcedLockTime, dustSats: this.dustSats, }); agoraPartial.updateScriptLen(); return new AgoraOffer({ variant: { type: 'PARTIAL', params: agoraPartial, }, outpoint: utxo.outpoint, txBuilderInput: { prevOut: utxo.outpoint, signData: { sats: utxo.sats, redeemScript: agoraPartial.script(), }, }, token: utxo.token, status, }); } } exports.Agora = Agora; function scriptOps(script) { const opsIter = script.ops(); const ops = []; let op; while ((op = opsIter.next()) !== undefined) { ops.push(op); } return ops; } exports.scriptOps = scriptOps; //# sourceMappingURL=agora.js.map