diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.ts index 366fa00d..e52b69e9 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.ts @@ -87,7 +87,12 @@ export async function assembleReviewedTransaction( }; } - if (review.outputs.length === 0) { + // An empty `outputs` is only a document fault when nothing will be paid at all. + // A policy-asset "change" output is deliberately absent from `review.outputs` — + // its value is computed by `builder.addChange()` further down — so an action whose + // only output is change (e.g. a full withdrawal that sends the whole remaining + // balance to the beneficiary) pays perfectly well and must not be refused here. + if (review.outputs.length === 0 && !review.changeDeferred) { return { ok: false, reason: `"${review.action}" pays nothing, so there is nothing to build.`, diff --git a/packages/tx-manifest/src/review/covenantSpend.test.ts b/packages/tx-manifest/src/review/covenantSpend.test.ts index 9e3f6c50..eb6a72dd 100644 --- a/packages/tx-manifest/src/review/covenantSpend.test.ts +++ b/packages/tx-manifest/src/review/covenantSpend.test.ts @@ -221,6 +221,42 @@ describe("the transaction-level facts a covenant spend needs", () => { test("carries none where the action declares none", async () => { expect((await reviewed()).sequence).toBeUndefined(); }); + + test("carries a relative timelock where the covenant input is the only input", async () => { + const inputs = receiveInputs(); + + inputs[0] = { ...inputs[0], sequence: { relative_blocks: 10 } }; + + const outputs = [ + { + amount_sat: 40_000, + asset: "lbtc", + description: "The reclaimed funds, sent to your wallet.", + destination: "wallet", + id: "received_out", + }, + ]; + const review = await reviewed(spendRequest(withReceive([inputs[0]], outputs))); + + expect(review.sequence).toBe(10); + expect(review.selected).toHaveLength(0); + expect(review.inputOrder).toHaveLength(1); + }); + + test("refuses a relative timelock a wallet fee input would inherit", async () => { + const inputs = receiveInputs(); + + inputs[0] = { ...inputs[0], sequence: { relative_blocks: 10 } }; + + const result = await reviewManifestAction(spendRequest(withReceiveInputs(inputs)), deps); + + expect(isRefusal(result)).toBe(true); + + if (isRefusal(result)) { + expect(result.reject).toBe("unimplemented-construct"); + expect(result.reason).toContain("would inherit that wait"); + } + }); }); describe("a covenant input that issues an asset", () => { diff --git a/packages/tx-manifest/src/review/index.ts b/packages/tx-manifest/src/review/index.ts index dea5525b..f1ef29f2 100644 --- a/packages/tx-manifest/src/review/index.ts +++ b/packages/tx-manifest/src/review/index.ts @@ -101,6 +101,7 @@ export type AssetMovement = { export type ManifestReview = { action: string; changeBlinded: boolean; + changeDeferred?: boolean; changeBlindedBy?: BlindingWord; changeOverrode?: BlindingWord; boundTo?: string; @@ -624,6 +625,13 @@ export async function reviewManifestAction( const outputAt = new Map(); const returned = new Map(); const changePaid = new Set(); + // Set when a policy-asset "change" output is left out of `outputs` below for the + // builder's `addChange()` to handle. Without this, an action whose only output is + // such a change output looks like an action with no outputs at all. + let changeDeferred = false; + + for (const [at, planned] of plan.plan.outputs.entries()) { + const asset = ledger.outputs[at] ?? policyAsset; for (const [at, planned] of plan.plan.outputs.entries()) { const asset = ledger.outputs[at] ?? policyAsset; @@ -635,6 +643,10 @@ export async function reviewManifestAction( : (fundedFor.get(asset)?.changeSats ?? 0n); if (surplus <= 0n) { + if (asset === policyAsset) { + changeDeferred = true; + } + continue; } @@ -861,6 +873,7 @@ export async function reviewManifestAction( action: request.action, ...(action.boundTo === undefined ? {} : { boundTo: action.boundTo }), changeBlinded: change.blinding === "blinded", + ...(changeDeferred ? { changeDeferred: true } : {}), ...(change.blinding === "blinded" ? { changeBlindedBy: change.decidedBy } : change.overrode === undefined