ecash-agora
Version:
Library for interacting with the eCash Agora protocol
871 lines (768 loc) • 29.5 kB
text/typescript
// Copyright (c) 2026 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import { expect, use } from 'chai';
import chaiAsPromised from 'chai-as-promised';
import { ChronikClient } from 'chronik-client';
import {
ALP_TOKEN_TYPE_STANDARD,
DEFAULT_DUST_SATS,
Ecc,
SLP_NFT1_CHILD,
SLP_TOKEN_TYPE_FUNGIBLE,
SLP_TOKEN_TYPE_NFT1_CHILD,
SLP_TOKEN_TYPE_NFT1_GROUP,
Script,
fromHex,
payment,
shaRmd160,
slpSend,
} from 'ecash-lib';
import { TestRunner } from 'ecash-lib/dist/test/testRunner.js';
import { Wallet } from 'ecash-wallet';
import { Agora } from '../src/agora.js';
import { AgoraOneshot } from '../src/oneshot.js';
use(chaiAsPromised);
const NUM_COINS = 500;
const COIN_VALUE = 1100000000n;
const FINALIZATION_TIMEOUT_ERROR =
'Error: Failed getting /broadcast-txs: 504: Transaction(s) failed to finalize within 1s';
const expectFinalizationTimedOut = async (
chronikClient: ChronikClient,
result: {
success: boolean;
broadcasted: string[];
unbroadcasted?: string[];
errors?: string[];
},
broadcastedCount: number,
) => {
expect(result.success).to.equal(false);
expect(result.broadcasted.length).to.equal(broadcastedCount);
expect(result.unbroadcasted).to.deep.equal([]);
expect(result.errors).to.deep.equal([FINALIZATION_TIMEOUT_ERROR]);
for (const txid of result.broadcasted) {
const tx = await chronikClient.tx(txid);
expect(tx.isFinal).to.equal(false);
}
};
describe('finalizationTimeoutSecs', () => {
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('AgoraPartial.list() returns expected error when the ALP listing tx does not finalize within 1s', async () => {
const agora = new Agora(chronik);
const makerSk = fromHex('f6'.repeat(32));
const makerWallet = Wallet.fromSk(makerSk, chronik);
const makerPk = ecc.derivePubkey(makerSk);
const makerScript = Script.p2pkh(shaRmd160(makerPk));
await runner.sendToScript(50000n, makerScript);
await makerWallet.sync();
const genesisResult = await makerWallet
.action({
outputs: [
{ sats: 0n },
{
sats: 546n,
tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER,
script: makerWallet.script,
atoms: 1000000n,
},
{
sats: DEFAULT_DUST_SATS,
tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER,
script: makerWallet.script,
isMintBaton: true,
atoms: 0n,
},
],
tokenActions: [
{
type: 'GENESIS',
tokenType: ALP_TOKEN_TYPE_STANDARD,
genesisInfo: {
tokenTicker: 'FIN ALP',
decimals: 4,
},
},
],
})
.build()
.broadcast();
const tokenId = genesisResult.broadcasted[0];
await makerWallet.sync();
const agoraPartial = await agora.selectParams(
{
offeredAtoms: 100000n,
priceNanoSatsPerAtom: 1_000_000_000n,
minAcceptedAtoms: 546n,
makerPk,
tokenId,
tokenType: ALP_TOKEN_TYPE_STANDARD.number,
tokenProtocol: 'ALP',
},
64n,
);
const listResult = await agoraPartial.list({
wallet: makerWallet,
finalizationTimeoutSecs: 1,
});
await expectFinalizationTimedOut(chronik, listResult, 1);
});
it('AgoraPartial.list() returns expected error when SLP listing txs do not finalize within 1s', async () => {
const agora = new Agora(chronik);
const makerSk = fromHex('f7'.repeat(32));
const makerWallet = Wallet.fromSk(makerSk, chronik);
const makerPk = ecc.derivePubkey(makerSk);
const makerScript = Script.p2pkh(shaRmd160(makerPk));
await runner.sendToScript(50000n, makerScript);
await makerWallet.sync();
const genesisResult = await makerWallet
.action({
outputs: [
{ sats: 0n },
{
sats: 20000n,
tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER,
script: makerWallet.script,
atoms: 1000000n,
},
],
tokenActions: [
{
type: 'GENESIS',
tokenType: SLP_TOKEN_TYPE_FUNGIBLE,
genesisInfo: {
tokenTicker: 'FIN SLP',
decimals: 4,
},
},
],
})
.build()
.broadcast();
const tokenId = genesisResult.broadcasted[0];
await makerWallet.sync();
const agoraPartial = await agora.selectParams(
{
offeredAtoms: 100000n,
priceNanoSatsPerAtom: 1_000_000_000n,
minAcceptedAtoms: 546n,
makerPk,
tokenId,
tokenType: SLP_TOKEN_TYPE_FUNGIBLE.number,
tokenProtocol: 'SLP',
},
64n,
);
const listResult = await agoraPartial.list({
wallet: makerWallet,
finalizationTimeoutSecs: 1,
});
await expectFinalizationTimedOut(chronik, listResult, 2);
});
it('AgoraOneshot.list() returns expected error when listing txs do not finalize within 1s', async () => {
const sellerSk = fromHex('f8'.repeat(32));
const sellerWallet = Wallet.fromSk(sellerSk, chronik);
const sellerPk = ecc.derivePubkey(sellerSk);
const sellerP2pkh = Script.p2pkh(shaRmd160(sellerPk));
await runner.sendToScript(50000n, sellerP2pkh);
await sellerWallet.sync();
const groupGenesisResult = await sellerWallet
.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: 'FIN GRP',
decimals: 0,
},
},
],
})
.build()
.broadcast();
const groupTokenId = groupGenesisResult.broadcasted[0];
await sellerWallet.sync();
const childGenesisResult = await sellerWallet
.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: 'FIN NFT',
decimals: 0,
},
groupTokenId,
},
],
})
.build()
.broadcast();
const childTokenId = childGenesisResult.broadcasted[0];
await sellerWallet.sync();
const agoraOneshot = new AgoraOneshot({
enforcedOutputs: [
{
sats: 0n,
script: slpSend(childTokenId, SLP_NFT1_CHILD, [0n, 1n]),
},
{
sats: 30000n,
script: sellerP2pkh,
},
],
cancelPk: sellerPk,
});
const listResult = await agoraOneshot.list({
wallet: sellerWallet,
tokenId: childTokenId,
tokenType: SLP_TOKEN_TYPE_NFT1_CHILD,
finalizationTimeoutSecs: 1,
});
await expectFinalizationTimedOut(chronik, listResult, 2);
});
it('AgoraOffer.take() returns expected error when ALP partial accept tx does not finalize within 1s', async () => {
const agora = new Agora(chronik);
const makerSk = fromHex('a1'.repeat(32));
const makerWallet = Wallet.fromSk(makerSk, chronik);
const makerPk = ecc.derivePubkey(makerSk);
const makerScript = Script.p2pkh(shaRmd160(makerPk));
const takerSk = fromHex('a2'.repeat(32));
const takerWallet = Wallet.fromSk(takerSk, chronik);
const takerPk = ecc.derivePubkey(takerSk);
await runner.sendToScript(50000n, makerScript);
await makerWallet.sync();
const genesisResult = await makerWallet
.action({
outputs: [
{ sats: 0n },
{
sats: 546n,
tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER,
script: makerWallet.script,
atoms: 1000000n,
},
{
sats: DEFAULT_DUST_SATS,
tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER,
script: makerWallet.script,
isMintBaton: true,
atoms: 0n,
},
],
tokenActions: [
{
type: 'GENESIS',
tokenType: ALP_TOKEN_TYPE_STANDARD,
genesisInfo: {
tokenTicker: 'FIN TAKE ALP',
decimals: 4,
},
},
],
})
.build()
.broadcast();
const tokenId = genesisResult.broadcasted[0];
await makerWallet.sync();
const agoraPartial = await agora.selectParams(
{
offeredAtoms: 100000n,
priceNanoSatsPerAtom: 1_000_000_000n,
minAcceptedAtoms: 546n,
makerPk,
tokenId,
tokenType: ALP_TOKEN_TYPE_STANDARD.number,
tokenProtocol: 'ALP',
},
64n,
);
const listResult = await agoraPartial.list({ wallet: makerWallet });
expect(listResult.success).to.equal(true);
const offers = await agora.activeOffersByTokenId(tokenId);
expect(offers.length).to.equal(1);
await runner.sendToScript(200000n, Script.p2pkh(shaRmd160(takerPk)));
await takerWallet.sync();
const takeResult = await offers[0].take({
wallet: takerWallet,
covenantSk: takerSk,
covenantPk: takerPk,
acceptedAtoms: 50000n,
finalizationTimeoutSecs: 1,
});
await expectFinalizationTimedOut(chronik, takeResult, 1);
});
it('AgoraOffer.take() returns expected error when SLP partial accept tx does not finalize within 1s', async () => {
const agora = new Agora(chronik);
const makerSk = fromHex('a3'.repeat(32));
const makerWallet = Wallet.fromSk(makerSk, chronik);
const makerPk = ecc.derivePubkey(makerSk);
const makerScript = Script.p2pkh(shaRmd160(makerPk));
const takerSk = fromHex('a4'.repeat(32));
const takerWallet = Wallet.fromSk(takerSk, chronik);
const takerPk = ecc.derivePubkey(takerSk);
await runner.sendToScript(50000n, makerScript);
await makerWallet.sync();
const genesisResult = await makerWallet
.action({
outputs: [
{ sats: 0n },
{
sats: 20000n,
tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER,
script: makerWallet.script,
atoms: 1000000n,
},
],
tokenActions: [
{
type: 'GENESIS',
tokenType: SLP_TOKEN_TYPE_FUNGIBLE,
genesisInfo: {
tokenTicker: 'FIN TAKE SLP',
decimals: 4,
},
},
],
})
.build()
.broadcast();
const tokenId = genesisResult.broadcasted[0];
await makerWallet.sync();
const agoraPartial = await agora.selectParams(
{
offeredAtoms: 100000n,
priceNanoSatsPerAtom: 1_000_000_000n,
minAcceptedAtoms: 546n,
makerPk,
tokenId,
tokenType: SLP_TOKEN_TYPE_FUNGIBLE.number,
tokenProtocol: 'SLP',
},
64n,
);
const listResult = await agoraPartial.list({ wallet: makerWallet });
expect(listResult.success).to.equal(true);
const offers = await agora.activeOffersByTokenId(tokenId);
expect(offers.length).to.equal(1);
await runner.sendToScript(200000n, Script.p2pkh(shaRmd160(takerPk)));
await takerWallet.sync();
const takeResult = await offers[0].take({
wallet: takerWallet,
covenantSk: takerSk,
covenantPk: takerPk,
acceptedAtoms: 50000n,
finalizationTimeoutSecs: 1,
});
await expectFinalizationTimedOut(chronik, takeResult, 1);
});
it('AgoraOffer.take() returns expected error when oneshot accept tx does not finalize within 1s', async () => {
const agora = new Agora(chronik);
const sellerSk = fromHex('a5'.repeat(32));
const sellerWallet = Wallet.fromSk(sellerSk, chronik);
const sellerPk = ecc.derivePubkey(sellerSk);
const sellerP2pkh = Script.p2pkh(shaRmd160(sellerPk));
const buyerSk = fromHex('a6'.repeat(32));
const buyerWallet = Wallet.fromSk(buyerSk, chronik);
const buyerPk = ecc.derivePubkey(buyerSk);
await runner.sendToScript(50000n, sellerP2pkh);
await sellerWallet.sync();
const groupGenesisResult = await sellerWallet
.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: 'FIN TAKE GRP',
decimals: 0,
},
},
],
})
.build()
.broadcast();
const groupTokenId = groupGenesisResult.broadcasted[0];
await sellerWallet.sync();
const childGenesisResult = await sellerWallet
.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: 'FIN TAKE NFT',
decimals: 0,
},
groupTokenId,
},
],
})
.build()
.broadcast();
const childTokenId = childGenesisResult.broadcasted[0];
await sellerWallet.sync();
const agoraOneshot = new AgoraOneshot({
enforcedOutputs: [
{
sats: 0n,
script: slpSend(childTokenId, SLP_NFT1_CHILD, [0n, 1n]),
},
{
sats: 30000n,
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);
const offers = await agora.activeOffersByTokenId(childTokenId);
expect(offers.length).to.equal(1);
await runner.sendToScript(50000n, Script.p2pkh(shaRmd160(buyerPk)));
await buyerWallet.sync();
const takeResult = await offers[0].take({
wallet: buyerWallet,
covenantSk: buyerSk,
covenantPk: buyerPk,
finalizationTimeoutSecs: 1,
});
await expectFinalizationTimedOut(chronik, takeResult, 1);
});
it('AgoraOffer.cancel() returns expected error when ALP partial cancel tx does not finalize within 1s', async () => {
const agora = new Agora(chronik);
const makerSk = fromHex('b1'.repeat(32));
const makerWallet = Wallet.fromSk(makerSk, chronik);
const makerPk = ecc.derivePubkey(makerSk);
const makerScript = Script.p2pkh(shaRmd160(makerPk));
await runner.sendToScript(100000n, makerScript);
await makerWallet.sync();
const genesisResult = await makerWallet
.action({
outputs: [
{ sats: 0n },
{
sats: 546n,
tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER,
script: makerWallet.script,
atoms: 1000000n,
},
{
sats: DEFAULT_DUST_SATS,
tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER,
script: makerWallet.script,
isMintBaton: true,
atoms: 0n,
},
],
tokenActions: [
{
type: 'GENESIS',
tokenType: ALP_TOKEN_TYPE_STANDARD,
genesisInfo: {
tokenTicker: 'FIN CAN ALP',
decimals: 4,
},
},
],
})
.build()
.broadcast();
const tokenId = genesisResult.broadcasted[0];
await makerWallet.sync();
const agoraPartial = await agora.selectParams(
{
offeredAtoms: 100000n,
priceNanoSatsPerAtom: 1_000_000_000n,
minAcceptedAtoms: 546n,
makerPk,
tokenId,
tokenType: ALP_TOKEN_TYPE_STANDARD.number,
tokenProtocol: 'ALP',
},
64n,
);
const listResult = await agoraPartial.list({ wallet: makerWallet });
expect(listResult.success).to.equal(true);
const offers = await agora.activeOffersByTokenId(tokenId);
expect(offers.length).to.equal(1);
const cancelResult = await offers[0].cancel({
wallet: makerWallet,
finalizationTimeoutSecs: 1,
});
await expectFinalizationTimedOut(chronik, cancelResult, 1);
});
it('AgoraOffer.cancel() returns expected error when SLP partial cancel tx does not finalize within 1s', async () => {
const agora = new Agora(chronik);
const makerSk = fromHex('b2'.repeat(32));
const makerWallet = Wallet.fromSk(makerSk, chronik);
const makerPk = ecc.derivePubkey(makerSk);
const makerScript = Script.p2pkh(shaRmd160(makerPk));
await runner.sendToScript(100000n, makerScript);
await makerWallet.sync();
const genesisResult = await makerWallet
.action({
outputs: [
{ sats: 0n },
{
sats: 20000n,
tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER,
script: makerWallet.script,
atoms: 1000000n,
},
],
tokenActions: [
{
type: 'GENESIS',
tokenType: SLP_TOKEN_TYPE_FUNGIBLE,
genesisInfo: {
tokenTicker: 'FIN CAN SLP',
decimals: 4,
},
},
],
})
.build()
.broadcast();
const tokenId = genesisResult.broadcasted[0];
await makerWallet.sync();
const agoraPartial = await agora.selectParams(
{
offeredAtoms: 100000n,
priceNanoSatsPerAtom: 1_000_000_000n,
minAcceptedAtoms: 546n,
makerPk,
tokenId,
tokenType: SLP_TOKEN_TYPE_FUNGIBLE.number,
tokenProtocol: 'SLP',
},
64n,
);
const listResult = await agoraPartial.list({ wallet: makerWallet });
expect(listResult.success).to.equal(true);
const offers = await agora.activeOffersByTokenId(tokenId);
expect(offers.length).to.equal(1);
const cancelResult = await offers[0].cancel({
wallet: makerWallet,
finalizationTimeoutSecs: 1,
});
await expectFinalizationTimedOut(chronik, cancelResult, 1);
});
it('AgoraOffer.cancel() returns expected error when oneshot cancel tx does not finalize within 1s', async () => {
const agora = new Agora(chronik);
const sellerSk = fromHex('b3'.repeat(32));
const sellerWallet = Wallet.fromSk(sellerSk, chronik);
const sellerPk = ecc.derivePubkey(sellerSk);
const sellerP2pkh = Script.p2pkh(shaRmd160(sellerPk));
await runner.sendToScript(100000n, sellerP2pkh);
await sellerWallet.sync();
const groupGenesisResult = await sellerWallet
.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: 'FIN CAN GRP',
decimals: 0,
},
},
],
})
.build()
.broadcast();
const groupTokenId = groupGenesisResult.broadcasted[0];
await sellerWallet.sync();
const childGenesisResult = await sellerWallet
.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: 'FIN CAN NFT',
decimals: 0,
},
groupTokenId,
},
],
})
.build()
.broadcast();
const childTokenId = childGenesisResult.broadcasted[0];
await sellerWallet.sync();
const agoraOneshot = new AgoraOneshot({
enforcedOutputs: [
{
sats: 0n,
script: slpSend(childTokenId, SLP_NFT1_CHILD, [0n, 1n]),
},
{
sats: 30000n,
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);
const offers = await agora.activeOffersByTokenId(childTokenId);
expect(offers.length).to.equal(1);
const cancelResult = await offers[0].cancel({
wallet: sellerWallet,
finalizationTimeoutSecs: 1,
});
await expectFinalizationTimedOut(chronik, cancelResult, 1);
});
it('AgoraOffer.relist() returns expected error when ALP relist tx does not finalize within 1s', async () => {
const agora = new Agora(chronik);
const makerSk = fromHex('c1'.repeat(32));
const makerWallet = Wallet.fromSk(makerSk, chronik);
const makerPk = ecc.derivePubkey(makerSk);
const makerScript = Script.p2pkh(shaRmd160(makerPk));
await runner.sendToScript(450000n, makerScript);
await makerWallet.sync();
const genesisResult = await makerWallet
.action({
outputs: [
{ sats: 0n },
{
sats: 546n,
tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER,
script: makerWallet.script,
atoms: 1000000n,
},
{
sats: DEFAULT_DUST_SATS,
tokenId: payment.GENESIS_TOKEN_ID_PLACEHOLDER,
script: makerWallet.script,
isMintBaton: true,
atoms: 0n,
},
],
tokenActions: [
{
type: 'GENESIS',
tokenType: ALP_TOKEN_TYPE_STANDARD,
genesisInfo: {
tokenTicker: 'FIN RELIST',
decimals: 4,
},
},
],
})
.build()
.broadcast();
const tokenId = genesisResult.broadcasted[0];
await makerWallet.sync();
const agoraPartialInitial = await agora.selectParams(
{
offeredAtoms: 100000n,
priceNanoSatsPerAtom: 1_000_000_000n,
minAcceptedAtoms: 546n,
makerPk,
tokenId,
tokenType: ALP_TOKEN_TYPE_STANDARD.number,
tokenProtocol: 'ALP',
},
64n,
);
const listResult = await agoraPartialInitial.list({
wallet: makerWallet,
});
expect(listResult.success).to.equal(true);
const offers = await agora.activeOffersByTokenId(tokenId);
expect(offers.length).to.equal(1);
const updatedPartial = await agora.selectParams(
{
offeredAtoms: 80000n,
priceNanoSatsPerAtom: 2_000_000_000n,
minAcceptedAtoms: 546n,
makerPk,
tokenId,
tokenType: ALP_TOKEN_TYPE_STANDARD.number,
tokenProtocol: 'ALP',
},
64n,
);
const relistResult = await offers[0].relist({
wallet: makerWallet,
updatedPartial,
finalizationTimeoutSecs: 1,
});
await expectFinalizationTimedOut(chronik, relistResult, 1);
});
});