ecash-agora
Version:
Library for interacting with the eCash Agora protocol
714 lines (685 loc) • 25.3 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,
ALP_STANDARD,
DEFAULT_DUST_SATS,
Ecc,
P2PKHSignatory,
Script,
TxBuilderInput,
alpSend,
emppScript,
fromHex,
shaRmd160,
toHex,
} from 'ecash-lib';
import { TestRunner } from 'ecash-lib/dist/test/testRunner.js';
import { AgoraPartial } from '../src/partial.js';
import { makeAlpOffer, takeAlpOffer } from './partial-helper-alp.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_ALP = {
tokenId: '00'.repeat(32), // filled in later
tokenType: ALP_STANDARD,
tokenProtocol: 'ALP' 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 ALP', () => {
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',
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: 0x7fffffffffffn,
info: '0.000000001sat/token, full accept',
priceNanoSatsPerAtom: 1n,
acceptedAtoms: 0x7fffc4660000n,
askedSats: 140744n,
},
{
offeredAtoms: 0x7fffffffffffn,
info: '0.000000001sat/token, dust accept',
priceNanoSatsPerAtom: 1n,
acceptedAtoms: 0x7f1e660000n,
askedSats: 546n,
},
{
offeredAtoms: 0x7fffffffffffn,
info: '0.000001sat/token, full accept',
priceNanoSatsPerAtom: 1000n,
acceptedAtoms: 0x7ffffff10000n,
askedSats: 143165576n,
},
{
offeredAtoms: 0x7fffffffffffn,
info: '0.000001sat/token, dust accept',
priceNanoSatsPerAtom: 1000n,
acceptedAtoms: 0x1ffd0000n,
askedSats: 546n,
},
{
offeredAtoms: 0x7fffffffffffn,
info: '0.001sat/token, max sats accept',
priceNanoSatsPerAtom: 1000000n,
acceptedAtoms: 799999983616n,
askedSats: 1041666816n,
},
{
offeredAtoms: 0x7fffffffffffn,
info: '0.001sat/token, dust accept',
priceNanoSatsPerAtom: 1000000n,
acceptedAtoms: 0x70000n,
askedSats: 768n,
},
{
offeredAtoms: 0x7fffffffffffn,
info: '1sat/token, max sats accept',
priceNanoSatsPerAtom: 1000000000n,
acceptedAtoms: 999948288n,
askedSats: 999948288n,
},
{
offeredAtoms: 0x7fffffffffffn,
info: '1sat/token, min accept',
priceNanoSatsPerAtom: 1000000000n,
acceptedAtoms: 0x10000n,
askedSats: 0x10000n,
},
{
offeredAtoms: 0xffffffffffffn,
info: '0.000000001sat/token, full accept',
priceNanoSatsPerAtom: 1n,
acceptedAtoms: 0xffffff000000n,
askedSats: 281505n,
},
{
offeredAtoms: 0xffffffffffffn,
info: '0.000000001sat/token, dust accept',
priceNanoSatsPerAtom: 1n,
acceptedAtoms: 0x7f1c000000n,
askedSats: 546n,
},
{
offeredAtoms: 0xffffffffffffn,
info: '0.000001sat/token, full accept',
priceNanoSatsPerAtom: 1000n,
acceptedAtoms: 0xffffff000000n,
askedSats: 306783360n,
},
{
offeredAtoms: 0xffffffffffffn,
info: '0.000001sat/token, dust accept',
priceNanoSatsPerAtom: 1000n,
acceptedAtoms: 0x1e000000n,
askedSats: 549n,
},
{
offeredAtoms: 0xffffffffffffn,
info: '0.001sat/token, max sats accept',
priceNanoSatsPerAtom: 1000000n,
acceptedAtoms: 0x8000000000n,
askedSats: 1073741824n,
},
{
offeredAtoms: 0xffffffffffffn,
info: '0.001sat/token, min accept',
priceNanoSatsPerAtom: 1000000n,
acceptedAtoms: 0x1000000n,
askedSats: 32768n,
},
{
offeredAtoms: 0xffffffffffffn,
info: '1sat/token, max sats accept',
priceNanoSatsPerAtom: 1000000000n,
acceptedAtoms: 989855744n,
askedSats: 989855744n,
},
{
offeredAtoms: 0xffffffffffffn,
info: '1sat/token, min accept',
priceNanoSatsPerAtom: 1000000000n,
acceptedAtoms: 0x1000000n,
askedSats: 0x1000000n,
},
];
for (const testCase of TEST_CASES) {
it(`AgoraPartial ALP ${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_ALP,
});
const askedSats = agoraPartial.askedSats(testCase.acceptedAtoms);
const requiredSats = askedSats + 2000n;
const [fuelInput, takerInput] = await makeBuilderInputs([
4000n,
requiredSats,
]);
const offer = await makeAlpOffer({
chronik,
agoraPartial,
makerSk,
fuelInput,
});
const acceptTxid = await takeAlpOffer({
chronik,
takerSk,
offer,
takerInput,
acceptedAtoms: testCase.acceptedAtoms,
allowUnspendable: testCase.allowUnspendable,
});
const acceptTx = await chronik.tx(acceptTxid);
// TODO we do not even get here, keep debugging
const offeredAtoms = agoraPartial.offeredAtoms();
const isFullAccept = testCase.acceptedAtoms == offeredAtoms;
if (isFullAccept) {
// FULL ACCEPT
// 0th output is OP_RETURN eMPP AGR0 ad + ALP SEND
expect(acceptTx.outputs[0].outputScript).to.equal(
toHex(
emppScript([
agoraPartial.adPushdata(),
alpSend(
agoraPartial.tokenId,
agoraPartial.tokenType,
[0n, agoraPartial.offeredAtoms()],
),
]).bytecode,
),
);
expect(acceptTx.outputs[0].sats).to.equal(0n);
expect(acceptTx.outputs[0].token).to.equal(undefined);
// 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 eMPP AGR0 ad + ALP SEND
expect(acceptTx.outputs[0].outputScript).to.equal(
toHex(
emppScript([
agoraPartial.adPushdata(),
alpSend(agoraPartial.tokenId, agoraPartial.tokenType, [
0n,
leftoverTokens,
testCase.acceptedAtoms,
]),
]).bytecode,
),
);
expect(acceptTx.outputs[0].sats).to.equal(0n);
expect(acceptTx.outputs[0].token).to.equal(undefined);
// 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',
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_ALP,
});
const askedSats = agoraPartial.askedSats(thisTestCase.acceptedAtoms);
const requiredSats = askedSats + 2000n;
const [fuelInput, takerInput] = await makeBuilderInputs([
4000n,
requiredSats,
]);
const offer = await makeAlpOffer({
chronik,
agoraPartial,
makerSk,
fuelInput,
});
const expectedError = `Accepting ${thisTestCase.acceptedAtoms} 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 the isolated method
expect(() =>
agoraPartial.preventUnacceptableRemainder(
thisTestCase.acceptedAtoms,
),
).to.throw(Error, expectedError);
// We get an error for test cases that would result in unspendable amounts
// if we do not pass allowUnspendable to agoraOffer.acceptTx
await assert.isRejected(
takeAlpOffer({
chronik,
takerSk,
offer,
takerInput,
acceptedAtoms: thisTestCase.acceptedAtoms,
allowUnspendable: false,
}),
expectedError,
);
// We can estimate the fee without this error, even though the offer is unacceptable
expect(
offer.acceptFeeSats({
recipientScript: offer.txBuilderInput.signData
?.redeemScript as Script,
acceptedAtoms: thisTestCase.acceptedAtoms,
}),
).to.equal(1725n);
});
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: 209,
enforcedLockTime: 1333546081,
makerPk,
...BASE_PARAMS_ALP,
});
const acceptedAtoms = 500n;
const askedSats = agoraPartial.askedSats(acceptedAtoms);
const requiredSats = askedSats + 2000n;
const [fuelInput, takerInput] = await makeBuilderInputs([
4000n,
requiredSats,
]);
const offer = await makeAlpOffer({
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 the isolated method
expect(() =>
agoraPartial.preventUnacceptableRemainder(acceptedAtoms),
).to.throw(Error, expectedError);
// And from attempting to accept
await assert.isRejected(
takeAlpOffer({
chronik,
takerSk,
offer,
takerInput,
acceptedAtoms: acceptedAtoms,
allowUnspendable: false,
}),
expectedError,
);
});
});