ecash-agora
Version:
Library for interacting with the eCash Agora protocol
942 lines • 42.4 kB
JavaScript
"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.Agora = exports.AgoraOffer = void 0;
exports.scriptOps = scriptOps;
const ecash_lib_1 = require("ecash-lib");
const ecash_wallet_1 = require("ecash-wallet");
const broadcast_1 = require("./broadcast");
const inputs_1 = require("./inputs");
const oneshot_js_1 = require("./oneshot.js");
const partial_js_1 = require("./partial.js");
const walletUtxoReconcile_js_1 = require("./walletUtxoReconcile.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 and broadcast an acceptTx, effectively taking 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 });
}
/**
* Build and broadcast a tx that accepts an agora offer
* according to user-provided params, to a user-provided
* ecash-wallet Wallet
*
* NB there is no option to "only build" by passing a Wallet, but this can be
* accomplished using the acceptTx method above
*/
async take(
/**
* NB these params are identical to the params required for acceptTx method above,
* except
*
* 1) We require a Wallet
* 2) We get fuelInputs from the provided wallet instead of accepting them
* as a param
*/
params) {
// Use default params for unspecified
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 wallet = params.wallet;
// Get fuel inputs
const walletUtxos = wallet.spendableSatsOnlyUtxos();
let fuelUtxos;
if (this.variant.type === 'ONESHOT') {
fuelUtxos = (0, inputs_1.getAgoraOneshotAcceptFuelInputs)(this, walletUtxos, params.feePerKb);
}
else if (typeof params.acceptedAtoms === 'bigint') {
fuelUtxos = (0, inputs_1.getAgoraPartialAcceptFuelInputs)(this, walletUtxos, params.acceptedAtoms, params.feePerKb);
}
else {
throw new Error('Must provide acceptedAtoms to accept a partial offer');
}
// Sign inputs using Wallet
// NB the take() method only supports ALL_BIP143 sighash type (for now),
// i.e. "normal" eCash txs and not postage
const finalizedInputs = fuelUtxos.map(utxo => wallet.p2pkhUtxoToBuilderInput(utxo, ecash_lib_1.ALL_BIP143));
// Build the tx using available ecash-agora method
const txBuild = this._acceptTxBuilder({
covenantSk: params.covenantSk,
covenantPk: params.covenantPk,
fuelInputs: finalizedInputs,
extraOutputs: [
{
sats: dustSats,
script: params.wallet.script,
},
params.wallet.script,
],
acceptedAtoms: params.acceptedAtoms,
allowUnspendable,
});
const signedTx = txBuild.sign({ feePerKb, dustSats });
// Broadcast using Wallet, and match the return type of ecash-wallet broadcasts
const builtAction = new ecash_wallet_1.BuiltAction(wallet, [signedTx], feePerKb);
const broadcastResult = await builtAction.broadcast((0, broadcast_1.toBroadcastConfig)(params));
// Keep wallet in-memory utxo set in sync with this successful spend
if (broadcastResult.success &&
typeof broadcastResult.broadcasted[0] !== 'undefined') {
const takerAtoms = this.variant.type === 'ONESHOT'
? this.token.atoms
: params.acceptedAtoms;
if (typeof takerAtoms === 'undefined') {
throw new Error('Must provide acceptedAtoms to accept a partial offer');
}
const takerTokenOutIdx = this.variant.type === 'ONESHOT'
? this.variant.params.enforcedOutputs.length
: this.token.atoms > takerAtoms
? 3
: 2;
(0, walletUtxoReconcile_js_1.reconcileWalletUtxosAfterBroadcasts)(wallet, [signedTx], broadcastResult.broadcasted, [
{
txIndex: 0,
outIdx: takerTokenOutIdx,
tokenId: this.token.tokenId,
atoms: takerAtoms,
tokenType: this.token.tokenType,
},
]);
}
return broadcastResult;
}
/**
* 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 });
}
/**
* Convenience method to cancel an offer using a Wallet
* The cancel tx is broadcast by the Wallet and canceled
* tokens / leftover sats are returned to this wallet
*
* NB this wallet must have the correct cancelSk to
* actually cancel the offer, otherwise expect an error
*
* NB you can see in the oneshot.test.ts that cancelTx can be used
* to "relist" an agora oneshot offer; this method will not do this
*
* Though we could add a relist() method to also simplify that procedure
*/
async cancel(params) {
const dustSats = params.dustSats ?? ecash_lib_1.DEFAULT_DUST_SATS;
const feePerKb = params.feePerKb ?? ecash_lib_1.DEFAULT_FEE_SATS_PER_KB;
// Determine fuel utxos
const walletUtxos = params.wallet.spendableSatsOnlyUtxos();
const fuelUtxos = (0, inputs_1.getAgoraCancelFuelInputs)(this, walletUtxos, params.feePerKb);
// Sign inputs using Wallet
// NB the cancel() method only supports ALL_BIP143 sighash type (for now),
// i.e. "normal" eCash txs and not postage
const fuelInputs = fuelUtxos.map(utxo => params.wallet.p2pkhUtxoToBuilderInput(utxo, ecash_lib_1.ALL_BIP143));
const txBuild = this._cancelTxBuilder({
cancelSk: params.wallet.sk,
fuelInputs,
extraOutputs: [
{
sats: dustSats,
script: params.wallet.script,
},
params.wallet.script,
],
});
const signedTx = txBuild.sign({ feePerKb, dustSats });
// Broadcast using Wallet, and match the return type of ecash-wallet broadcasts
const builtAction = new ecash_wallet_1.BuiltAction(params.wallet, [signedTx], feePerKb);
const broadcastResult = await builtAction.broadcast((0, broadcast_1.toBroadcastConfig)(params));
if (broadcastResult.success &&
typeof broadcastResult.broadcasted[0] !== 'undefined') {
(0, walletUtxoReconcile_js_1.reconcileWalletUtxosAfterBroadcasts)(params.wallet, [signedTx], broadcastResult.broadcasted, [
{
txIndex: 0,
outIdx: 1,
tokenId: this.token.tokenId,
atoms: this.token.atoms,
tokenType: this.token.tokenType,
},
]);
}
return broadcastResult;
}
/**
* Update an AgoraOffer by changing its price or quantity
*
* We use the method name "relist" as this method will cancel the existing AgoraOffer
* and create a new one. What it is doing is closer to "updating" ... but we do
* in fact relist and lose this AgoraOffer
*
* Under the hood, this is canceling an offer and relisting it in a single tx
*
* While this is technically possible to do on SLP, e.g. you can see
* an example in oneshot.test.ts, for now we only support ALP
* as it is easier to implement and anticipated to have more use cases
*/
async relist(params) {
if (this.variant.type !== 'PARTIAL') {
throw new Error('relist() only supports partial offers');
}
if (this.variant.params.tokenProtocol !== 'ALP') {
throw new Error('relist() only supports ALP offers');
}
if (params.updatedPartial.tokenProtocol !== 'ALP') {
throw new Error('The updated offer must be an ALP offer');
}
if (this.variant.params.tokenId !== params.updatedPartial.tokenId) {
throw new Error('The updated offer must be for the same token');
}
if ((0, ecash_lib_1.toHex)(this.variant.params.makerPk) !== (0, ecash_lib_1.toHex)(params.wallet.pk)) {
// If this offer was not created by this wallet
// We cannot cancel it
throw new Error(`Cannot relist this offer with this wallet, offer can only be relisted by pk ${(0, ecash_lib_1.toHex)(this.variant.params.makerPk)}. This wallet pk is ${(0, ecash_lib_1.toHex)(params.wallet.pk)}.`);
}
const dustSats = params.dustSats ?? ecash_lib_1.DEFAULT_DUST_SATS;
const feePerKb = params.feePerKb ?? ecash_lib_1.DEFAULT_FEE_SATS_PER_KB;
const tokenId = this.variant.params.tokenId;
// We manually build this tx using wallet params, then we can broadcast it using the wallet
// Input is canceled offer
const cancelSignatory = (0, partial_js_1.AgoraPartialCancelSignatory)(
// Note we must use the sk that corresponds with cancelPk
params.wallet.sk, this.variant.params.tokenProtocol);
// Required input to cancel the tx
const inputs = [
{
input: this.txBuilderInput,
signatory: cancelSignatory,
},
];
// Build EMPP script for output
// Note we may be adjusting price and quantity based on offerToCreate
// If quantity is being adjusted, we may also need to adjust token inputs
// and we will definitely need to adjust token outputs
const prevAtoms = this.token.atoms;
const newAtoms = params.updatedPartial.offeredAtoms();
const sendAmountAtomsArr = [];
if (prevAtoms === newAtoms) {
// Quantity may match the old offer
sendAmountAtomsArr.push(prevAtoms);
}
else {
sendAmountAtomsArr.push(newAtoms);
if (newAtoms > prevAtoms) {
// Get non-mintbaton utxos for this tokenId
const utxosThisToken = params.wallet.utxos.filter(utxo => utxo.token?.tokenId === this.token.tokenId &&
utxo.token?.isMintBaton === false);
// We already have prevAtoms from the canceled offer as token utxos
let atomsTheseInputs = prevAtoms;
let sufficientTokenInputs = false;
for (const utxo of utxosThisToken) {
atomsTheseInputs += utxo.token.atoms;
inputs.push(params.wallet.p2pkhUtxoToBuilderInput(utxo));
if (atomsTheseInputs >= newAtoms) {
sufficientTokenInputs = true;
break;
}
}
if (!sufficientTokenInputs) {
// We can't afford to list this quantity
throw new Error(`Insufficient token utxos to re-list ${newAtoms} atoms of ${tokenId}. ${atomsTheseInputs} atoms available from previous offer and this wallet.`);
}
// Determine change
const change = atomsTheseInputs - newAtoms;
// We always have change in this block
sendAmountAtomsArr.push(change);
}
else {
// If newAtoms < prevAtoms, we can calculate change right here, no new inputs
const change = prevAtoms - newAtoms;
// We always have change in this block
sendAmountAtomsArr.push(change);
}
}
const agoraScript = params.updatedPartial.script();
const agoraP2sh = ecash_lib_1.Script.p2sh((0, ecash_lib_1.shaRmd160)(agoraScript.bytecode));
// Output is same as a listing (listing + alpSend EMPP)
const outputs = [
// EMPP script for agora offer and ALP send at index 0
{
sats: 0n,
script: (0, ecash_lib_1.emppScript)([
params.updatedPartial.adPushdata(),
(0, ecash_lib_1.alpSend)(tokenId, params.updatedPartial.tokenType, sendAmountAtomsArr),
]),
},
// Agora offer at index 1
{ sats: dustSats, script: agoraP2sh },
];
// Token change if we have it
if (sendAmountAtomsArr.length > 1) {
outputs.push({ sats: dustSats, script: params.wallet.script });
}
// XEC change for the relisting wallet
outputs.push(params.wallet.script);
const relistTokenOuts = sendAmountAtomsArr.length > 1
? [
{
txIndex: 0,
outIdx: 2,
tokenId,
atoms: sendAmountAtomsArr[sendAmountAtomsArr.length - 1],
tokenType: ecash_lib_1.ALP_TOKEN_TYPE_STANDARD,
},
]
: [];
const availableFuelUtxos = params.wallet.spendableSatsOnlyUtxos();
// Assume we cannot cover the tx with dust cancel utxo and immediately add fuel
let inputSats = inputs.reduce((acc, input) => acc + input.input.signData.sats, 0n);
const outputSats = outputs.reduce((acc, output) => {
if ('sats' in output) {
return acc + output.sats;
}
else {
return acc;
}
}, 0n);
for (const utxo of availableFuelUtxos) {
inputs.push(params.wallet.p2pkhUtxoToBuilderInput(utxo));
inputSats += utxo.sats;
try {
const alpRelistTxBuilder = new ecash_lib_1.TxBuilder({
inputs: inputs,
outputs,
});
const alpRelistTx = alpRelistTxBuilder.sign({
feePerKb,
dustSats,
});
const builtAction = new ecash_wallet_1.BuiltAction(params.wallet, [alpRelistTx], feePerKb);
const broadcastResult = await builtAction.broadcast((0, broadcast_1.toBroadcastConfig)(params));
if (broadcastResult.success &&
typeof broadcastResult.broadcasted[0] !== 'undefined') {
(0, walletUtxoReconcile_js_1.reconcileWalletUtxosAfterBroadcasts)(params.wallet, [alpRelistTx], broadcastResult.broadcasted, relistTokenOuts);
}
return broadcastResult;
}
catch {
// We need another fuel utxo
}
}
// We do not have enough fuel utxos
throw new Error(`Insufficient fuel: cannot cover outputs (${outputSats}) + fee with available fuel UTXOs (${inputSats} total sats)`);
}
/**
* 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);
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, scriptIntegerBits = 64n) {
// 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,
}, scriptIntegerBits);
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;
}
//# sourceMappingURL=agora.js.map