ecash-agora
Version:
Library for interacting with the eCash Agora protocol
352 lines • 16.8 kB
JavaScript
;
// 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.AgoraOneshotAdSignatory = exports.AgoraOneshotCancelSignatory = exports.AgoraOneshotSignatory = exports.AgoraOneshot = void 0;
const ecash_lib_1 = require("ecash-lib");
const ecash_wallet_1 = require("ecash-wallet");
const broadcast_js_1 = require("./broadcast.js");
const consts_js_1 = require("./consts.js");
const walletUtxoReconcile_js_1 = require("./walletUtxoReconcile.js");
const inputs_js_1 = require("./inputs.js");
/**
* Agora offer that has to be accepted in "one shot", i.e. all or nothing.
* This is useful for offers that offer exactly 1 token, especially NFTs.
*
* The covenant is reasonably simple, see
* https://read.cash/@pein/bch-covenants-with-spedn-4a980ed3 for an explanation of the
* covenant mechanism, but uses two optimizations:
* 1. It uses ANYONECANPAY as sighash for the "accept" path, which makes the sighash
* preimage start with `1000....00000`, which can be created with
* `OP_1 68 OP_NUM2BIN`, saving around 64 bytes.
* 2. It uses OP_CODESEPARATOR before the OP_CHECKSIG, which cuts out the entire script
* code, leaving only the OP_CHECKSIG behind. The scriptCode part in the BIP143
* sighash now just becomes `01ac`, which is both easier to deal with in the OP_SPLIT
* and also saves 100 bytes or so (depending on the enforced outputs).
**/
class AgoraOneshot {
constructor({ enforcedOutputs, cancelPk, }) {
this.enforcedOutputs = enforcedOutputs;
this.cancelPk = cancelPk;
}
/** Build the Script enforcing the Agora offer covenant. */
script() {
const serEnforcedOutputs = (writer) => {
for (const output of this.enforcedOutputs) {
(0, ecash_lib_1.writeTxOutput)(output, writer);
}
};
const writerLength = new ecash_lib_1.WriterLength();
serEnforcedOutputs(writerLength);
const writer = new ecash_lib_1.WriterBytes(writerLength.length);
serEnforcedOutputs(writer);
const enforcedOutputsSer = writer.data;
return ecash_lib_1.Script.fromOps([
ecash_lib_1.OP_IF, // if is_accept
(0, ecash_lib_1.pushBytesOp)(enforcedOutputsSer), // push enforced_outputs
ecash_lib_1.OP_SWAP, // swap buyer_outputs, enforced_outputs
ecash_lib_1.OP_CAT, // outputs = OP_CAT(enforced_outputs, buyer_outputs)
ecash_lib_1.OP_HASH256, // expected_hash_outputs = OP_HASH256(outputs)
ecash_lib_1.OP_OVER, // duplicate preimage_4_10,
// push hash_outputs_idx:
(0, ecash_lib_1.pushBytesOp)(new Uint8Array([
36 + // 4. outpoint
2 + // 5. scriptCode, truncated to 01ac via OP_CODESEPARATOR
8 + // 6. value
4, // 7. sequence
])),
ecash_lib_1.OP_SPLIT, // split into preimage_4_7 and preimage_8_10
ecash_lib_1.OP_NIP, // remove preimage_4_7
(0, ecash_lib_1.pushBytesOp)(new Uint8Array([32])), // push 32 onto the stack
ecash_lib_1.OP_SPLIT, // split into actual_hash_outputs and preimage_9_10
ecash_lib_1.OP_DROP, // drop preimage_9_10
ecash_lib_1.OP_EQUALVERIFY, // expected_hash_outputs === actual_hash_outputs
ecash_lib_1.OP_2, // push tx version
// length of BIP143 preimage parts 1 to 3
(0, ecash_lib_1.pushBytesOp)(new Uint8Array([4 + 32 + 32])),
// build BIP143 preimage parts 1 to 3 for ANYONECANPAY using OP_NUM2BIN
ecash_lib_1.OP_NUM2BIN,
ecash_lib_1.OP_SWAP, // swap preimage_4_10 and preimage_1_3
ecash_lib_1.OP_CAT, // preimage = OP_CAT(preimage_1_3, preimage_4_10)
ecash_lib_1.OP_SHA256, // preimage_sha256 = OP_SHA256(preimage)
ecash_lib_1.OP_3DUP, // OP_3DUP(covenant_pk, covenant_sig, preimage_sha256)
ecash_lib_1.OP_ROT, // -> covenant_sig | preimage_sha256 | covenant_pk
ecash_lib_1.OP_CHECKDATASIGVERIFY, // verify preimage matches covenant_sig
ecash_lib_1.OP_DROP, // drop preimage_sha256
// push ALL|ANYONECANPAY|BIP143 onto the stack
(0, ecash_lib_1.pushBytesOp)(new Uint8Array([ecash_lib_1.ALL_ANYONECANPAY_BIP143.toInt()])),
ecash_lib_1.OP_CAT, // append sighash flags onto covenant_sig
ecash_lib_1.OP_SWAP, // swap covenant_pk, covenant_sig_flagged
ecash_lib_1.OP_ELSE, // cancel path
(0, ecash_lib_1.pushBytesOp)(this.cancelPk), // pubkey that can cancel the covenant
ecash_lib_1.OP_ENDIF,
// cut out everything except the OP_CHECKSIG from the BIP143 scriptCode
ecash_lib_1.OP_CODESEPARATOR,
ecash_lib_1.OP_CHECKSIG,
]);
}
static fromRedeemScript(redeemScript, opreturnScript) {
const ops = redeemScript.ops();
const outputsSerOp = ops.next();
if (!(0, ecash_lib_1.isPushOp)(outputsSerOp)) {
throw new Error('Op 0 expected to be pushop for outputsSer');
}
if (ops.next() !== ecash_lib_1.OP_DROP) {
throw new Error('Op 1 expected to be OP_DROP');
}
const cancelPkOp = ops.next();
if (!(0, ecash_lib_1.isPushOp)(cancelPkOp)) {
throw new Error('Op 2 expected to be pushop for cancelPk');
}
if (cancelPkOp.data.length != 33) {
throw new Error(`Expected cancelPk to be 33 bytes`);
}
if (ops.next() !== ecash_lib_1.OP_CHECKSIGVERIFY) {
throw new Error('Op 3 expected to be OP_CHECKSIGVERIFY');
}
const covenantVariantOp = ops.next();
if (!(0, ecash_lib_1.isPushOp)(covenantVariantOp)) {
throw new Error('Op 4 expected to be pushop for covenantVariant');
}
if (ops.next() !== ecash_lib_1.OP_EQUALVERIFY) {
throw new Error('Op 5 expected to be OP_EQUALVERIFY');
}
const lokadIdOp = ops.next();
if (!(0, ecash_lib_1.isPushOp)(lokadIdOp)) {
throw new Error('Op 6 expected to be pushop for LOKAD ID');
}
const outputsSerBytes = new ecash_lib_1.Bytes(outputsSerOp.data);
const enforcedOutputs = [
{
sats: 0n,
script: opreturnScript,
},
];
while (outputsSerBytes.data.length > outputsSerBytes.idx) {
enforcedOutputs.push((0, ecash_lib_1.readTxOutput)(outputsSerBytes));
}
return new AgoraOneshot({
enforcedOutputs,
cancelPk: cancelPkOp.data,
});
}
adScript() {
const serOutputs = (writer) => {
for (const output of this.enforcedOutputs.slice(1)) {
(0, ecash_lib_1.writeTxOutput)(output, writer);
}
};
const writerLength = new ecash_lib_1.WriterLength();
serOutputs(writerLength);
const writer = new ecash_lib_1.WriterBytes(writerLength.length);
serOutputs(writer);
const outputsSer = writer.data;
return ecash_lib_1.Script.fromOps([
(0, ecash_lib_1.pushBytesOp)(outputsSer),
ecash_lib_1.OP_DROP,
(0, ecash_lib_1.pushBytesOp)(this.cancelPk),
ecash_lib_1.OP_CHECKSIGVERIFY,
(0, ecash_lib_1.pushBytesOp)((0, ecash_lib_1.strToBytes)(AgoraOneshot.COVENANT_VARIANT)),
ecash_lib_1.OP_EQUALVERIFY,
(0, ecash_lib_1.pushBytesOp)(consts_js_1.AGORA_LOKAD_ID),
ecash_lib_1.OP_EQUAL,
]);
}
askedSats() {
return this.enforcedOutputs.reduce((prev, output) => prev + output.sats, 0n);
}
/**
* Build and broadcast a chained transaction to list an SLP NFT token.
* This creates an "ad prep" transaction followed by the actual offer transaction.
*
* @param params - Parameters for listing the NFT
* @returns Promise resolving to broadcast result
* @throws Error if token type is not SLP NFT
*/
async list(params) {
// Validate token type - only SLP NFT is supported
if (params.tokenType.type !== 'SLP_TOKEN_TYPE_NFT1_CHILD') {
throw new Error('AgoraOneshot.list() only supports SLP NFT tokens (SLP_TOKEN_TYPE_NFT1_CHILD)');
}
const dustSats = params.dustSats ?? ecash_lib_1.DEFAULT_DUST_SATS;
const feePerKb = params.feePerKb ?? ecash_lib_1.DEFAULT_FEE_SATS_PER_KB;
// Build the ad script and P2SH address
const agoraAdScript = this.adScript();
const agoraAdP2sh = ecash_lib_1.Script.p2sh((0, ecash_lib_1.shaRmd160)(agoraAdScript.bytecode));
// Determine the offer tx parameters before building txs, so we can
// accurately calculate its fee
const agoraScript = this.script();
const agoraP2sh = ecash_lib_1.Script.p2sh((0, ecash_lib_1.shaRmd160)(agoraScript.bytecode));
const offerTargetOutputs = [
{
sats: 0n,
script: (0, ecash_lib_1.slpSend)(params.tokenId, ecash_lib_1.SLP_NFT1_CHILD, [1n]),
},
{ sats: dustSats, script: agoraP2sh },
];
const offerTxFuelSats = (0, inputs_js_1.getAgoraAdFuelSats)(agoraAdScript, (0, exports.AgoraOneshotAdSignatory)(params.wallet.sk), offerTargetOutputs, feePerKb);
// The ad prep tx must include an output with fuel that covers this fee
// This will be dust + fee
const adFuelOutputSats = dustSats + offerTxFuelSats;
// Build the ad setup tx using ecash-wallet (without broadcasting)
let adSetupTx;
let adSetupTxid;
try {
// Build payment.Action for ad setup transaction
// This sends the NFT to the P2SH address with fuel for the offer tx
// Output 0: OP_RETURN (ecash-wallet will build the script from tokenActions)
// Output 1: P2SH output with NFT (for the offer tx)
// ecash-wallet will automatically select the NFT UTXO based on the token send action
const adSetupOutputs = [
{ sats: 0n }, // OP_RETURN - ecash-wallet will build the script
{
sats: adFuelOutputSats,
script: agoraAdP2sh,
tokenId: params.tokenId,
atoms: 1n, // NFT quantity is always 1
},
];
const adSetupAction = {
outputs: adSetupOutputs,
tokenActions: [
{
type: 'SEND',
tokenId: params.tokenId,
tokenType: params.tokenType,
},
],
feePerKb,
};
// Build without broadcasting to get the txid
const builtAdSetupAction = params.wallet
.action(adSetupAction)
.build();
adSetupTx = builtAdSetupAction.txs[0];
adSetupTxid = builtAdSetupAction.builtTxs[0].txid;
}
catch (err) {
console.error(`Error building NFT listing ad tx`, err);
return {
success: false,
broadcasted: [],
unbroadcasted: [],
errors: [`Error building NFT listing ad tx: ${err}`],
};
}
// Build the offer transaction
// This uses a P2SH input with custom signatory, so we build it manually with TxBuilder
let offerTx;
try {
const offerInputs = [
{
input: {
prevOut: {
// Use the txid from the built (but not yet broadcast) ad tx
txid: adSetupTxid,
outIdx: 1,
},
signData: {
sats: adFuelOutputSats,
redeemScript: agoraAdScript,
},
},
signatory: (0, exports.AgoraOneshotAdSignatory)(params.wallet.sk),
},
];
// Build the offer transaction using TxBuilder
const offerTxBuilder = new ecash_lib_1.TxBuilder({
inputs: offerInputs,
outputs: offerTargetOutputs,
});
offerTx = offerTxBuilder.sign({
feePerKb,
dustSats,
});
}
catch (err) {
console.error(`Error building NFT listing offer tx`, err);
return {
success: false,
broadcasted: [],
unbroadcasted: [],
errors: [`Error building NFT listing offer tx: ${err}`],
};
}
// Broadcast both transactions together
try {
const builtAction = new ecash_wallet_1.BuiltAction(params.wallet, [adSetupTx, offerTx], feePerKb);
const broadcastResult = await builtAction.broadcast((0, broadcast_js_1.toBroadcastConfig)(params));
if (broadcastResult.success &&
broadcastResult.broadcasted.length >= 2) {
(0, walletUtxoReconcile_js_1.reconcileWalletUtxosAfterBroadcasts)(params.wallet, [adSetupTx, offerTx], broadcastResult.broadcasted, [], { skipAddOutputsForTxIndices: new Set([0]) });
}
return broadcastResult;
}
catch (err) {
console.error(`Error broadcasting NFT listing txs`, err);
return {
success: false,
broadcasted: [],
unbroadcasted: [],
errors: [`Error broadcasting NFT listing txs: ${err}`],
};
}
}
}
exports.AgoraOneshot = AgoraOneshot;
AgoraOneshot.COVENANT_VARIANT = 'ONESHOT';
const AgoraOneshotSignatory = (covenantSk, covenantPk, numEnforcedOutputs) => {
return (ecc, input) => {
const preimage = input.sigHashPreimage(ecash_lib_1.ALL_ANYONECANPAY_BIP143, 0);
const sighash = (0, ecash_lib_1.sha256d)(preimage.bytes);
const covenantSig = ecc.schnorrSign(covenantSk, sighash);
const serBuyerOutputs = (writer) => {
for (const output of input.unsignedTx.tx.outputs.slice(numEnforcedOutputs)) {
(0, ecash_lib_1.writeTxOutput)(output, writer);
}
};
const writerLength = new ecash_lib_1.WriterLength();
serBuyerOutputs(writerLength);
const writer = new ecash_lib_1.WriterBytes(writerLength.length);
serBuyerOutputs(writer);
const buyerOutputsSer = writer.data;
return ecash_lib_1.Script.fromOps([
(0, ecash_lib_1.pushBytesOp)(covenantPk),
(0, ecash_lib_1.pushBytesOp)(covenantSig),
(0, ecash_lib_1.pushBytesOp)(preimage.bytes.slice(4 + 32 + 32)), // preimage_4_10
(0, ecash_lib_1.pushBytesOp)(buyerOutputsSer),
ecash_lib_1.OP_1, // is_accept = true
(0, ecash_lib_1.pushBytesOp)(preimage.redeemScript.bytecode),
]);
};
};
exports.AgoraOneshotSignatory = AgoraOneshotSignatory;
const AgoraOneshotCancelSignatory = (cancelSk) => {
return (ecc, input) => {
const preimage = input.sigHashPreimage(ecash_lib_1.ALL_BIP143, 0);
const sighash = (0, ecash_lib_1.sha256d)(preimage.bytes);
const cancelSig = (0, ecash_lib_1.flagSignature)(ecc.schnorrSign(cancelSk, sighash), ecash_lib_1.ALL_BIP143);
return ecash_lib_1.Script.fromOps([
(0, ecash_lib_1.pushBytesOp)(cancelSig),
ecash_lib_1.OP_0, // is_accept = false
(0, ecash_lib_1.pushBytesOp)(preimage.redeemScript.bytecode),
]);
};
};
exports.AgoraOneshotCancelSignatory = AgoraOneshotCancelSignatory;
const AgoraOneshotAdSignatory = (cancelSk) => {
return (ecc, input) => {
const preimage = input.sigHashPreimage(ecash_lib_1.ALL_BIP143);
const sighash = (0, ecash_lib_1.sha256d)(preimage.bytes);
const cancelSig = (0, ecash_lib_1.flagSignature)(ecc.schnorrSign(cancelSk, sighash), ecash_lib_1.ALL_BIP143);
return ecash_lib_1.Script.fromOps([
(0, ecash_lib_1.pushBytesOp)(consts_js_1.AGORA_LOKAD_ID),
(0, ecash_lib_1.pushBytesOp)((0, ecash_lib_1.strToBytes)(AgoraOneshot.COVENANT_VARIANT)),
(0, ecash_lib_1.pushBytesOp)(cancelSig),
(0, ecash_lib_1.pushBytesOp)(preimage.redeemScript.bytecode),
]);
};
};
exports.AgoraOneshotAdSignatory = AgoraOneshotAdSignatory;
//# sourceMappingURL=oneshot.js.map