ecash-agora
Version:
Library for interacting with the eCash Agora protocol
775 lines (744 loc) • 27 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 { expect, use, assert } from 'chai';
import chaiAsPromised from 'chai-as-promised';
import { ChronikClient } from 'chronik-client';
import {
ALL_BIP143,
DEFAULT_DUST_SATS,
Ecc,
P2PKHSignatory,
SLP_FUNGIBLE,
Script,
TxBuilderInput,
fromHex,
shaRmd160,
slpSend,
toHex,
} from 'ecash-lib';
import { TestRunner } from 'ecash-lib/dist/test/testRunner.js';
import { AgoraPartial } from '../src/partial.js';
import { makeSlpOffer, takeSlpOffer } from './partial-helper-slp.js';
import { Agora, TakenInfo } from '../src/agora.js';
use(chaiAsPromised);
// This test needs a lot of sats
const NUM_COINS = 500;
const COIN_VALUE = 1100000000n;
const BASE_PARAMS_SLP = {
tokenId: '00'.repeat(32), // filled in later
tokenType: SLP_FUNGIBLE,
tokenProtocol: 'SLP' as const,
dustSats: DEFAULT_DUST_SATS,
};
const ecc = new Ecc();
const makerSk = fromHex('33'.repeat(32));
const makerPk = ecc.derivePubkey(makerSk);
const makerPkh = shaRmd160(makerPk);
const makerScript = Script.p2pkh(makerPkh);
const makerScriptHex = toHex(makerScript.bytecode);
const takerSk = fromHex('44'.repeat(32));
const takerPk = ecc.derivePubkey(takerSk);
const takerPkh = shaRmd160(takerPk);
const takerScript = Script.p2pkh(takerPkh);
const takerScriptHex = toHex(takerScript.bytecode);
describe('AgoraPartial SLP', () => {
let runner: TestRunner;
let chronik: ChronikClient;
async function makeBuilderInputs(
values: bigint[],
): Promise<TxBuilderInput[]> {
const txid = await runner.sendToScript(values, makerScript);
return values.map((sats, outIdx) => ({
input: {
prevOut: {
txid,
outIdx,
},
signData: {
sats,
outputScript: makerScript,
},
},
signatory: P2PKHSignatory(makerSk, makerPk, ALL_BIP143),
}));
}
before(async () => {
runner = await TestRunner.setup('setup_scripts/ecash-agora_base');
chronik = runner.chronik;
await runner.setupCoins(NUM_COINS, COIN_VALUE);
});
after(() => {
runner.stop();
});
interface TestCase {
offeredAtoms: bigint;
info: string;
priceNanoSatsPerAtom: bigint;
acceptedAtoms: bigint;
askedSats: bigint;
allowUnspendable?: boolean;
}
const TEST_CASES: TestCase[] = [
{
offeredAtoms: 1000n,
info: '1sat/token, full accept',
priceNanoSatsPerAtom: 1000000000n,
acceptedAtoms: 1000n,
askedSats: 1000n,
},
{
offeredAtoms: 1000n,
info: '1sat/token, dust accept, must allowUnspendable',
priceNanoSatsPerAtom: 1000000000n,
acceptedAtoms: 546n,
askedSats: 546n,
allowUnspendable: true,
},
{
offeredAtoms: 1000n,
info: '1000sat/token, full accept',
priceNanoSatsPerAtom: 1000n * 1000000000n,
acceptedAtoms: 1000n,
askedSats: 1000225n,
},
{
offeredAtoms: 1000n,
info: '1000sat/token, half accept',
priceNanoSatsPerAtom: 1000n * 1000000000n,
acceptedAtoms: 500n,
askedSats: 500113n,
},
{
offeredAtoms: 1000n,
info: '1000sat/token, 1 accept',
priceNanoSatsPerAtom: 1000n * 1000000000n,
acceptedAtoms: 1n,
askedSats: 1001n,
},
{
offeredAtoms: 1000n,
info: '1000000sat/token, full accept',
priceNanoSatsPerAtom: 1000000n * 1000000000n,
acceptedAtoms: 1000n,
askedSats: 1000013824n,
},
{
offeredAtoms: 1000n,
info: '1000000sat/token, half accept',
priceNanoSatsPerAtom: 1000000n * 1000000000n,
acceptedAtoms: 500n,
askedSats: 500039680n,
},
{
offeredAtoms: 1000n,
info: '1000000sat/token, 1 accept',
priceNanoSatsPerAtom: 1000000n * 1000000000n,
acceptedAtoms: 1n,
askedSats: 1048576n,
},
{
offeredAtoms: 1000n,
info: '1000000000sat/token, 1 accept',
priceNanoSatsPerAtom: 1000000000n * 1000000000n,
acceptedAtoms: 1n,
askedSats: 1006632960n,
},
{
offeredAtoms: 1000000n,
info: '0.001sat/token, full accept',
priceNanoSatsPerAtom: 1000000n,
acceptedAtoms: 1000000n,
askedSats: 1000n,
},
{
offeredAtoms: 1000000n,
info: '1sat/token, full accept',
priceNanoSatsPerAtom: 1000000000n,
acceptedAtoms: 1000000n,
askedSats: 1000000n,
},
{
offeredAtoms: 1000000n,
info: '1sat/token, half accept',
priceNanoSatsPerAtom: 1000000000n,
acceptedAtoms: 500000n,
askedSats: 500000n,
},
{
offeredAtoms: 1000000n,
info: '1sat/token, dust accept',
priceNanoSatsPerAtom: 1000000000n,
acceptedAtoms: 546n,
askedSats: 546n,
},
{
offeredAtoms: 1000000n,
info: '1000sat/token, full accept',
priceNanoSatsPerAtom: 1000n * 1000000000n,
acceptedAtoms: 999936n,
askedSats: 999948288n,
},
{
offeredAtoms: 1000000n,
info: '1000sat/token, half accept',
priceNanoSatsPerAtom: 1000n * 1000000000n,
acceptedAtoms: 499968n,
askedSats: 499974144n,
},
{
offeredAtoms: 1000000n,
info: '1000sat/token, 256 accept',
priceNanoSatsPerAtom: 1000n * 1000000000n,
acceptedAtoms: 256n,
askedSats: 262144n,
},
{
offeredAtoms: 1000000n,
info: '1000000sat/token, 1024 accept',
priceNanoSatsPerAtom: 1000000n * 1000000000n,
acceptedAtoms: 1024n,
askedSats: 1040187392n,
},
{
offeredAtoms: 1000000n,
info: '1000000sat/token, 256 accept',
priceNanoSatsPerAtom: 1000000n * 1000000000n,
acceptedAtoms: 256n,
askedSats: 268435456n,
},
{
offeredAtoms: 1000000000n,
info: '0.001sat/token, full accept',
priceNanoSatsPerAtom: 1000000n,
acceptedAtoms: 1000000000n,
askedSats: 1000000n,
},
{
offeredAtoms: 1000000000n,
info: '0.001sat/token, half accept',
priceNanoSatsPerAtom: 1000000n,
acceptedAtoms: 500000000n,
askedSats: 500000n,
},
{
offeredAtoms: 1000000000n,
info: '0.001sat/token, dust accept',
priceNanoSatsPerAtom: 1000000n,
acceptedAtoms: 546000n,
askedSats: 546n,
},
{
offeredAtoms: 1000000000n,
info: '1sat/token, full accept',
priceNanoSatsPerAtom: 1000000000n,
acceptedAtoms: 1000000000n,
askedSats: 1000000000n,
},
{
offeredAtoms: 1000000000n,
info: '1sat/token, half accept',
priceNanoSatsPerAtom: 1000000000n,
acceptedAtoms: 500000000n,
askedSats: 500000000n,
},
{
offeredAtoms: 1000000000n,
info: '1sat/token, dust accept',
priceNanoSatsPerAtom: 1000000000n,
acceptedAtoms: 546n,
askedSats: 546n,
},
{
offeredAtoms: 1000000000n,
info: '1000sat/token, 983040 accept',
priceNanoSatsPerAtom: 1000n * 1000000000n,
acceptedAtoms: 983040n,
askedSats: 989855744n,
},
{
offeredAtoms: 1000000000n,
info: '1000sat/token, 65536 accept',
priceNanoSatsPerAtom: 1000n * 1000000000n,
acceptedAtoms: 65536n,
askedSats: 67108864n,
},
{
offeredAtoms: 1000000000000n,
info: '0.000001sat/token, full accept',
priceNanoSatsPerAtom: 1000n,
acceptedAtoms: 999999995904n,
askedSats: 1000108n,
},
{
offeredAtoms: 1000000000000n,
info: '0.000001sat/token, half accept',
priceNanoSatsPerAtom: 1000n,
acceptedAtoms: 546045952n,
askedSats: 547n,
},
{
offeredAtoms: 1000000000000n,
info: '0.001sat/token, full accept',
priceNanoSatsPerAtom: 1000000n,
acceptedAtoms: 999999995904n,
askedSats: 1068115230n,
},
{
offeredAtoms: 1000000000000n,
info: '0.001sat/token, dust accept',
priceNanoSatsPerAtom: 1000000n,
acceptedAtoms: 589824n,
askedSats: 630n,
},
{
offeredAtoms: 1000000000000000n,
info: '0.000000001sat/token, full accept',
priceNanoSatsPerAtom: 1n,
acceptedAtoms: 999999986991104n,
askedSats: 1000358n,
},
{
offeredAtoms: 1000000000000000n,
info: '0.000000001sat/token, dust accept',
priceNanoSatsPerAtom: 1n,
acceptedAtoms: 546014494720n,
askedSats: 547n,
},
{
offeredAtoms: 1000000000000000n,
info: '0.000001sat/token, full accept',
priceNanoSatsPerAtom: 1000n,
acceptedAtoms: 999999986991104n,
askedSats: 1072883592n,
},
{
offeredAtoms: 1000000000000000n,
info: '0.000001sat/token, dust accept',
priceNanoSatsPerAtom: 1000n,
acceptedAtoms: 570425344n,
askedSats: 612n,
},
{
offeredAtoms: 1000000000000000n,
info: '0.001sat/token, 1/1000 accept',
priceNanoSatsPerAtom: 1000000n,
acceptedAtoms: 999989182464n,
askedSats: 1004470272n,
},
{
offeredAtoms: 1000000000000000n,
info: '0.001sat/token, min accept',
priceNanoSatsPerAtom: 1000000n,
acceptedAtoms: 0x1000000n,
askedSats: 65536n,
},
{
offeredAtoms: 1000000000000000n,
info: '1sat/token, 1/1000000 accept',
priceNanoSatsPerAtom: 1000000000n,
acceptedAtoms: 989855744n,
askedSats: 989855744n,
},
{
offeredAtoms: 1000000000000000n,
info: '1sat/token, min accept',
priceNanoSatsPerAtom: 1000000000n,
acceptedAtoms: 0x1000000n,
askedSats: 16777216n,
},
{
offeredAtoms: 1000000000000000000n,
info: '0.000000001sat/token, full accept',
priceNanoSatsPerAtom: 1n,
acceptedAtoms: 999999997191651328n,
askedSats: 1047737894n,
},
{
offeredAtoms: 1000000000000000000n,
info: '0.000000001sat/token, dust accept',
priceNanoSatsPerAtom: 1n,
acceptedAtoms: 558345748480n,
askedSats: 585n,
},
{
offeredAtoms: 1000000000000000000n,
info: '0.000001sat/token, 1/1000 accept',
priceNanoSatsPerAtom: 1000n,
acceptedAtoms: 999997235527680n,
askedSats: 1002438656n,
},
{
offeredAtoms: 1000000000000000000n,
info: '0.000001sat/token, min accept',
priceNanoSatsPerAtom: 1000n,
acceptedAtoms: 0x100000000n,
askedSats: 65536n,
},
{
offeredAtoms: 1000000000000000000n,
info: '0.001sat/token, 1/1000000 accept',
priceNanoSatsPerAtom: 1000000n,
acceptedAtoms: 996432412672n,
askedSats: 1006632960n,
},
{
offeredAtoms: 1000000000000000000n,
info: '0.001sat/token, min accept',
priceNanoSatsPerAtom: 1000000n,
acceptedAtoms: 0x100000000n,
askedSats: 16777216n,
},
{
offeredAtoms: 0x7fffffffffffffffn,
info: '0.000000001sat/token, max sats accept',
priceNanoSatsPerAtom: 1n,
acceptedAtoms: 999999997191651328n,
askedSats: 1010248448n,
},
{
offeredAtoms: 0x7fffffffffffffffn,
info: '0.000000001sat/token, dust accept',
priceNanoSatsPerAtom: 1n,
acceptedAtoms: 558345748480n,
askedSats: 768n,
},
{
offeredAtoms: 0x7fffffffffffffffn,
info: '0.000001sat/token, max sats accept',
priceNanoSatsPerAtom: 1000n,
acceptedAtoms: 999997235527680n,
askedSats: 1017249792n,
},
{
offeredAtoms: 0x7fffffffffffffffn,
info: '0.000001sat/token, min accept',
priceNanoSatsPerAtom: 1000n,
acceptedAtoms: 0x100000000n,
askedSats: 65536n,
},
{
offeredAtoms: 0x7fffffffffffffffn,
info: '0.001sat/token, max sats accept',
priceNanoSatsPerAtom: 1000000n,
acceptedAtoms: 0xc300000000n,
askedSats: 1090519040n,
},
{
offeredAtoms: 0x7fffffffffffffffn,
info: '0.001sat/token, min accept',
priceNanoSatsPerAtom: 1000000n,
acceptedAtoms: 0x100000000n,
askedSats: 16777216n,
},
{
offeredAtoms: 0xffffffffffffffffn,
info: '0.000000001sat/token, max sats accept',
priceNanoSatsPerAtom: 1n,
acceptedAtoms: 999999228392505344n,
askedSats: 1027665664n,
},
{
offeredAtoms: 0xffffffffffffffffn,
info: '0.000000001sat/token, dust accept',
priceNanoSatsPerAtom: 1n,
acceptedAtoms: 0x10000000000n,
askedSats: 1280n,
},
{
offeredAtoms: 0xffffffffffffffffn,
info: '0.000001sat/token, max sats accept',
priceNanoSatsPerAtom: 1000n,
acceptedAtoms: 999456069648384n,
askedSats: 1089339392n,
},
{
offeredAtoms: 0xffffffffffffffffn,
info: '0.000001sat/token, min accept',
priceNanoSatsPerAtom: 1000n,
acceptedAtoms: 0x10000000000n,
askedSats: 1245184n,
},
];
for (const testCase of TEST_CASES) {
it(`AgoraPartial SLP ${testCase.offeredAtoms} for ${testCase.info}`, async () => {
const agora = new Agora(chronik);
const agoraPartial = await agora.selectParams({
offeredAtoms: testCase.offeredAtoms,
priceNanoSatsPerAtom: testCase.priceNanoSatsPerAtom,
minAcceptedAtoms: testCase.acceptedAtoms,
makerPk,
...BASE_PARAMS_SLP,
});
const askedSats = agoraPartial.askedSats(testCase.acceptedAtoms);
const requiredSats = askedSats + 2000n;
const [fuelInput, takerInput] = await makeBuilderInputs([
4000n,
requiredSats,
]);
const offer = await makeSlpOffer({
chronik,
agoraPartial,
makerSk,
fuelInput,
});
const acceptTxid = await takeSlpOffer({
chronik,
takerSk,
offer,
takerInput,
acceptedAtoms: testCase.acceptedAtoms,
allowUnspendable: testCase.allowUnspendable,
});
const acceptTx = await chronik.tx(acceptTxid);
const offeredAtoms = agoraPartial.offeredAtoms();
const isFullAccept = testCase.acceptedAtoms == offeredAtoms;
if (isFullAccept) {
// FULL ACCEPT
// 0th output is OP_RETURN SLP SEND
expect(acceptTx.outputs[0].outputScript).to.equal(
toHex(
slpSend(agoraPartial.tokenId, agoraPartial.tokenType, [
0n,
agoraPartial.offeredAtoms(),
]).bytecode,
),
);
// 1st output is sats to maker
expect(acceptTx.outputs[1].token).to.equal(undefined);
expect(acceptTx.outputs[1].sats).to.equal(testCase.askedSats);
expect(acceptTx.outputs[1].outputScript).to.equal(
makerScriptHex,
);
// 2nd output is tokens to taker
expect(acceptTx.outputs[2].token?.atoms).to.equal(offeredAtoms);
expect(acceptTx.outputs[2].sats).to.equal(DEFAULT_DUST_SATS);
expect(acceptTx.outputs[2].outputScript).to.equal(
takerScriptHex,
);
// Offer is now gone
const newOffers = await agora.activeOffersByTokenId(
offer.token.tokenId,
);
expect(newOffers).to.deep.equal([]);
return;
}
// PARTIAL ACCEPT
const leftoverTokens = offeredAtoms - testCase.acceptedAtoms;
const leftovertruncAtoms =
leftoverTokens >> BigInt(8 * agoraPartial.numAtomsTruncBytes);
// 0th output is OP_RETURN SLP SEND
expect(acceptTx.outputs[0].outputScript).to.equal(
toHex(
slpSend(agoraPartial.tokenId, agoraPartial.tokenType, [
0n,
leftoverTokens,
testCase.acceptedAtoms,
]).bytecode,
),
);
// 1st output is sats to maker
expect(acceptTx.outputs[1].token).to.equal(undefined);
expect(acceptTx.outputs[1].sats).to.equal(testCase.askedSats);
expect(acceptTx.outputs[1].outputScript).to.equal(makerScriptHex);
// 2nd output is back to the P2SH Script
expect(acceptTx.outputs[2].token?.atoms).to.equal(leftoverTokens);
expect(acceptTx.outputs[2].sats).to.equal(DEFAULT_DUST_SATS);
expect(acceptTx.outputs[2].outputScript.slice(0, 4)).to.equal(
'a914',
);
// 3rd output is tokens to taker
expect(acceptTx.outputs[3].token?.atoms).to.equal(
testCase.acceptedAtoms,
);
expect(acceptTx.outputs[3].sats).to.equal(DEFAULT_DUST_SATS);
expect(acceptTx.outputs[3].outputScript).to.equal(takerScriptHex);
// Offer is now modified
const newOffers = await agora.activeOffersByTokenId(
offer.token.tokenId,
);
expect(newOffers.length).to.equal(1);
const newOffer = newOffers[0];
expect(newOffer.variant).to.deep.equal({
type: 'PARTIAL',
params: new AgoraPartial({
...agoraPartial,
truncAtoms: leftovertruncAtoms,
}),
});
// Cancel leftover offer
const cancelFeeSats = newOffer.cancelFeeSats({
recipientScript: makerScript,
extraInputs: [fuelInput], // dummy input for measuring
});
const cancelTxSer = newOffer
.cancelTx({
cancelSk: makerSk,
fuelInputs: await makeBuilderInputs([cancelFeeSats]),
recipientScript: makerScript,
})
.ser();
const cancelTxid = (await chronik.broadcastTx(cancelTxSer)).txid;
const cancelTx = await chronik.tx(cancelTxid);
expect(cancelTx.outputs[1].token?.atoms).to.equal(leftoverTokens);
expect(cancelTx.outputs[1].outputScript).to.equal(makerScriptHex);
// takerIndex is 2 for full accept, 3 for partial accept
const takerIndex = isFullAccept ? 2 : 3;
// Get takenInfo from offer creation params
const takenInfo: TakenInfo = {
sats: BigInt(testCase.askedSats),
takerScriptHex: acceptTx.outputs[takerIndex].outputScript,
atoms: testCase.acceptedAtoms,
};
// Tx history by token ID
const offers = [
{
...offer,
status: 'TAKEN',
takenInfo,
},
{
...newOffer,
status: 'CANCELED',
},
];
const actualOffers = await agora.historicOffers({
type: 'TOKEN_ID',
tokenId: offer.token.tokenId,
table: 'UNCONFIRMED',
});
if (offers[0].status !== actualOffers.offers[0].status) {
offers.reverse();
}
expect(actualOffers).to.deep.equal({
offers,
numTxs: 3,
numPages: 1,
});
});
}
it('Without manually setting an over-ride, we are unable to accept an agora partial if the remaining offer would be unacceptable due to the terms of the contract', async () => {
const thisTestCase: TestCase = {
offeredAtoms: 1000n,
info: '1sat/token, dust accept, must allowUnspendable',
priceNanoSatsPerAtom: 1000000000n,
acceptedAtoms: 546n,
askedSats: 546n,
allowUnspendable: true,
};
const agora = new Agora(chronik);
const agoraPartial = await agora.selectParams({
offeredAtoms: thisTestCase.offeredAtoms,
priceNanoSatsPerAtom: thisTestCase.priceNanoSatsPerAtom,
minAcceptedAtoms: thisTestCase.acceptedAtoms,
makerPk,
...BASE_PARAMS_SLP,
});
const askedSats = agoraPartial.askedSats(thisTestCase.acceptedAtoms);
const requiredSats = askedSats + 2000n;
const [fuelInput, takerInput] = await makeBuilderInputs([
4000n,
requiredSats,
]);
const offer = await makeSlpOffer({
chronik,
agoraPartial,
makerSk,
fuelInput,
});
const expectedError =
'Accepting 546 token satoshis would leave an amount lower than the min acceptable by the terms of this contract, and hence unacceptable. Accept fewer tokens or the full offer.';
// We can get the error from this isolated method
expect(() =>
agoraPartial.preventUnacceptableRemainder(
thisTestCase.acceptedAtoms,
),
).to.throw(Error, expectedError);
// Or by attempting to accept the offer
await assert.isRejected(
takeSlpOffer({
chronik,
takerSk,
offer,
takerInput,
acceptedAtoms: thisTestCase.acceptedAtoms,
allowUnspendable: false,
}),
expectedError,
);
});
it('Without manually setting an over-ride, we are unable to accept an agora partial if the remaining offer would be unacceptable due to a price less than dust', async () => {
// ecash-agora does not support creating an agora partial with min accept amount priced less than dust
// from the approximateParams method
// However we can still do this if we manually create a new AgoraPartial
// I think it is okay to preserve this, as the protocol does technically allow it,
// and perhaps a power user wants to do this for some reason
// Manually build an offer equivalent to previous test but accepting 500 tokens
const agoraPartial = new AgoraPartial({
truncAtoms: 1000n,
numAtomsTruncBytes: 0,
atomsScaleFactor: 2145336n,
scaledTruncAtomsPerTruncSat: 2145336n,
numSatsTruncBytes: 0,
minAcceptedScaledTruncAtoms: 1072668000n,
scriptLen: 216,
enforcedLockTime: 1087959628,
makerPk,
...BASE_PARAMS_SLP,
});
const acceptedAtoms = 500n;
const askedSats = agoraPartial.askedSats(acceptedAtoms);
const requiredSats = askedSats + 2000n;
const [fuelInput, takerInput] = await makeBuilderInputs([
4000n,
requiredSats,
]);
const offer = await makeSlpOffer({
chronik,
agoraPartial,
makerSk,
fuelInput,
});
const expectedError =
'Accepting 500 token satoshis would leave an amount priced lower than dust. Accept fewer tokens or the full offer.';
// We can get the error from this isolated method
expect(() =>
agoraPartial.preventUnacceptableRemainder(acceptedAtoms),
).to.throw(Error, expectedError);
// Or by attempting to accept the offer
await assert.isRejected(
takeSlpOffer({
chronik,
takerSk,
offer,
takerInput,
acceptedAtoms: acceptedAtoms,
allowUnspendable: false,
}),
expectedError,
);
// We also check if allowUnspendable is specified as undefined
await assert.isRejected(
takeSlpOffer({
chronik,
takerSk,
offer,
takerInput,
acceptedAtoms: acceptedAtoms,
allowUnspendable: undefined,
}),
expectedError,
);
// And if the user simply omits the allowUnspendable param
await assert.isRejected(
takeSlpOffer({
chronik,
takerSk,
offer,
takerInput,
acceptedAtoms: acceptedAtoms,
}),
expectedError,
);
});
});