diff --git a/modules/ecash-agora/src/agora.ts b/modules/ecash-agora/src/agora.ts --- a/modules/ecash-agora/src/agora.ts +++ b/modules/ecash-agora/src/agora.ts @@ -140,9 +140,12 @@ dustAmount?: number; /** Fee per kB to use when building the tx. */ feePerKb?: number; + /** Allow accepting an offer such that the remaining quantity is unacceptable */ + allowUnspendable?: boolean; }): Tx { const dustAmount = params.dustAmount ?? DEFAULT_DUST_LIMIT; const feePerKb = params.feePerKb ?? DEFAULT_FEE_PER_KB; + const allowUnspendable = params.allowUnspendable ?? false; const txBuild = this._acceptTxBuilder({ covenantSk: params.covenantSk, covenantPk: params.covenantPk, @@ -155,6 +158,7 @@ params.recipientScript, ], acceptedTokens: params.acceptedTokens, + allowUnspendable, }); return txBuild.sign(params.ecc, feePerKb, dustAmount); } @@ -197,6 +201,7 @@ fuelInputs: TxBuilderInput[]; extraOutputs: TxBuilderOutput[]; acceptedTokens?: bigint; + allowUnspendable?: boolean; }): TxBuilder { switch (this.variant.type) { case 'ONESHOT': @@ -232,6 +237,17 @@ `Must acceptedTokens must be a multiple of ${truncFactor}`, ); } + + if ( + params.allowUnspendable === false || + typeof params.allowUnspendable === 'undefined' + ) { + // Prevent creation of unacceptable offer + agoraPartial.preventUnacceptableRemainder( + params.acceptedTokens, + ); + } + txBuild.inputs.push({ input: this.txBuilderInput, signatory: AgoraPartialSignatory( diff --git a/modules/ecash-agora/src/partial.ts b/modules/ecash-agora/src/partial.ts --- a/modules/ecash-agora/src/partial.ts +++ b/modules/ecash-agora/src/partial.ts @@ -547,6 +547,48 @@ return askedTruncSats << numSatsTruncBits; } + /** + * Throw an error if accept amount is invalid + * Note we do not prepare amounts in this function + * @param acceptedTokens + */ + public preventUnacceptableRemainder(acceptedTokens: bigint) { + // Validation to avoid creating an offer that cannot be accepted + // + // 1 - confirm the remaining offer amount is more than the + // min accept amount for this agora partial + // + // 2 - Confirm the cost of accepting the (full) remainder is + // at least dust. This is already confirmed...for offers + // created by this lib... as minAcceptedTokens() must + // cost more than dust + // + // + // If these condtions are not met, an AgoraOffer would be created + // that is impossible to accept; can only be canceld by its maker + + // Get the token qty that would remain after this accept + const offeredTokens = this.offeredTokens(); + const remainingTokens = offeredTokens - acceptedTokens; + if (remainingTokens <= 0n) { + return; + } + // Full accepts are always ok + + const minAcceptedTokens = this.minAcceptedTokens(); + const priceOfRemainingTokens = this.askedSats(remainingTokens); + if (remainingTokens < minAcceptedTokens) { + throw new Error( + `Accepting ${acceptedTokens} 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.`, + ); + } + if (priceOfRemainingTokens < this.dustAmount) { + throw new Error( + `Accepting ${acceptedTokens} token satoshis would leave an amount priced lower than dust. Accept fewer tokens or the full offer.`, + ); + } + } + /** * Prepare the given acceptedTokens amount for the Script; `acceptedTokens` * must have the lowest numTokenTruncBytes bytes set to 0 and this function diff --git a/modules/ecash-agora/tests/partial-helper-alp.ts b/modules/ecash-agora/tests/partial-helper-alp.ts --- a/modules/ecash-agora/tests/partial-helper-alp.ts +++ b/modules/ecash-agora/tests/partial-helper-alp.ts @@ -132,6 +132,7 @@ takerSk: Uint8Array; takerInput: TxBuilderInput; acceptedTokens: bigint; + allowUnspendable?: boolean; }) { const takerSk = params.takerSk; const takerPk = params.ecc.derivePubkey(takerSk); @@ -144,6 +145,7 @@ fuelInputs: [params.takerInput], recipientScript: takerP2pkh, acceptedTokens: params.acceptedTokens, + allowUnspendable: params.allowUnspendable, }); const acceptTxid = (await params.chronik.broadcastTx(acceptTx.ser())).txid; return acceptTxid; diff --git a/modules/ecash-agora/tests/partial-helper-slp.ts b/modules/ecash-agora/tests/partial-helper-slp.ts --- a/modules/ecash-agora/tests/partial-helper-slp.ts +++ b/modules/ecash-agora/tests/partial-helper-slp.ts @@ -136,6 +136,7 @@ takerSk: Uint8Array; takerInput: TxBuilderInput; acceptedTokens: bigint; + allowUnspendable?: boolean; }) { const takerSk = params.takerSk; const takerPk = params.ecc.derivePubkey(takerSk); @@ -148,6 +149,7 @@ fuelInputs: [params.takerInput], recipientScript: takerP2pkh, acceptedTokens: params.acceptedTokens, + allowUnspendable: params.allowUnspendable, }); const acceptTxid = (await params.chronik.broadcastTx(acceptTx.ser())).txid; return acceptTxid; diff --git a/modules/ecash-agora/tests/partial.alp.test.ts b/modules/ecash-agora/tests/partial.alp.test.ts --- a/modules/ecash-agora/tests/partial.alp.test.ts +++ b/modules/ecash-agora/tests/partial.alp.test.ts @@ -2,7 +2,7 @@ // 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 { expect, use, assert } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import { ChronikClient } from 'chronik-client'; import { @@ -103,6 +103,7 @@ priceNanoSatsPerToken: bigint; acceptedTokens: bigint; askedSats: number; + allowUnspendable?: boolean; } const TEST_CASES: TestCase[] = [ { @@ -118,6 +119,7 @@ priceNanoSatsPerToken: 1000000000n, acceptedTokens: 546n, askedSats: 546, + allowUnspendable: true, }, { offeredTokens: 1000n, @@ -460,6 +462,7 @@ offer, takerInput, acceptedTokens: testCase.acceptedTokens, + allowUnspendable: testCase.allowUnspendable, }); const acceptTx = await chronik.tx(acceptTxid); const offeredTokens = agoraPartial.offeredTokens(); @@ -601,4 +604,117 @@ }); }); } + 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 = { + offeredTokens: 1000n, + info: '1sat/token, dust accept', + priceNanoSatsPerToken: 1000000000n, + acceptedTokens: 546n, + askedSats: 546, + allowUnspendable: true, + }; + const agora = new Agora(chronik); + const agoraPartial = await agora.selectParams({ + offeredTokens: thisTestCase.offeredTokens, + priceNanoSatsPerToken: thisTestCase.priceNanoSatsPerToken, + minAcceptedTokens: thisTestCase.acceptedTokens, + makerPk, + ...BASE_PARAMS_ALP, + }); + const askedSats = agoraPartial.askedSats(thisTestCase.acceptedTokens); + const requiredSats = askedSats + 2000n; + const [fuelInput, takerInput] = await makeBuilderInputs([ + 4000, + Number(requiredSats), + ]); + + const offer = await makeAlpOffer({ + chronik, + ecc, + agoraPartial, + makerSk, + fuelInput, + }); + + const expectedError = `Accepting ${thisTestCase.acceptedTokens} 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.acceptedTokens, + ), + ).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, + ecc, + takerSk, + offer, + takerInput, + acceptedTokens: thisTestCase.acceptedTokens, + 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({ + truncTokens: 1000n, + numTokenTruncBytes: 0, + tokenScaleFactor: 2145336n, + scaledTruncTokensPerTruncSat: 2145336n, + numSatsTruncBytes: 0, + minAcceptedScaledTruncTokens: 1072668000n, + scriptLen: 209, + enforcedLockTime: 1333546081, + makerPk, + ...BASE_PARAMS_ALP, + }); + const acceptedTokens = 500n; + const askedSats = agoraPartial.askedSats(acceptedTokens); + const requiredSats = askedSats + 2000n; + const [fuelInput, takerInput] = await makeBuilderInputs([ + 4000, + Number(requiredSats), + ]); + + const offer = await makeAlpOffer({ + chronik, + ecc, + 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(acceptedTokens), + ).to.throw(Error, expectedError); + + // And from attempting to accept + await assert.isRejected( + takeAlpOffer({ + chronik, + ecc, + takerSk, + offer, + takerInput, + acceptedTokens: acceptedTokens, + allowUnspendable: false, + }), + expectedError, + ); + }); }); diff --git a/modules/ecash-agora/tests/partial.slp.test.ts b/modules/ecash-agora/tests/partial.slp.test.ts --- a/modules/ecash-agora/tests/partial.slp.test.ts +++ b/modules/ecash-agora/tests/partial.slp.test.ts @@ -2,7 +2,7 @@ // 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 { expect, use, assert } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import { ChronikClient } from 'chronik-client'; import { @@ -102,6 +102,7 @@ priceNanoSatsPerToken: bigint; acceptedTokens: bigint; askedSats: number; + allowUnspendable?: boolean; } const TEST_CASES: TestCase[] = [ { @@ -113,10 +114,11 @@ }, { offeredTokens: 1000n, - info: '1sat/token, dust accept', + info: '1sat/token, dust accept, must allowUnspendable', priceNanoSatsPerToken: 1000000000n, acceptedTokens: 546n, askedSats: 546, + allowUnspendable: true, }, { offeredTokens: 1000n, @@ -515,6 +517,7 @@ offer, takerInput, acceptedTokens: testCase.acceptedTokens, + allowUnspendable: testCase.allowUnspendable, }); const acceptTx = await chronik.tx(acceptTxid); const offeredTokens = agoraPartial.offeredTokens(); @@ -645,4 +648,146 @@ }); }); } + + 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 = { + offeredTokens: 1000n, + info: '1sat/token, dust accept, must allowUnspendable', + priceNanoSatsPerToken: 1000000000n, + acceptedTokens: 546n, + askedSats: 546, + allowUnspendable: true, + }; + const agora = new Agora(chronik); + const agoraPartial = await agora.selectParams({ + offeredTokens: thisTestCase.offeredTokens, + priceNanoSatsPerToken: thisTestCase.priceNanoSatsPerToken, + minAcceptedTokens: thisTestCase.acceptedTokens, + makerPk, + ...BASE_PARAMS_SLP, + }); + const askedSats = agoraPartial.askedSats(thisTestCase.acceptedTokens); + const requiredSats = askedSats + 2000n; + const [fuelInput, takerInput] = await makeBuilderInputs([ + 4000, + Number(requiredSats), + ]); + + const offer = await makeSlpOffer({ + chronik, + ecc, + 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.acceptedTokens, + ), + ).to.throw(Error, expectedError); + + // Or by attempting to accept the offer + await assert.isRejected( + takeSlpOffer({ + chronik, + ecc, + takerSk, + offer, + takerInput, + acceptedTokens: thisTestCase.acceptedTokens, + 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({ + truncTokens: 1000n, + numTokenTruncBytes: 0, + tokenScaleFactor: 2145336n, + scaledTruncTokensPerTruncSat: 2145336n, + numSatsTruncBytes: 0, + minAcceptedScaledTruncTokens: 1072668000n, + scriptLen: 216, + enforcedLockTime: 1087959628, + makerPk, + ...BASE_PARAMS_SLP, + }); + const acceptedTokens = 500n; + const askedSats = agoraPartial.askedSats(acceptedTokens); + const requiredSats = askedSats + 2000n; + const [fuelInput, takerInput] = await makeBuilderInputs([ + 4000, + Number(requiredSats), + ]); + + const offer = await makeSlpOffer({ + chronik, + ecc, + 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(acceptedTokens), + ).to.throw(Error, expectedError); + + // Or by attempting to accept the offer + await assert.isRejected( + takeSlpOffer({ + chronik, + ecc, + takerSk, + offer, + takerInput, + acceptedTokens: acceptedTokens, + allowUnspendable: false, + }), + expectedError, + ); + + // We also check if allowUnspendable is specified as undefined + await assert.isRejected( + takeSlpOffer({ + chronik, + ecc, + takerSk, + offer, + takerInput, + acceptedTokens: acceptedTokens, + allowUnspendable: undefined, + }), + expectedError, + ); + + // And if the user simply omits the allowUnspendable param + await assert.isRejected( + takeSlpOffer({ + chronik, + ecc, + takerSk, + offer, + takerInput, + acceptedTokens: acceptedTokens, + }), + expectedError, + ); + }); });