UNPKG

ecash-agora

Version:

Library for interacting with the eCash Agora protocol

352 lines 16.8 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.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