Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
Expand Down
36 changes: 36 additions & 0 deletions packages/tx-manifest/src/review/covenantSpend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
13 changes: 13 additions & 0 deletions packages/tx-manifest/src/review/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export type AssetMovement = {
export type ManifestReview = {
action: string;
changeBlinded: boolean;
changeDeferred?: boolean;
changeBlindedBy?: BlindingWord;
changeOverrode?: BlindingWord;
boundTo?: string;
Expand Down Expand Up @@ -624,6 +625,13 @@ export async function reviewManifestAction(
const outputAt = new Map<string, number>();
const returned = new Map<string, bigint>();
const changePaid = new Set<string>();
// 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;
Expand All @@ -635,6 +643,10 @@ export async function reviewManifestAction(
: (fundedFor.get(asset)?.changeSats ?? 0n);

if (surplus <= 0n) {
if (asset === policyAsset) {
changeDeferred = true;
}

continue;
}

Expand Down Expand Up @@ -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
Expand Down