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,48 @@ `Must acceptedTokens must be a multiple of ${truncFactor}`, ); } + // Validation to avoid creating an unspendable offer + // + // 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 = BigInt(this.token.amount); + if ( + typeof params.allowUnspendable !== 'undefined' && + params.allowUnspendable === false + ) { + const remainingTokens = + offeredTokens - params.acceptedTokens; + if (remainingTokens > 0n) { + // Full accepts are always ok + + const minAcceptedTokens = + agoraPartial.minAcceptedTokens(); + const priceOfRemainingTokens = + agoraPartial.askedSats(remainingTokens); + if (remainingTokens < minAcceptedTokens) { + throw new Error( + `Accepting ${params.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 < agoraPartial.dustAmount) { + throw new Error( + `Accepting ${params.acceptedTokens} token satoshis would leave an amount priced lower than dust. Accept fewer tokens or the full offer.`, + ); + } + } + } + txBuild.inputs.push({ input: this.txBuilderInput, signatory: AgoraPartialSignatory( @@ -243,7 +290,7 @@ }); txBuild.inputs.push(...params.fuelInputs); const sendAmounts: Amount[] = [0]; - const offeredTokens = BigInt(this.token.amount); + if (offeredTokens > params.acceptedTokens) { sendAmounts.push(offeredTokens - params.acceptedTokens); } 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, @@ -405,6 +407,7 @@ priceNanoSatsPerToken: 1000n, acceptedTokens: 0x1e000000n, askedSats: 549, + allowUnspendable: true, }, { offeredTokens: 0xffffffffffffn, @@ -467,7 +470,24 @@ offer, takerInput, acceptedTokens: testCase.acceptedTokens, + allowUnspendable: testCase.allowUnspendable, }); + if (testCase.allowUnspendable) { + // 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: testCase.acceptedTokens, + allowUnspendable: false, + }), + `Accepting ${testCase.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.`, + ); + } const acceptTx = await chronik.tx(acceptTxid); const offeredTokens = agoraPartial.offeredTokens(); if (testCase.acceptedTokens == offeredTokens) { 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[] = [ { @@ -117,6 +118,7 @@ priceNanoSatsPerToken: 1000000000n, acceptedTokens: 546n, askedSats: 546, + allowUnspendable: true, }, { offeredTokens: 1000n, @@ -194,6 +196,7 @@ priceNanoSatsPerToken: 1000000000n, acceptedTokens: 546n, askedSats: 546, + allowUnspendable: true, }, { offeredTokens: 1000000n, @@ -522,7 +525,24 @@ offer, takerInput, acceptedTokens: testCase.acceptedTokens, + allowUnspendable: testCase.allowUnspendable, }); + if (testCase.allowUnspendable) { + // 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( + takeSlpOffer({ + chronik, + ecc, + takerSk, + offer, + takerInput, + acceptedTokens: testCase.acceptedTokens, + allowUnspendable: false, + }), + `Accepting ${testCase.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.`, + ); + } const acceptTx = await chronik.tx(acceptTxid); const offeredTokens = agoraPartial.offeredTokens(); if (testCase.acceptedTokens == offeredTokens) {