ecash-agora
Version:
Library for interacting with the eCash Agora protocol
708 lines (666 loc) • 24.2 kB
text/typescript
// 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,
Script,
TxBuilder,
TxInput,
TxOutput,
fromHex,
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';
use(chaiAsPromised);
const NUM_COINS = 500;
const COIN_VALUE = 100000n;
const SLP_TOKEN_TYPE_NFT1_GROUP = {
number: 0x81,
protocol: 'SLP' as const,
type: 'SLP_TOKEN_TYPE_NFT1_GROUP' as const,
};
const SLP_TOKEN_TYPE_NFT1_CHILD = {
number: 0x41,
protocol: 'SLP' as const,
type: 'SLP_TOKEN_TYPE_NFT1_CHILD' as const,
};
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,
});
});
});