UNPKG

ecash-agora

Version:

Library for interacting with the eCash Agora protocol

1,435 lines (1,326 loc) 52 kB
// 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. import { assert, expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import { ChronikClient, MsgTxClient } from 'chronik-client'; import { ALL_BIP143, Ecc, OutPoint, P2PKHSignatory, SLP_NFT1_CHILD, SLP_NFT1_GROUP, SLP_TOKEN_TYPE_NFT1_CHILD, SLP_TOKEN_TYPE_NFT1_GROUP, Script, TxBuilder, TxInput, TxOutput, fromHex, payment, shaRmd160, slpGenesis, slpSend, toHex, } from 'ecash-lib'; import { TestRunner } from 'ecash-lib/dist/test/testRunner.js'; import { parseAgoraTx } from '../src/ad.js'; import { AGORA_LOKAD_ID } from '../src/consts.js'; import { AgoraOneshot, AgoraOneshotAdSignatory, AgoraOneshotSignatory, } from '../src/oneshot.js'; import { Agora, AgoraOffer } from '../src/agora.js'; import { EventEmitter, once } from 'node:events'; import { Wallet } from 'ecash-wallet/src/wallet.js'; use(chaiAsPromised); const NUM_COINS = 500; const COIN_VALUE = 100000n; describe('SLP', () => { let runner: TestRunner; let chronik: ChronikClient; const ecc = new Ecc(); before(async () => { runner = await TestRunner.setup('setup_scripts/ecash-agora_base'); chronik = runner.chronik; await runner.setupCoins(NUM_COINS, COIN_VALUE); }); after(() => { runner.stop(); }); it('SLP NFT1 Agora Oneshot', async () => { // Tests the Agora Oneshot Script using SLP NFT1 tokens: // 1. Seller creates an NFT1 GROUP token // 2. Seller creates an NFT1 CHILD token using the group token // 3. Seller sends the NFT to an ad setup output for an Agora Oneshot // covenant that asks for 80000 sats // 4. Seller finishes offer setup + sends NFT to the advertised P2SH // 5. Buyer searches for NFT trades, finds the advertised one // 6. Buyer attempts to buy the NFT using 79999 sats, which is rejected // 7. Seller cancels the trade and changes the price to 70000 sats, // with a new advertisement // 8. Buyer searches for NFT trades again, finding both, one spent // 9. Buyer successfully accepts advertized NFT offer for 70000 sats // Create Agora object to access trades const agora = new Agora(chronik); const sellerSk = fromHex('11'.repeat(32)); const sellerPk = ecc.derivePubkey(sellerSk); const sellerPkh = shaRmd160(sellerPk); const sellerP2pkh = Script.p2pkh(sellerPkh); const buyerSk = fromHex('22'.repeat(32)); const buyerPk = ecc.derivePubkey(buyerSk); const buyerPkh = shaRmd160(buyerPk); const buyerP2pkh = Script.p2pkh(buyerPkh); await runner.sendToScript(50000n, sellerP2pkh); const utxos = await chronik.script('p2pkh', toHex(sellerPkh)).utxos(); expect(utxos.utxos.length).to.equal(1); const utxo = utxos.utxos[0]; // 1. Seller creates an NFT1 GROUP token const txBuildGenesisGroup = new TxBuilder({ inputs: [ { input: { prevOut: utxo.outpoint, signData: { sats: utxo.sats, outputScript: sellerP2pkh, }, }, signatory: P2PKHSignatory(sellerSk, sellerPk, ALL_BIP143), }, ], outputs: [ { sats: 0n, script: slpGenesis( SLP_NFT1_GROUP, { tokenTicker: 'SLP NFT1 GROUP TOKEN', decimals: 4, }, 1n, ), }, { sats: 10000n, script: sellerP2pkh }, ], }); const genesisTx = txBuildGenesisGroup.sign(); const genesisTxid = (await chronik.broadcastTx(genesisTx.ser())).txid; const groupTokenId = genesisTxid; expect(await chronik.token(genesisTxid)).to.deep.equal({ tokenId: groupTokenId, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, genesisInfo: { tokenTicker: 'SLP NFT1 GROUP TOKEN', tokenName: '', url: '', hash: '', decimals: 4, }, timeFirstSeen: 1300000000, }); // 2. Seller creates an NFT1 CHILD token using the group token const txBuildGenesisChild = new TxBuilder({ inputs: [ { input: { prevOut: { txid: genesisTxid, outIdx: 1, }, signData: { sats: 10000n, outputScript: sellerP2pkh, }, }, signatory: P2PKHSignatory(sellerSk, sellerPk, ALL_BIP143), }, ], outputs: [ { sats: 0n, script: slpGenesis( SLP_NFT1_CHILD, { tokenTicker: 'SLP NFT1 CHILD TOKEN', decimals: 0, }, 1n, ), }, { sats: 8000n, script: sellerP2pkh }, ], }); const genesisChildTx = txBuildGenesisChild.sign(); const genesisChildTxid = ( await chronik.broadcastTx(genesisChildTx.ser()) ).txid; const childTokenId = genesisChildTxid; expect(await chronik.token(childTokenId)).to.deep.equal({ tokenId: childTokenId, tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, genesisInfo: { tokenTicker: 'SLP NFT1 CHILD TOKEN', tokenName: '', url: '', hash: '', decimals: 0, }, timeFirstSeen: 1300000000, }); const emitter = new EventEmitter(); const ws = chronik.ws({ onMessage: async msg => { if (!emitter.emit('ws', msg)) { console.warn('Emitted msg without any listeners', msg); } }, }); await ws.waitForOpen(); agora.subscribeWs(ws, { type: 'TOKEN_ID', tokenId: childTokenId, }); const listenNext = () => once(emitter, 'ws') as Promise<[MsgTxClient]>; // 3. Seller sends the NFT to an ad setup output for an Agora Oneshot // covenant that asks for 80000 sats const enforcedOutputs: TxOutput[] = [ { sats: BigInt(0), script: slpSend(childTokenId, SLP_NFT1_CHILD, [0n, 1n]), }, { sats: BigInt(80000), script: sellerP2pkh }, ]; const agoraOneshot = new AgoraOneshot({ enforcedOutputs, cancelPk: sellerPk, }); const agoraAdScript = agoraOneshot.adScript(); const agoraAdP2sh = Script.p2sh(shaRmd160(agoraAdScript.bytecode)); const txBuildAdSetup = new TxBuilder({ inputs: [ { input: { prevOut: { txid: genesisChildTxid, outIdx: 1, }, signData: { sats: 8000n, outputScript: sellerP2pkh, }, }, signatory: P2PKHSignatory(sellerSk, sellerPk, ALL_BIP143), }, ], outputs: [ { sats: 0n, script: slpSend(childTokenId, SLP_NFT1_CHILD, [1n]), }, { sats: 7000n, script: agoraAdP2sh }, ], }); const adSetupTx = txBuildAdSetup.sign(); const adSetupTxid = (await chronik.broadcastTx(adSetupTx.ser())).txid; // 4. Seller finishes offer setup + sends NFT to the advertised P2SH const agoraScript = agoraOneshot.script(); const agoraP2sh = Script.p2sh(shaRmd160(agoraScript.bytecode)); const txBuildOffer = new TxBuilder({ inputs: [ { input: { prevOut: { txid: adSetupTxid, outIdx: 1, }, signData: { sats: 7000n, redeemScript: agoraAdScript, }, }, signatory: AgoraOneshotAdSignatory(sellerSk), }, ], outputs: [ { sats: 0n, script: slpSend(childTokenId, SLP_NFT1_CHILD, [1n]), }, { sats: 546n, script: agoraP2sh }, ], }); const offerTx = txBuildOffer.sign(); const offerPromise = listenNext(); const offerTxid = (await chronik.broadcastTx(offerTx.ser())).txid; const offerOutpoint: OutPoint = { txid: offerTxid, outIdx: 1, }; const offerTxBuilderInput: TxInput = { prevOut: offerOutpoint, signData: { redeemScript: agoraScript, sats: 546n, }, }; // Expected created offer const expectedOffer = new AgoraOffer({ variant: { type: 'ONESHOT', params: agoraOneshot, }, outpoint: offerOutpoint, txBuilderInput: offerTxBuilderInput, token: { tokenId: childTokenId, tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, atoms: 1n, isMintBaton: false, }, status: 'OPEN', }); const [offerMsg] = await offerPromise; expect(offerMsg.type).to.equal('Tx'); expect(offerMsg.msgType).to.equal('TX_ADDED_TO_MEMPOOL'); expect(offerMsg.txid).to.equal(offerTxid); // 5. Buyer searches for NFT trades, finds the advertised one expect(await agora.allOfferedTokenIds()).to.deep.equal([childTokenId]); expect(await agora.offeredGroupTokenIds()).to.deep.equal([ groupTokenId, ]); expect(await agora.offeredFungibleTokenIds()).to.deep.equal([]); // Query by group token ID expect( await agora.activeOffersByGroupTokenId(groupTokenId), ).to.deep.equal([expectedOffer]); // activeOffersByGroupTokenId with child token ID -> empty result expect( await agora.activeOffersByGroupTokenId(childTokenId), ).to.deep.equal([]); // Query by child token ID expect(await agora.activeOffersByTokenId(childTokenId)).to.deep.equal([ expectedOffer, ]); // activeOffersByTokenId with by group token ID -> empty result expect(await agora.activeOffersByTokenId(groupTokenId)).to.deep.equal( [], ); // Query by cancelPk expect(await agora.activeOffersByPubKey(toHex(sellerPk))).to.deep.equal( [expectedOffer], ); // Use LOKAD ID endpoint + parseAgoraTx to find offers const agoraTxs = ( await chronik.lokadId(toHex(AGORA_LOKAD_ID)).history() ).txs; expect(agoraTxs.length).to.be.equal(1); const agoraTx = agoraTxs[0]; expect(agoraTx.inputs.length).to.be.equal(1); const parsedAd = parseAgoraTx(agoraTx); if (parsedAd === undefined) { throw 'Parsing agora tx failed'; } if (parsedAd.type !== 'ONESHOT') { throw 'Expected ONESHOT offer in this test'; } expect(parsedAd).to.be.deep.equal({ type: 'ONESHOT', params: agoraOneshot, outpoint: offerOutpoint, spentBy: undefined, txBuilderInput: offerTxBuilderInput, }); // 6. Buyer attempts to buy the NFT using 79999 sats, which is rejected const buyerSatsTxid = await runner.sendToScript(90000n, buyerP2pkh); const txBuildAcceptFail = new TxBuilder({ version: 2, inputs: [ { input: parsedAd.txBuilderInput, signatory: AgoraOneshotSignatory( buyerSk, buyerPk, parsedAd.params.enforcedOutputs.length, ), }, { input: { prevOut: { txid: buyerSatsTxid, outIdx: 0, }, signData: { sats: 90000n, outputScript: buyerP2pkh, }, }, signatory: P2PKHSignatory(buyerSk, buyerPk, ALL_BIP143), }, ], outputs: [ { sats: 0n, script: slpSend(childTokenId, SLP_NFT1_CHILD, [0n, 1n]), }, // failure: one sat missing { sats: 79999n, script: sellerP2pkh }, { sats: 546n, script: buyerP2pkh }, ], }); // Accepting trade failed, must send 80000 sats to seller, but sent 79999 const acceptFailTx = txBuildAcceptFail.sign(); // OP_EQUALVERIFY failed assert.isRejected(chronik.broadcastTx(acceptFailTx.ser())); // 7. Seller cancels the trade and changes the price to 70000 sats, // with a new advertisement const newEnforcedOutputs: TxOutput[] = [ { sats: BigInt(0), script: slpSend(childTokenId, SLP_NFT1_CHILD, [0n, 1n]), }, { sats: BigInt(70000), script: sellerP2pkh }, ]; const newAgoraOneshot = new AgoraOneshot({ enforcedOutputs: newEnforcedOutputs, cancelPk: sellerPk, }); const newAgoraScript = newAgoraOneshot.script(); const newAgoraP2sh = Script.p2sh(shaRmd160(newAgoraScript.bytecode)); const newAgoraAdScript = newAgoraOneshot.adScript(); const cancelFeeSats = 600n; const newAdSetupTxid = await runner.sendToScript( cancelFeeSats, Script.p2sh(shaRmd160(newAgoraAdScript.bytecode)), ); const newOfferPromise = listenNext(); const offer1 = (await agora.activeOffersByTokenId(childTokenId))[0]; const offer1AdInput = { input: { prevOut: { txid: newAdSetupTxid, outIdx: 0, }, signData: { sats: cancelFeeSats, redeemScript: newAgoraAdScript, }, }, signatory: AgoraOneshotAdSignatory(sellerSk), }; expect(offer1.askedSats()).to.equal(80000n); expect( offer1.cancelFeeSats({ recipientScript: newAgoraP2sh, extraInputs: [offer1AdInput], }), ).to.equal(cancelFeeSats); const cancelTx = offer1.cancelTx({ cancelSk: sellerSk, fuelInputs: [offer1AdInput], recipientScript: newAgoraP2sh, }); expect(cancelTx.serSize()).to.equal(Number(cancelFeeSats)); const newOfferTxid = (await chronik.broadcastTx(cancelTx.ser())).txid; const newOfferOutpoint: OutPoint = { txid: newOfferTxid, outIdx: 1, }; const newOfferTxBuilderInput: TxInput = { prevOut: newOfferOutpoint, signData: { redeemScript: newAgoraScript, sats: 546n, }, }; const [newOfferMsg] = await newOfferPromise; expect(newOfferMsg.type).to.equal('Tx'); expect(newOfferMsg.msgType).to.equal('TX_ADDED_TO_MEMPOOL'); expect(newOfferMsg.txid).to.equal(newOfferTxid); // 8. Buyer searches for NFT trades again, finding both, one spent expect(await agora.allOfferedTokenIds()).to.deep.equal([childTokenId]); expect(await agora.offeredGroupTokenIds()).to.deep.equal([ groupTokenId, ]); expect(await agora.offeredFungibleTokenIds()).to.deep.equal([]); // Get history expect( await agora.historicOffers({ type: 'TOKEN_ID', tokenId: childTokenId, table: 'HISTORY', }), ).to.deep.equal({ offers: [ { ...expectedOffer, status: 'CANCELED', }, ], numTxs: 2, numPages: 1, }); expect( await agora.historicOffers({ type: 'GROUP_TOKEN_ID', groupTokenId, table: 'HISTORY', }), ).to.deep.equal({ offers: [ { ...expectedOffer, status: 'CANCELED', }, ], numTxs: 2, numPages: 1, }); expect( await agora.historicOffers({ type: 'PUBKEY', pubkeyHex: toHex(sellerPk), table: 'HISTORY', }), ).to.deep.equal({ offers: [ { ...expectedOffer, status: 'CANCELED', }, ], numTxs: 2, numPages: 1, }); expect( await agora.historicOffers({ type: 'TOKEN_ID', tokenId: childTokenId, table: 'UNCONFIRMED', }), ).to.deep.equal({ offers: [ { ...expectedOffer, status: 'CANCELED', }, ], numTxs: 2, numPages: 1, }); expect( await agora.historicOffers({ type: 'TOKEN_ID', tokenId: childTokenId, table: 'CONFIRMED', }), ).to.deep.equal({ offers: [], numTxs: 0, numPages: 0, }); const newExpectedOffer = new AgoraOffer({ variant: { type: 'ONESHOT', params: newAgoraOneshot, }, outpoint: newOfferOutpoint, txBuilderInput: newOfferTxBuilderInput, token: { tokenId: childTokenId, tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, atoms: 1n, isMintBaton: false, }, status: 'OPEN', }); expect( await agora.activeOffersByGroupTokenId(groupTokenId), ).to.deep.equal([newExpectedOffer]); expect(await agora.activeOffersByTokenId(childTokenId)).to.deep.equal([ newExpectedOffer, ]); expect(await agora.activeOffersByPubKey(toHex(sellerPk))).to.deep.equal( [newExpectedOffer], ); // Use LOKAD ID index + parseAgoraTx to find the offers const newAgoraTxs = ( await chronik.lokadId(toHex(AGORA_LOKAD_ID)).history() ).txs; expect(newAgoraTxs.length).to.equal(2); newAgoraTxs.sort( (a, b) => +!a.outputs[1].spentBy - +!b.outputs[1].spentBy, ); const parsedAds = newAgoraTxs.map(parseAgoraTx); expect(parsedAds).to.deep.equal([ { type: 'ONESHOT', params: agoraOneshot, outpoint: offerOutpoint, txBuilderInput: offerTxBuilderInput, spentBy: { txid: newOfferTxid, outIdx: 1, }, }, { type: 'ONESHOT', params: newAgoraOneshot, outpoint: newOfferOutpoint, txBuilderInput: newOfferTxBuilderInput, spentBy: undefined, }, ]); // 9. Buyer successfully accepts advertized NFT offer for 70000 sats const offer2 = (await agora.activeOffersByTokenId(childTokenId))[0]; expect(offer2.askedSats()).to.equal(70000n); const acceptFeeSats = 740n; const acceptSats = acceptFeeSats + offer2.askedSats(); const acceptSatsTxid = await runner.sendToScript( acceptSats, buyerP2pkh, ); const offer2AcceptInput = { input: { prevOut: { txid: acceptSatsTxid, outIdx: 0, }, signData: { sats: acceptSats, outputScript: buyerP2pkh, }, }, signatory: P2PKHSignatory(buyerSk, buyerPk, ALL_BIP143), }; expect( offer2.acceptFeeSats({ recipientScript: buyerP2pkh, extraInputs: [offer2AcceptInput], }), ).to.equal(acceptFeeSats); const acceptSuccessTx = offer2.acceptTx({ covenantSk: buyerSk, covenantPk: buyerPk, fuelInputs: [offer2AcceptInput], recipientScript: buyerP2pkh, }); expect(acceptSuccessTx.serSize()).to.equal(Number(acceptFeeSats)); const acceptPromise = listenNext(); const acceptSuccessTxid = ( await chronik.broadcastTx(acceptSuccessTx.ser()) ).txid; // No trades left anymore expect(await agora.allOfferedTokenIds()).to.deep.equal([]); expect(await agora.offeredGroupTokenIds()).to.deep.equal([]); expect(await agora.offeredFungibleTokenIds()).to.deep.equal([]); expect( await agora.activeOffersByGroupTokenId(groupTokenId), ).to.deep.equal([]); expect(await agora.activeOffersByTokenId(childTokenId)).to.deep.equal( [], ); expect(await agora.activeOffersByPubKey(toHex(sellerPk))).to.deep.equal( [], ); const [acceptMsg] = await acceptPromise; expect(acceptMsg.type).to.equal('Tx'); expect(acceptMsg.msgType).to.equal('TX_ADDED_TO_MEMPOOL'); expect(acceptMsg.txid).to.equal(acceptSuccessTxid); // But we have the history expect( await agora.historicOffers({ type: 'TOKEN_ID', tokenId: childTokenId, table: 'HISTORY', }), ).to.deep.equal({ offers: [ { ...expectedOffer, status: 'CANCELED', }, { ...newExpectedOffer, takenInfo: { sats: 70000n, takerScriptHex: '76a914531260aa2a199e228c537dfa42c82bea2c7c1f4d88ac', atoms: 1n, }, status: 'TAKEN', }, ], numTxs: 3, numPages: 1, }); }); it('We can cancel an Agora Oneshot offer using cancel()', async () => { // Create Agora object to access trades const agora = new Agora(chronik); const cancelerSk = fromHex('99'.repeat(32)); const cancelerWallet = Wallet.fromSk(cancelerSk, chronik); const cancelerPk = cancelerWallet.pk; const cancelerP2pkh = cancelerWallet.script; await runner.sendToScript(50000n, cancelerP2pkh); await cancelerWallet.sync(); const utxos = cancelerWallet.utxos; expect(utxos.length).to.equal(1); const utxo = utxos[0]; // 1. Seller creates an NFT1 GROUP token const txBuildGenesisGroup = new TxBuilder({ inputs: [ { input: { prevOut: utxo.outpoint, signData: { sats: utxo.sats, outputScript: cancelerP2pkh, }, }, signatory: P2PKHSignatory( cancelerSk, cancelerPk, ALL_BIP143, ), }, ], outputs: [ { sats: 0n, script: slpGenesis( SLP_NFT1_GROUP, { tokenTicker: 'SLP NFT1 GROUP TOKEN', decimals: 4, }, 1n, ), }, { sats: 10000n, script: cancelerP2pkh }, ], }); const genesisTx = txBuildGenesisGroup.sign(); const genesisTxid = (await chronik.broadcastTx(genesisTx.ser())).txid; const groupTokenId = genesisTxid; expect(await chronik.token(genesisTxid)).to.deep.equal({ tokenId: groupTokenId, tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, genesisInfo: { tokenTicker: 'SLP NFT1 GROUP TOKEN', tokenName: '', url: '', hash: '', decimals: 4, }, timeFirstSeen: 1300000000, }); // 2. Seller creates an NFT1 CHILD token using the group token const txBuildGenesisChild = new TxBuilder({ inputs: [ { input: { prevOut: { txid: genesisTxid, outIdx: 1, }, signData: { sats: 10000n, outputScript: cancelerP2pkh, }, }, signatory: P2PKHSignatory( cancelerSk, cancelerPk, ALL_BIP143, ), }, ], outputs: [ { sats: 0n, script: slpGenesis( SLP_NFT1_CHILD, { tokenTicker: 'SLP NFT1 CHILD TOKEN', decimals: 0, }, 1n, ), }, { sats: 8000n, script: cancelerP2pkh }, ], }); const genesisChildTx = txBuildGenesisChild.sign(); const genesisChildTxid = ( await chronik.broadcastTx(genesisChildTx.ser()) ).txid; const childTokenId = genesisChildTxid; expect(await chronik.token(childTokenId)).to.deep.equal({ tokenId: childTokenId, tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, genesisInfo: { tokenTicker: 'SLP NFT1 CHILD TOKEN', tokenName: '', url: '', hash: '', decimals: 0, }, timeFirstSeen: 1300000000, }); const emitter = new EventEmitter(); const ws = chronik.ws({ onMessage: async msg => { if (!emitter.emit('ws', msg)) { console.warn('Emitted msg without any listeners', msg); } }, }); await ws.waitForOpen(); agora.subscribeWs(ws, { type: 'TOKEN_ID', tokenId: childTokenId, }); const listenNext = () => once(emitter, 'ws') as Promise<[MsgTxClient]>; // 3. Seller sends the NFT to an ad setup output for an Agora Oneshot // covenant that asks for 80000 sats const enforcedOutputs: TxOutput[] = [ { sats: BigInt(0), script: slpSend(childTokenId, SLP_NFT1_CHILD, [0n, 1n]), }, { sats: BigInt(80000), script: cancelerP2pkh }, ]; const agoraOneshot = new AgoraOneshot({ enforcedOutputs, cancelPk: cancelerPk, }); const agoraAdScript = agoraOneshot.adScript(); const agoraAdP2sh = Script.p2sh(shaRmd160(agoraAdScript.bytecode)); const txBuildAdSetup = new TxBuilder({ inputs: [ { input: { prevOut: { txid: genesisChildTxid, outIdx: 1, }, signData: { sats: 8000n, outputScript: cancelerP2pkh, }, }, signatory: P2PKHSignatory( cancelerSk, cancelerPk, ALL_BIP143, ), }, ], outputs: [ { sats: 0n, script: slpSend(childTokenId, SLP_NFT1_CHILD, [1n]), }, { sats: 7000n, script: agoraAdP2sh }, ], }); const adSetupTx = txBuildAdSetup.sign(); const adSetupTxid = (await chronik.broadcastTx(adSetupTx.ser())).txid; // 4. Seller finishes offer setup + sends NFT to the advertised P2SH const agoraScript = agoraOneshot.script(); const agoraP2sh = Script.p2sh(shaRmd160(agoraScript.bytecode)); const txBuildOffer = new TxBuilder({ inputs: [ { input: { prevOut: { txid: adSetupTxid, outIdx: 1, }, signData: { sats: 7000n, redeemScript: agoraAdScript, }, }, signatory: AgoraOneshotAdSignatory(cancelerSk), }, ], outputs: [ { sats: 0n, script: slpSend(childTokenId, SLP_NFT1_CHILD, [1n]), }, { sats: 546n, script: agoraP2sh }, ], }); const offerTx = txBuildOffer.sign(); const offerPromise = listenNext(); const offerTxid = (await chronik.broadcastTx(offerTx.ser())).txid; const offerOutpoint: OutPoint = { txid: offerTxid, outIdx: 1, }; const offerTxBuilderInput: TxInput = { prevOut: offerOutpoint, signData: { redeemScript: agoraScript, sats: 546n, }, }; // Expected created offer const expectedOffer = new AgoraOffer({ variant: { type: 'ONESHOT', params: agoraOneshot, }, outpoint: offerOutpoint, txBuilderInput: offerTxBuilderInput, token: { tokenId: childTokenId, tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, atoms: 1n, isMintBaton: false, }, status: 'OPEN', }); const [offerMsg] = await offerPromise; expect(offerMsg.type).to.equal('Tx'); expect(offerMsg.msgType).to.equal('TX_ADDED_TO_MEMPOOL'); expect(offerMsg.txid).to.equal(offerTxid); // 5. Buyer searches for NFT trades, finds the advertised one expect(await agora.allOfferedTokenIds()).to.deep.equal([childTokenId]); // Query by group token ID expect( await agora.activeOffersByGroupTokenId(groupTokenId), ).to.deep.equal([expectedOffer]); // Query by child token ID expect(await agora.activeOffersByTokenId(childTokenId)).to.deep.equal([ expectedOffer, ]); // Query by cancelPk expect( await agora.activeOffersByPubKey(toHex(cancelerPk)), ).to.deep.equal([expectedOffer]); // We can cancel the offer using cancel() // We need a utxo to spend to cancel this offer await runner.sendToScript(50000n, cancelerP2pkh); await cancelerWallet.sync(); await expectedOffer.cancel({ wallet: cancelerWallet, }); // We can no longer find the offer expect(await agora.allOfferedTokenIds()).to.deep.equal([]); expect(await agora.offeredGroupTokenIds()).to.deep.equal([]); expect(await agora.offeredFungibleTokenIds()).to.deep.equal([]); expect( await agora.activeOffersByGroupTokenId(groupTokenId), ).to.deep.equal([]); expect(await agora.activeOffersByTokenId(childTokenId)).to.deep.equal( [], ); expect( await agora.activeOffersByPubKey(toHex(cancelerPk)), ).to.deep.equal([]); }); it('Can list an NFT offer using list() method and take it with another wallet', async () => { const agora = new Agora(chronik); const sellerSk = fromHex('aa'.repeat(32)); const sellerWallet = Wallet.fromSk(sellerSk, chronik); const sellerPk = ecc.derivePubkey(sellerSk); const sellerPkh = shaRmd160(sellerPk); const sellerP2pkh = Script.p2pkh(sellerPkh); const buyerSk = fromHex('bb'.repeat(32)); const buyerWallet = Wallet.fromSk(buyerSk, chronik); const buyerPk = ecc.derivePubkey(buyerSk); // Fund seller wallet await runner.sendToScript(50000n, sellerP2pkh); await sellerWallet.sync(); // Create NFT GROUP token using Wallet const groupGenesisAction: payment.Action = { outputs: [ { sats: 0n }, { sats: 10000n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, script: sellerWallet.script, atoms: 1n, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, genesisInfo: { tokenTicker: 'TEST GROUP', decimals: 0, }, }, ], }; const groupGenesisResult = await sellerWallet .action(groupGenesisAction) .build() .broadcast(); const groupTokenId = groupGenesisResult.broadcasted[0]; // Create NFT CHILD token using Wallet await sellerWallet.sync(); const childGenesisAction: payment.Action = { outputs: [ { sats: 0n }, { sats: 8000n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, script: sellerWallet.script, atoms: 1n, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, genesisInfo: { tokenTicker: 'TEST NFT', decimals: 0, }, groupTokenId: groupTokenId, }, ], }; const childGenesisResult = await sellerWallet .action(childGenesisAction) .build() .broadcast(); const childTokenId = childGenesisResult.broadcasted[0]; // Wait for token to be indexed await sellerWallet.sync(); // Create AgoraOneshot offer and list it const listPriceSatoshis = 30000n; const agoraOneshot = new AgoraOneshot({ enforcedOutputs: [ { sats: 0n, script: slpSend(childTokenId, SLP_NFT1_CHILD, [0n, 1n]), }, { sats: listPriceSatoshis, script: sellerP2pkh, }, ], cancelPk: sellerPk, }); const listResult = await agoraOneshot.list({ wallet: sellerWallet, tokenId: childTokenId, tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, }); expect(listResult.success).to.equal(true); expect(listResult.broadcasted.length).to.equal(2); // Find the offer const offers = await agora.activeOffersByTokenId(childTokenId); expect(offers.length).to.equal(1); const offer = offers[0]; // Fund buyer wallet await runner.sendToScript(50000n, Script.p2pkh(shaRmd160(buyerPk))); await buyerWallet.sync(); // Take the offer using take() method const takeResult = await offer.take({ wallet: buyerWallet, covenantSk: buyerSk, covenantPk: buyerPk, }); expect(takeResult.success).to.equal(true); expect(takeResult.broadcasted.length).to.equal(1); // Verify offer is no longer active const remainingOffers = await agora.activeOffersByTokenId(childTokenId); expect(remainingOffers.length).to.equal(0); }); it('Can take two oneshot NFT offers with the same buyer wallet, WITHOUT syncing in between', async () => { const agora = new Agora(chronik); const sellerSk = fromHex('bf'.repeat(32)); const sellerWallet = Wallet.fromSk(sellerSk, chronik); const sellerPk = ecc.derivePubkey(sellerSk); const sellerPkh = shaRmd160(sellerPk); const sellerP2pkh = Script.p2pkh(sellerPkh); const buyerSk = fromHex('c0'.repeat(32)); const buyerWallet = Wallet.fromSk(buyerSk, chronik); const buyerPk = ecc.derivePubkey(buyerSk); await runner.sendToScript(90000n, sellerP2pkh); await sellerWallet.sync(); const groupGenesisAction: payment.Action = { outputs: [ { sats: 0n }, { sats: 10000n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, script: sellerWallet.script, atoms: 2n, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, genesisInfo: { tokenTicker: 'TST GRP SYNC', decimals: 0, }, }, ], }; const groupGenesisResult = await sellerWallet .action(groupGenesisAction) .build() .broadcast(); expect(groupGenesisResult.success).to.equal(true); const groupBroadcasted = groupGenesisResult.broadcasted; const groupTokenId = groupBroadcasted[groupBroadcasted.length - 1]; await sellerWallet.sync(); async function mintChildListing( ticker: string, listPriceSatoshis: bigint, ): Promise<string> { const childGenesisAction: payment.Action = { outputs: [ { sats: 0n }, { sats: 8000n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, script: sellerWallet.script, atoms: 1n, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, genesisInfo: { tokenTicker: ticker, decimals: 0, }, groupTokenId: groupTokenId, }, ], }; const childGenesisResult = await sellerWallet .action(childGenesisAction) .build() .broadcast(); expect(childGenesisResult.success).to.equal(true); // SLP NFT1 CHILD genesis may be a 2-tx chain (group fan-out SEND + GENESIS). // broadcasted is [fanoutTxid, genesisTxid]; the child token id is always the // GENESIS tx (last in the array). Using broadcasted[0] breaks list() on the // second mint when fan-out triggers. const childBroadcasted = childGenesisResult.broadcasted; const cid = childBroadcasted[childBroadcasted.length - 1]; const agoraOneshot = new AgoraOneshot({ enforcedOutputs: [ { sats: 0n, script: slpSend(cid, SLP_NFT1_CHILD, [0n, 1n]), }, { sats: listPriceSatoshis, script: sellerP2pkh, }, ], cancelPk: sellerPk, }); const listResult = await agoraOneshot.list({ wallet: sellerWallet, tokenId: cid, tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, }); expect(listResult.success).to.equal(true); expect(listResult.broadcasted.length).to.equal(2); await sellerWallet.sync(); return cid; } const childTokenIdA = await mintChildListing('TST NFT A', 30000n); const childTokenIdB = await mintChildListing('TST NFT B', 28000n); await runner.sendToScript(90000n, Script.p2pkh(shaRmd160(buyerPk))); await buyerWallet.sync(); const offersA = await agora.activeOffersByTokenId(childTokenIdA); expect(offersA.length).to.equal(1); const takeAResult = await offersA[0].take({ wallet: buyerWallet, covenantSk: buyerSk, covenantPk: buyerPk, }); expect(takeAResult.success).to.equal(true); expect(takeAResult.broadcasted.length).to.equal(1); const offersB = await agora.activeOffersByTokenId(childTokenIdB); expect(offersB.length).to.equal(1); const takeBResult = await offersB[0].take({ wallet: buyerWallet, covenantSk: buyerSk, covenantPk: buyerPk, }); expect(takeBResult.success).to.equal(true); expect(takeBResult.broadcasted.length).to.equal(1); expect( (await agora.activeOffersByTokenId(childTokenIdA)).length, ).to.equal(0); expect( (await agora.activeOffersByTokenId(childTokenIdB)).length, ).to.equal(0); }); it('Can list an NFT offer using list() method and cancel it with the same wallet', async () => { const agora = new Agora(chronik); const sellerSk = fromHex('cc'.repeat(32)); const sellerWallet = Wallet.fromSk(sellerSk, chronik); const sellerPk = ecc.derivePubkey(sellerSk); const sellerPkh = shaRmd160(sellerPk); const sellerP2pkh = Script.p2pkh(sellerPkh); // Fund seller wallet await runner.sendToScript(50000n, sellerP2pkh); await sellerWallet.sync(); // Create NFT GROUP token using Wallet const groupGenesisAction: payment.Action = { outputs: [ { sats: 0n }, { sats: 10000n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, script: sellerWallet.script, atoms: 1n, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, genesisInfo: { tokenTicker: 'TEST GROUP 2', decimals: 0, }, }, ], }; const groupGenesisResult = await sellerWallet .action(groupGenesisAction) .build() .broadcast(); const groupTokenId = groupGenesisResult.broadcasted[0]; // Create NFT CHILD token using Wallet await sellerWallet.sync(); const childGenesisAction: payment.Action = { outputs: [ { sats: 0n }, { sats: 8000n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, script: sellerWallet.script, atoms: 1n, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, genesisInfo: { tokenTicker: 'TEST NFT 2', decimals: 0, }, groupTokenId: groupTokenId, }, ], }; const childGenesisResult = await sellerWallet .action(childGenesisAction) .build() .broadcast(); const childBr = childGenesisResult.broadcasted; const childTokenId = childBr[childBr.length - 1]; // Wait for token to be indexed await sellerWallet.sync(); // Create AgoraOneshot offer and list it const listPriceSatoshis = 50000n; const agoraOneshot = new AgoraOneshot({ enforcedOutputs: [ { sats: 0n, script: slpSend(childTokenId, SLP_NFT1_CHILD, [0n, 1n]), }, { sats: listPriceSatoshis, script: sellerP2pkh, }, ], cancelPk: sellerPk, }); const listResult = await agoraOneshot.list({ wallet: sellerWallet, tokenId: childTokenId, tokenType: SLP_TOKEN_TYPE_NFT1_CHILD, }); expect(listResult.success).to.equal(true); expect(listResult.broadcasted.length).to.equal(2); // Find the offer const offers = await agora.activeOffersByTokenId(childTokenId); expect(offers.length).to.equal(1); const offer = offers[0]; // Fund seller wallet for cancel fee await runner.sendToScript(50000n, sellerP2pkh); await sellerWallet.sync(); // Cancel the offer using cancel() method const cancelResult = await offer.cancel({ wallet: sellerWallet, }); expect(cancelResult.success).to.equal(true); expect(cancelResult.broadcasted.length).to.equal(1); // Verify offer is no longer active const remainingOffers = await agora.activeOffersByTokenId(childTokenId); expect(remainingOffers.length).to.equal(0); }); it('Can list an NFT oneshot and cancel() with the same wallet, WITHOUT syncing in between', async () => { const agora = new Agora(chronik); const sellerSk = fromHex('c1'.repeat(32)); const sellerWallet = Wallet.fromSk(sellerSk, chronik); const sellerPk = ecc.derivePubkey(sellerSk); const sellerPkh = shaRmd160(sellerPk); const sellerP2pkh = Script.p2pkh(sellerPkh); // Enough XEC so list+cancel need no wallet.sync() mid-flow. TestRunner allocates // ONE setup coin input per sendToScript (COIN_VALUE === 100000n in this suite), so each // call can only pay ~COIN_VALUE − fee — never a single amount above that. await runner.sendToScript(98000n, sellerP2pkh); await runner.sendToScript(98000n, sellerP2pkh); await runner.sendToScript(98000n, sellerP2pkh); await sellerWallet.sync(); const groupGenesisAction: payment.Action = { outputs: [ { sats: 0n }, { sats: 10000n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, script: sellerWallet.script, atoms: 1n, }, ], tokenActions: [ { type: 'GENESIS', tokenType: SLP_TOKEN_TYPE_NFT1_GROUP, genesisInfo: { tokenTicker: 'NST G SYNC', decimals: 0, }, }, ], }; const groupGenesisResult = await sellerWallet .action(groupGenesisAction) .build() .broadcast(); const groupBr = groupGenesisResult.broadcasted; const groupTokenId = groupBr[groupBr.length - 1]; await sellerWallet.sync(); const childGenesisAction: payment.Action = { outputs: [ { sats: 0n }, { sats: 8000n, tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER, script: