From 68457f183d7910ab095c720db5e4f9e07ef3f4e9 Mon Sep 17 00:00:00 2001 From: lukachi Date: Wed, 2 Sep 2026 17:45:15 +0300 Subject: [PATCH] feat(tx-manifest): plan assets, issuance and blinding --- .../smplx/assembleReviewedTransaction.test.ts | 478 ++++++++++- .../smplx/assembleReviewedTransaction.ts | 262 +++++- .../adapters/smplx/loadSmplxWasm.test.ts | 126 +++ .../src/__fixtures__/multiasset.manifest.json | 95 +++ packages/tx-manifest/src/chain/bytes.ts | 25 + packages/tx-manifest/src/chain/chainRead.ts | 12 + .../tx-manifest/src/chain/issuance.test.ts | 123 +++ packages/tx-manifest/src/chain/issuance.ts | 165 ++++ packages/tx-manifest/src/chain/outpoint.ts | 52 ++ .../tx-manifest/src/document/asset.test.ts | 57 ++ packages/tx-manifest/src/document/asset.ts | 61 ++ .../src/document/references.test.ts | 19 +- .../tx-manifest/src/document/references.ts | 68 +- packages/tx-manifest/src/document/sites.ts | 18 +- .../src/evaluation/assetLedger.test.ts | 218 +++++ .../tx-manifest/src/evaluation/assetLedger.ts | 291 +++++++ .../src/evaluation/blinding.test.ts | 102 +++ .../tx-manifest/src/evaluation/blinding.ts | 112 +++ .../src/evaluation/issuance.test.ts | 177 ++++ .../tx-manifest/src/evaluation/issuance.ts | 222 +++++ .../tx-manifest/src/evaluation/plan.test.ts | 20 +- packages/tx-manifest/src/evaluation/plan.ts | 70 +- .../src/review/assetFunding.test.ts | 171 ++++ .../tx-manifest/src/review/assetFunding.ts | 158 ++++ .../src/review/classAction.test.ts | 32 +- .../src/review/coinSelection.test.ts | 99 +++ .../tx-manifest/src/review/coinSelection.ts | 40 +- packages/tx-manifest/src/review/index.test.ts | 24 +- packages/tx-manifest/src/review/index.ts | 565 +++++++++++- .../tx-manifest/src/review/multiAsset.test.ts | 805 ++++++++++++++++++ 30 files changed, 4544 insertions(+), 123 deletions(-) create mode 100644 packages/tx-manifest/src/__fixtures__/multiasset.manifest.json create mode 100644 packages/tx-manifest/src/chain/bytes.ts create mode 100644 packages/tx-manifest/src/chain/issuance.test.ts create mode 100644 packages/tx-manifest/src/chain/issuance.ts create mode 100644 packages/tx-manifest/src/chain/outpoint.ts create mode 100644 packages/tx-manifest/src/document/asset.test.ts create mode 100644 packages/tx-manifest/src/document/asset.ts create mode 100644 packages/tx-manifest/src/evaluation/assetLedger.test.ts create mode 100644 packages/tx-manifest/src/evaluation/assetLedger.ts create mode 100644 packages/tx-manifest/src/evaluation/blinding.test.ts create mode 100644 packages/tx-manifest/src/evaluation/blinding.ts create mode 100644 packages/tx-manifest/src/evaluation/issuance.test.ts create mode 100644 packages/tx-manifest/src/evaluation/issuance.ts create mode 100644 packages/tx-manifest/src/review/assetFunding.test.ts create mode 100644 packages/tx-manifest/src/review/assetFunding.ts create mode 100644 packages/tx-manifest/src/review/multiAsset.test.ts diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.test.ts index 8a3fc95..f781c10 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.test.ts @@ -7,8 +7,6 @@ import { type AssemblingBuilder, assembleReviewedTransaction, } from "./assembleReviewedTransaction"; -import type { SmplxWasmModule } from "./loadSmplxWasm"; - // A substitute rather than the real module, because what is under test is what this assembles // and what it releases, not what the module makes of it. Its method names and shapes are the // real binding's — `loadSmplxWasm.test.ts` is what holds that claim true — so a substitute that @@ -39,37 +37,106 @@ const TXOUT_HEX = `01${"49".repeat(32)}0100000000000186a000160014${"00".repeat(2 type Recorded = { changes: { blindingKey: string | null | undefined; script: string }[]; freed: number; - outputs: { asset: string; sats: bigint; script: string }[]; + /** How many issuance reports were released, which must match how many were handed over. */ + freedReports: number; + issues: { + assetAmountSats: bigint; + inflationAmountSats: bigint; + issuerContractHex: string | undefined; + txOut: string; + txid: string; + vout: number; + }[]; + outputs: { + asset: string; + blindingKey: string | null | undefined; + sats: bigint; + script: string; + }[]; spends: { txOut: string; txid: string; vout: number }[]; }; +/** What the derivation in the review says, so a substitute can agree with it or not. */ +const ISSUED = { + asset: "ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2", + entropy: "a".repeat(64), + reissuanceToken: "59fe4d2127ba9f16bd6850a3e6271a166e7ed2e1669f6c107d655791c94ee98f", +}; + +const ISSUANCE_TXID = "c".repeat(64); + +/** One planned issuance derived from the wallet output the review selected. */ +const plannedIssuance = () => ({ + asset: ISSUED.asset, + assetAmountSats: 1000n, + entropy: ISSUED.entropy, + inflationAmountSats: 0n, + inputId: "mint_in", + kind: "new" as const, + outpoint: { txid: ISSUANCE_TXID, vout: 0 }, + reissuanceToken: ISSUED.reissuanceToken, +}); + // Narrow — it stands in for the four methods this module calls and nothing else — but exact // for each of them. A substitute that drops an argument is a substitute that cannot fail when // the wrong value is passed in it, which is how a bech32 address reached the real builder // unremarked. `loadSmplxWasm.test.ts` is what holds these signatures to the real binding. -function substitute(recorded: Recorded): Pick { +function substitute( + recorded: Recorded, + /** What the module claims it derived, which the wallet's own derivation is compared against. */ + reports: Partial = {}, +): SmplxModule { return { TransactionBuilder: class { addChange(script: string, blindingKey?: string | null) { recorded.changes.push({ blindingKey, script }); } - addOutput(script: string, sats: bigint, asset: string) { - recorded.outputs.push({ asset, sats, script }); + addOutput(script: string, sats: bigint, asset: string, blindingKey?: string | null) { + recorded.outputs.push({ asset, blindingKey, sats, script }); } // The encoded output is recorded with the outpoint, because the module needs all three // and a substitute that ignores one cannot notice it going missing. addWalletInput(txid: string, vout: number, txOut: string) { recorded.spends.push({ txOut, txid, vout }); } + addWalletIssuanceInput( + txid: string, + vout: number, + txOut: string, + assetAmountSats: bigint, + inflationAmountSats: bigint, + issuerContractHex?: string, + ) { + recorded.issues.push({ + assetAmountSats, + inflationAmountSats, + issuerContractHex, + txOut, + txid, + vout, + }); + + return { + assetId: reports.asset ?? ISSUED.asset, + entropy: reports.entropy ?? ISSUED.entropy, + free: () => { + recorded.freedReports += 1; + }, + reissuanceTokenId: reports.reissuanceToken ?? ISSUED.reissuanceToken, + }; + } // Held across the wasm boundary, so the module under test releases it. A substitute // without this passes only because nothing checked that it was released. free() { recorded.freed += 1; } }, - } as unknown as Pick; + }; } +/** What this module needs of the SDK, which is what it states for itself. */ +type SmplxModule = { TransactionBuilder: new () => AssemblingBuilder }; + function review(overrides: Partial = {}): ManifestReview { return { action: "Pay", @@ -83,9 +150,21 @@ function review(overrides: Partial = {}): ManifestReview { verified: "not-yet-on-chain", }, ], + changeBlinded: false, + changeOverrode: "chain", feeRateSatsPerKvb: 1000, + issuances: [], normalisation: [], - outputs: [{ asset: ASSET, id: "p2pk_out", sats: 50_000n, scriptPubKeyHex: COVENANT_SCRIPT }], + outputs: [ + { + asset: ASSET, + blinded: false, + decidedBy: "unblindable", + id: "p2pk_out", + sats: 50_000n, + scriptPubKeyHex: COVENANT_SCRIPT, + }, + ], protocol: "p2pk-simplicity", selected: [ { amount: "1000000", spendable: true, txOut: TXOUT_HEX, txid: "c".repeat(64), vout: 0 }, @@ -94,15 +173,29 @@ function review(overrides: Partial = {}): ManifestReview { }; } -function subject(overrides: Partial = {}, finalize = () => SIGNED) { - const recorded: Recorded = { changes: [], freed: 0, outputs: [], spends: [] }; +function subject( + overrides: Partial = {}, + finalize = () => SIGNED, + extra: { blindingPublicKeyHex?: string; reports?: Partial } = {}, +) { + const recorded: Recorded = { + changes: [], + freed: 0, + freedReports: 0, + issues: [], + outputs: [], + spends: [], + }; return { assemble: () => assembleReviewedTransaction(review(overrides), { + ...(extra.blindingPublicKeyHex === undefined + ? {} + : { blindingPublicKeyHex: extra.blindingPublicKeyHex }), changeScriptPubKeyHex: CHANGE_SCRIPT, finalize, - smplx: substitute(recorded), + smplx: substitute(recorded, extra.reports), }), recorded, }; @@ -125,7 +218,9 @@ describe("assembleReviewedTransaction", () => { await assemble(); - expect(recorded.outputs).toEqual([{ asset: ASSET, sats: 50_000n, script: COVENANT_SCRIPT }]); + expect(recorded.outputs).toEqual([ + { asset: ASSET, blindingKey: undefined, sats: 50_000n, script: COVENANT_SCRIPT }, + ]); }); // The builder hex-decodes every script it is given, so an address reaching it fails inside @@ -217,19 +312,19 @@ describe("assembleReviewedTransaction", () => { }); test("releases the builder when an output the module will not take throws", async () => { - const recorded: Recorded = { changes: [], freed: 0, outputs: [], spends: [] }; - const smplx = { - TransactionBuilder: class { - addChange() {} - addOutput() { - throw new Error("Invalid script: Odd number of digits"); - } - addWalletInput() {} - free() { - recorded.freed += 1; - } - }, - } as unknown as Pick; + const recorded: Recorded = { + changes: [], + freed: 0, + freedReports: 0, + issues: [], + outputs: [], + spends: [], + }; + const smplx = substitute(recorded); + + smplx.TransactionBuilder.prototype.addOutput = () => { + throw new Error("Invalid script: Odd number of digits"); + }; const result = await assembleReviewedTransaction(review(), { changeScriptPubKeyHex: CHANGE_SCRIPT, @@ -244,20 +339,20 @@ describe("assembleReviewedTransaction", () => { // A change script the module will not decode fails the same way an output does, and // after every input has already been added. test("releases the builder when the change script is refused", async () => { - const recorded: Recorded = { changes: [], freed: 0, outputs: [], spends: [] }; + const recorded: Recorded = { + changes: [], + freed: 0, + freedReports: 0, + issues: [], + outputs: [], + spends: [], + }; let finalized = 0; - const smplx = { - TransactionBuilder: class { - addChange() { - throw new Error("Invalid script: Odd number of digits"); - } - addOutput() {} - addWalletInput() {} - free() { - recorded.freed += 1; - } - }, - } as unknown as Pick; + const smplx = substitute(recorded); + + smplx.TransactionBuilder.prototype.addChange = () => { + throw new Error("Invalid script: Odd number of digits"); + }; const result = await assembleReviewedTransaction(review(), { changeScriptPubKeyHex: "tex1q_wallet", @@ -332,11 +427,314 @@ describe("assembleReviewedTransaction", () => { // off a signer. Deriving a script from an address is public work. test("pays a wallet output the script the review derived", async () => { const { assemble, recorded } = subject({ - outputs: [{ asset: ASSET, id: "received_out", sats: 10n, scriptPubKeyHex: WALLET_SCRIPT }], + outputs: [ + { + asset: ASSET, + blinded: false, + decidedBy: "output", + id: "received_out", + sats: 10n, + scriptPubKeyHex: WALLET_SCRIPT, + }, + ], }); await assemble(); - expect(recorded.outputs).toEqual([{ asset: ASSET, sats: 10n, script: WALLET_SCRIPT }]); + expect(recorded.outputs).toEqual([ + { asset: ASSET, blindingKey: undefined, sats: 10n, script: WALLET_SCRIPT }, + ]); + }); + + /** + * An issuing input is added once, as an issuance. + * + * The asset an issuance creates is a function of the output its input spends, so the two + * are joined on that outpoint and on nothing else. Adding the same output again as an + * ordinary wallet input would spend it twice, which is not a transaction at all. + */ + describe("an input that creates an asset", () => { + const issuing = { issuances: [plannedIssuance()] }; + + test("is added as an issuance, with the amounts the review resolved", async () => { + const { assemble, recorded } = subject(issuing); + + await assemble(); + + expect(recorded.issues).toEqual([ + { + assetAmountSats: 1000n, + // Zero, always: Liquid requires a reissuance token to be held confidentially + // and this path builds transactions whose values are all explicit, so the + // review refuses any other figure long before it reaches here. + inflationAmountSats: 0n, + // A manifest declares no issuer contract at any position, so both sides commit + // to nothing and each says so. + issuerContractHex: undefined, + txOut: TXOUT_HEX, + txid: ISSUANCE_TXID, + vout: 0, + }, + ]); + }); + + test("and is not also added as an ordinary wallet input", async () => { + const { assemble, recorded } = subject(issuing); + + await assemble(); + + expect(recorded.spends).toEqual([]); + }); + + // Every other selected output is still an ordinary input. Only the one the asset is + // derived from becomes the issuance. + test("while the wallet's other outputs are added as they were", async () => { + const { assemble, recorded } = subject({ + ...issuing, + selected: [ + { amount: "1000000", spendable: true, txOut: TXOUT_HEX, txid: ISSUANCE_TXID, vout: 0 }, + { amount: "2000", spendable: true, txOut: TXOUT_HEX, txid: "d".repeat(64), vout: 3 }, + ], + }); + + await assemble(); + + expect(recorded.issues).toHaveLength(1); + expect(recorded.spends).toEqual([{ txOut: TXOUT_HEX, txid: "d".repeat(64), vout: 3 }]); + }); + + // The module derives the asset for itself from the same output. Two independent + // derivations of one fact are compared rather than one of them being trusted. + test("releases the module's report when the two sides agree", async () => { + const { assemble, recorded } = subject(issuing); + + expect(await assemble()).toMatchObject({ ok: true }); + expect(recorded.freedReports).toBe(1); + }); + + test("refuses when the module reports a different asset", async () => { + const { assemble } = subject(issuing, () => SIGNED, { + reports: { asset: "b".repeat(64) }, + }); + + const result = await assemble(); + + expect(result).toMatchObject({ ok: false }); + + if (!result.ok) { + expect(result.reason).toContain("mint_in"); + expect(result.reason).toContain("asset"); + } + }); + + test("and when it reports a different entropy or reissuance token", async () => { + const differentEntropy = await subject(issuing, () => SIGNED, { + reports: { entropy: "b".repeat(64) }, + }).assemble(); + const differentToken = await subject(issuing, () => SIGNED, { + reports: { reissuanceToken: "b".repeat(64) }, + }).assemble(); + + expect(differentEntropy).toMatchObject({ ok: false }); + expect(differentToken).toMatchObject({ ok: false }); + }); + + // The report is a handle across the wasm boundary like everything else the module + // returns. A refusal that leaks one leaks it on exactly the path a person hits. + test("releases the module's report on the path that refuses too", async () => { + const { assemble, recorded } = subject(issuing, () => SIGNED, { + reports: { asset: "b".repeat(64) }, + }); + + await assemble(); + + expect(recorded.freedReports).toBe(1); + expect(recorded.freed).toBe(1); + }); + + /** + * The joins that are wrong about the whole transaction are settled before it exists. + * + * Each of these is a disagreement between the two lists a review carries rather than a + * fault in one input, and a check made while adding inputs would find it with half the + * transaction already built — leaving a builder to unwind and an error naming whichever + * input it happened to reach. Nothing is constructed, so nothing has to be released. + */ + describe("what it settles before starting a builder", () => { + // An asset derived from an output no input spends is an id for something that would + // never come to exist, and the person would already have been shown it. + test("an issuance derived from an output this transaction does not spend", async () => { + const { assemble, recorded } = subject({ + issuances: [{ ...plannedIssuance(), outpoint: { txid: "e".repeat(64), vout: 7 } }], + }); + + const result = await assemble(); + + expect(result).toMatchObject({ ok: false }); + expect(recorded.freed).toBe(0); + expect(recorded.spends).toEqual([]); + expect(recorded.issues).toEqual([]); + + if (!result.ok) { + expect(result.reason).toContain("mint_in"); + } + }); + + /** + * Two issuances claiming one output. + * + * Each is a well-formed id for a different asset, and both need that one output + * spent to exist. A map built from them without looking keeps the last and mints one + * asset while a person was shown two — silently, because nothing downstream holds + * both lists. + */ + test("two issuances derived from one output", async () => { + const { assemble, recorded } = subject({ + issuances: [ + plannedIssuance(), + { ...plannedIssuance(), asset: "b".repeat(64), inputId: "mint_two" }, + ], + }); + + const result = await assemble(); + + expect(result).toMatchObject({ ok: false }); + expect(recorded.freed).toBe(0); + expect(recorded.issues).toEqual([]); + + if (!result.ok) { + expect(result.reason).toContain("mint_two"); + expect(result.reason).toContain("cannot create two assets"); + } + }); + + // Two descriptions of one output are one output. Adding both spends it twice. + test("one of the wallet's outputs selected more than once", async () => { + const spent = { + amount: "1000000", + spendable: true, + txOut: TXOUT_HEX, + txid: ISSUANCE_TXID, + vout: 0, + }; + const { assemble, recorded } = subject({ selected: [spent, { ...spent }] }); + + const result = await assemble(); + + expect(result).toMatchObject({ ok: false }); + expect(recorded.freed).toBe(0); + expect(recorded.spends).toEqual([]); + + if (!result.ok) { + expect(result.reason).toContain("more than once"); + } + }); + + // A txid is thirty-two bytes, and the same bytes in two cases are one output. A + // check spelling its own key would let this through. + test("and the same output written in two cases", async () => { + const spent = { + amount: "1000000", + spendable: true, + txOut: TXOUT_HEX, + txid: ISSUANCE_TXID, + vout: 0, + }; + const { assemble, recorded } = subject({ + selected: [spent, { ...spent, txid: ISSUANCE_TXID.toUpperCase() }], + }); + + expect(await assemble()).toMatchObject({ ok: false }); + expect(recorded.freed).toBe(0); + }); + }); + }); + + /** + * Which outputs hide what they carry, and which do not. + * + * The decision is the document's and was made while reading it; the builder has never read + * the document. A key is passed for the outputs the review calls hidden and for no others — + * passing one to an open output hides an amount the protocol published on purpose, and + * withholding one from a hidden output publishes an amount it asked to keep. + */ + describe("blinding", () => { + const BLINDING_KEY = `02${"55".repeat(32)}`; + const hiddenAndOpen = { + outputs: [ + { + asset: ASSET, + blinded: true, + decidedBy: "chain" as const, + id: "paid_out", + sats: 10n, + scriptPubKeyHex: WALLET_SCRIPT, + }, + { + asset: ASSET, + blinded: false, + decidedBy: "unblindable" as const, + id: "p2pk_out", + sats: 50_000n, + scriptPubKeyHex: COVENANT_SCRIPT, + }, + ], + }; + + test("passes the key only to the outputs the review says are hidden", async () => { + const { assemble, recorded } = subject(hiddenAndOpen, () => SIGNED, { + blindingPublicKeyHex: BLINDING_KEY, + }); + + await assemble(); + + expect(recorded.outputs).toEqual([ + { asset: ASSET, blindingKey: BLINDING_KEY, sats: 10n, script: WALLET_SCRIPT }, + { asset: ASSET, blindingKey: undefined, sats: 50_000n, script: COVENANT_SCRIPT }, + ]); + }); + + // Deliberately open under the current design, so that the money comes back in a form the + // next contract action can be funded from. The review says so outright rather than this + // module assuming it. + test("passes no key for change the review returns in the open", async () => { + const { assemble, recorded } = subject({}, () => SIGNED, { + blindingPublicKeyHex: BLINDING_KEY, + }); + + await assemble(); + + expect(recorded.changes).toEqual([{ blindingKey: undefined, script: CHANGE_SCRIPT }]); + }); + + test("and passes it for change the review says must be hidden", async () => { + const { assemble, recorded } = subject({ changeBlinded: true }, () => SIGNED, { + blindingPublicKeyHex: BLINDING_KEY, + }); + + await assemble(); + + expect(recorded.changes).toEqual([{ blindingKey: BLINDING_KEY, script: CHANGE_SCRIPT }]); + }); + + // Publishing an amount the protocol asked to keep cannot be taken back afterwards, so + // nothing is built at all rather than built in the open. + test("refuses a hidden output it was given no key for, building nothing", async () => { + const { assemble, recorded } = subject(hiddenAndOpen); + + const result = await assemble(); + + expect(result).toMatchObject({ ok: false }); + expect(recorded.freed).toBe(0); + expect(recorded.outputs).toEqual([]); + + if (!result.ok) { + expect(result.reason).toContain("paid_out"); + } + }); + + test("and refuses hidden change it was given no key for", async () => { + expect(await subject({ changeBlinded: true }).assemble()).toMatchObject({ ok: false }); + }); }); }); 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 4958cd5..2192229 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.ts @@ -9,14 +9,53 @@ export type AssembledTransaction = { txid: string; }; +/** + * What the module made of an issuance it was asked to add. + * + * A handle across the wasm boundary like everything else the module returns, so it is freed + * on every path. The three ids are the module's own derivation from the same output the + * wallet derived from — independently, which is why they are compared rather than trusted. + */ +export type AssembledIssuanceReport = { + assetId: string; + entropy: string; + free: () => void; + reissuanceTokenId: string; +}; + /** * The transaction under assembly, as this module drives it. * - * Named rather than taken from the module's own type so what is used is visible: this adds - * inputs and outputs and nothing else. Everything that needs a key happens on the other side - * of `FinalizeTransaction`. + * Stated here rather than taken whole from the module's own type, so that what is used is + * visible and is exactly this: four calls that add inputs and outputs, and the release. Every + * one of them is a method the SDK declares under these names and these arguments; nothing + * that needs a key is among them, because everything that does happens on the other side of + * `FinalizeTransaction`. + * + * Written structurally so it is satisfied by the real module and by a fake standing in for it + * in a test, and so a build of the wasm that has drifted from the SDK this wallet is pinned to + * is a mismatch here rather than a call that compiles and is not there at run time. */ -export type AssemblingBuilder = InstanceType; +export type AssemblingBuilder = Pick< + InstanceType, + "addChange" | "addOutput" | "addWalletInput" | "free" +> & { + /** + * Adds a wallet input that also creates a new asset. + * + * The issuer contract is the last argument and is left unstated, because a manifest + * declares none at any position. What comes back is the module's own derivation of the + * asset from this very outpoint, which is the thing the reviewed plan is compared against. + */ + addWalletIssuanceInput: ( + txid: string, + vout: number, + txOutHex: string, + assetAmountSats: bigint, + inflationAmountSats: bigint, + issuerContractHex?: string, + ) => AssembledIssuanceReport; +}; /** * Turns an assembled transaction into a finished one. @@ -61,16 +100,37 @@ export type AssembleResult = export async function assembleReviewedTransaction( review: ManifestReview, input: { + /** + * The public key an output the document wants hidden is blinded to. + * + * A public key and nothing else. This is not a signer seam: hiding an output needs only + * the blinding key of the address it pays to, and this module still acquires no + * credential of any kind. It is supplied by the caller because it belongs to the + * wallet's own address, and only the caller knows which one that is. + * + * Optional because a transaction whose outputs are all open needs none. One that turns + * out to want a key and is given none is refused rather than built open: publishing an + * amount the protocol asked to keep cannot be taken back afterwards. + */ + blindingPublicKeyHex?: string; /** * Where this transaction's change goes, as a script rather than an address. * - * The wallet's own, supplied by the caller. No blinding key goes with it in this slice: - * what an output hides is a decision the document makes and this runtime does not read - * yet, so change is returned in the open rather than hidden on a guess. + * The wallet's own, supplied by the caller. Whether it hides what it carries is the + * review's answer rather than this module's: under the current design a contract + * action's change is deliberately published so the next action can be funded from it, + * and the review says so outright rather than this module assuming it. */ changeScriptPubKeyHex: string; finalize: FinalizeTransaction; - smplx: Pick; + /** + * The module's builder constructor, as this module needs it. + * + * Narrowed to a constructor of the surface stated above rather than taken from the + * module's own type, so a wasm build that has drifted from the SDK this wallet is + * pinned to fails to satisfy this instead of failing at the call. + */ + smplx: { TransactionBuilder: new () => AssemblingBuilder }; }, ): Promise { // A covenant being spent needs the source, the arguments and the witness the review @@ -97,23 +157,166 @@ export async function assembleReviewedTransaction( return { ok: false, reason: `"${review.action}" pays nothing, so there is nothing to build.` }; } + // An output the document wants hidden needs a key to hide it with, and one that cannot be + // supplied is refused here rather than built in the open. Publishing an amount a protocol + // asked to keep is not a smaller version of the right transaction — it is a different one, + // and it is on the chain permanently. + const unblindable = review.outputs.find((output) => output.blinded); + + if (unblindable && input.blindingPublicKeyHex === undefined) { + return { + ok: false, + reason: + `The output ${unblindable.id || "(unnamed)"} must hide what it carries, and no ` + + "blinding key was supplied to hide it with.", + }; + } + + if (review.changeBlinded && input.blindingPublicKeyHex === undefined) { + return { + ok: false, + reason: `"${review.action}" returns change that must hide what it carries, and no blinding key was supplied to hide it with.`, + }; + } + + /** + * Which inputs create an asset, keyed by the output each one is derived from. + * + * That outpoint is the only join both sides promise: the manifest named the input, the + * wallet chose the output, and an asset id is a function of the output rather than of + * where the input ended up. Matching on order would be matching on something neither side + * states. + * + * Built and checked before the builder exists, and that is the point. Everything wrong + * with this join is wrong about the whole transaction rather than about one input, and a + * check made while adding inputs discovers it with half of them already added — leaving a + * builder to unwind and, on the paths that throw, an error naming the input it happened to + * reach rather than the disagreement that caused it. Nothing here allocates, so nothing + * here has to be released. + */ + const issuing = new Map(); + + for (const issuance of review.issuances) { + const key = outpointKey(issuance.outpoint); + + // Two issuances derived from one output would each be a well-formed id for a different + // asset, and the transaction would have to spend that output twice to create both. A + // map built without looking would simply keep the last of them and mint one asset while + // a person had been shown two. + if (issuing.has(key)) { + return { + ok: false, + reason: + `Input ${issuance.inputId} issues an asset from ${issuance.outpoint.txid}:` + + `${issuance.outpoint.vout}, which another input of this transaction already ` + + "issues from. One output cannot create two assets.", + }; + } + + issuing.set(key, issuance); + } + + // An asset derived from an output no input spends is an id for something that would never + // come to exist, and the person would already have been shown it. + const spending = new Set(review.selected.map((utxo) => outpointKey(utxo))); + const stranded = review.issuances.find( + (issuance) => !spending.has(outpointKey(issuance.outpoint)), + ); + + if (stranded) { + return { + ok: false, + reason: + `Input ${stranded.inputId} issues an asset from an output this transaction does not ` + + "spend, so the asset would never exist.", + }; + } + + // One output described twice is still one output, and adding both is a transaction that + // spends it twice. Selection removes these, so reaching here means the review was assembled + // by something other than a review — which is exactly when a builder should not be started. + if (spending.size !== review.selected.length) { + return { + ok: false, + reason: `"${review.action}" spends one of this wallet's outputs more than once.`, + }; + } + const builder = new input.smplx.TransactionBuilder(); try { for (const utxo of review.selected) { - builder.addWalletInput(utxo.txid, utxo.vout, utxo.txOut); + const issuance = issuing.get(outpointKey(utxo)); + + if (!issuance) { + builder.addWalletInput(utxo.txid, utxo.vout, utxo.txOut); + + continue; + } + + // An issuing input is added once, as an issuance. Adding it here and again as an + // ordinary wallet input would spend the same output twice, which is not a + // transaction at all. + // + // The issuer contract is left unstated because a manifest declares none at any + // position, so both sides commit to nothing and each says so. + const reported = builder.addWalletIssuanceInput( + utxo.txid, + utxo.vout, + utxo.txOut, + issuance.assetAmountSats, + issuance.inflationAmountSats, + undefined, + ); + + // The module derived the asset for itself, from the same output. This is the first + // fact the wallet and the module each establish independently, so it gets the + // treatment every other such fact gets: they are compared, and a difference refuses + // rather than one of the two being trusted. A silent disagreement means one of them + // is creating a different asset than the other, and nothing downstream could tell + // which — after a person has already approved the one the wallet showed them. + try { + const difference = firstDisagreement(issuance, reported); + + if (difference) { + return { + ok: false, + reason: + `Input ${issuance.inputId} creates an asset the signing module does not ` + + `agree about: the ${difference.what} the wallet derived is ${difference.mine} ` + + `and the module reports ${difference.theirs}.`, + }; + } + } finally { + reported.free(); + } } // Paid in the asset the review worked out for it, and to the script it derived. An // output built from an amount alone pays whatever asset the module defaults to, and one // built from an address is not hex the module can decode. + // + // Whether it hides what it carries was decided while reading the document, not here: + // the builder has never read it. A key is passed for the outputs the review says are + // hidden and for no others — passing one to an open output would hide an amount the + // protocol published on purpose. for (const output of review.outputs) { - builder.addOutput(output.scriptPubKeyHex, output.sats, output.asset); + builder.addOutput( + output.scriptPubKeyHex, + output.sats, + output.asset, + output.blinded ? input.blindingPublicKeyHex : undefined, + ); } // Set on the builder rather than passed to the call that signs, because where change - // goes is a fact about this transaction and not about the signature over it. - builder.addChange(input.changeScriptPubKeyHex); + // goes is a fact about this transaction and not about the signature over it. It is + // deliberately open under the current design — see the review's own account of why — + // so it is given no key unless the review says otherwise. + builder.addChange( + input.changeScriptPubKeyHex, + review.changeBlinded ? input.blindingPublicKeyHex : undefined, + ); return { ok: true, transaction: await input.finalize(builder, review.feeRateSatsPerKvb) }; } catch (error) { @@ -122,3 +325,38 @@ export async function assembleReviewedTransaction( builder.free(); } } + +/** + * The first of the three ids the two sides disagree about, if they disagree at all. + * + * The first rather than all of them, because one difference is already the whole answer: the + * two are deriving different assets, and which field showed it first is enough to say so. + */ +function firstDisagreement( + mine: ManifestReview["issuances"][number], + theirs: Omit, +): { mine: string; theirs: string; what: string } | undefined { + const compared = [ + { mine: mine.asset, theirs: theirs.assetId, what: "asset" }, + { mine: mine.entropy, theirs: theirs.entropy, what: "entropy" }, + { mine: mine.reissuanceToken, theirs: theirs.reissuanceTokenId, what: "reissuance token" }, + ]; + + return compared.find((field) => field.mine.toLowerCase() !== field.theirs.toLowerCase()); +} + +/** + * The one spelling of "this output" that the joins above compare by. + * + * An outpoint is the only identity a transaction output has. It is not the object it was + * described with: the two lists a review carries — the outputs the wallet selected and the + * assets it creates — are built separately, so joining them on anything else would join them + * on nothing. + * + * Lower-cased because a txid is thirty-two bytes and their casing is not part of which output + * they name. Spelled here rather than reached for through the manifest package: this module + * needs a key for two local maps, and a key is not a shape a package publishes. + */ +function outpointKey(outpoint: { txid: string; vout: number }): string { + return `${outpoint.txid.trim().toLowerCase()}:${outpoint.vout}`; +} diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts index 5f237b4..6356f22 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -150,3 +150,129 @@ describe("transaction assembly", () => { builder.free(); }); }); + +/** + * The call that creates an asset, against the module this wallet ships. + * + * Everything else about issuance in this slice is checked against a substitute, which can only + * prove that the wallet calls what it meant to call. This proves the call exists, takes these + * arguments in this order, and hands back the three ids the wallet compares its own derivation + * against — a substitute cannot fail when the real binding reorders two arguments. + * + * Credential-free: adding an input needs no signer, only finalizing does, and this stops + * before that. Nothing is signed and nothing is broadcast. + * + * The expectations are one asset Liquid already carries rather than anything this repository + * computes, so the test cannot agree with a wrong implementation by sharing it. Tether USD, + * read on 2026-08-13 from Blockstream's Liquid Esplora `GET /liquid/api/asset/`, which + * reports its `issuance_prevout`, `contract_hash` and `reissuance_token`. That is the same + * provenance as the vectors in `packages/tx-manifest/src/chain/issuance.test.ts`, and the two + * arrive at it independently — one in TypeScript here, one in Rust inside the module. + * + * The entropy is not published by the registry. It is pinned here because it is the only value + * that produces both of the ids that are: the asset and the token are each a hash of it, so an + * entropy off by a byte could not reproduce either. Its spelling is the reversed one, which is + * how the module's `sha256::Midstate` displays (`DISPLAY_BACKWARD`). + */ +describe("a wallet input that creates an asset", () => { + const ISSUED_FROM = "9596d259270ef5bac0020435e6d859aea633409483ba64e232b8ba04ce288668"; + const ISSUER_CONTRACT = "3c7f0a53c2ff5b99590620d7f6604a7a3a7bfbaaa6aa61f7bfc7833ca03cde82"; + const TETHER = "ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2"; + const TETHER_TOKEN = "59fe4d2127ba9f16bd6850a3e6271a166e7ed2e1669f6c107d655791c94ee98f"; + const TETHER_ENTROPY = "15e71351641d30019845313442452885f64bf5985d366f09a291e949fa929608"; + /** No issuer contract, written out — the same statement production makes by omission. */ + const NO_CONTRACT = "0".repeat(64); + // The same P2WPKH output of 100_000 sats used above, consensus-encoded. Which output the + // input spends decides the asset; what is in it does not. + const TXOUT_HEX = + "01" + + "499a818545f6bae39fc03b637f2a4e1e64e590cac1bc3a6f6d71aa4443654c14" + + "01" + + "00000000000186a0" + + "00" + + "160014" + + "0000000000000000000000000000000000000000"; + + test("takes the outpoint, its output and what it mints, and reports the asset Liquid holds", () => { + const builder = new smplx.TransactionBuilder(); + + try { + const report = builder.addWalletIssuanceInput( + ISSUED_FROM, + 0, + TXOUT_HEX, + 1000n, + // No reissuance token: Liquid requires one to be held confidentially, and this + // path builds transactions whose values are all explicit. + 0n, + ISSUER_CONTRACT, + ); + + try { + expect(report.assetId).toBe(TETHER); + expect(report.entropy).toBe(TETHER_ENTROPY); + expect(report.reissuanceTokenId).toBe(TETHER_TOKEN); + } finally { + report.free(); + } + + // Added once, as an issuance, rather than alongside an ordinary input. + expect(builder.inputCount()).toBe(1); + } finally { + builder.free(); + } + }); + + /** + * The shape production actually calls, which the vector above does not exercise. + * + * `assembleReviewedTransaction` passes `undefined` for the issuer contract, because a + * manifest declares none at any position — so the one call this wallet ever makes is the + * one with the optional argument omitted, and the vector above proves a different call. A + * binding that required the argument, or that read an omitted one as anything other than + * no commitment, would mint an asset under a different id than the wallet derived and + * showed to a person, and the disagreement would surface only at assembly. + * + * The assertion is the whole of it: `reportFor(undefined)` makes the production call + * against the shipped binding and must equal `reportFor(NO_CONTRACT)`, which states the + * same thing outright. Reaching the binding at all is what proves the argument is + * genuinely optional; the two reports agreeing is what proves omitting it means the empty + * commitment rather than something else. Both halves of the comparison come out of the + * module, so nothing here is derived here and nothing is shared with the implementation + * this repository would otherwise be checking against itself. + * + * One outpoint, and therefore a builder per call: the ids are a function of the output the + * input spends, so comparing two outpoints would compare the wrong thing — and one builder + * cannot spend one outpoint twice. + */ + test("reads the issuer contract production omits exactly as an all-zero one", () => { + const reportFor = (contract: string | undefined) => { + const builder = new smplx.TransactionBuilder(); + + try { + const report = builder.addWalletIssuanceInput( + ISSUED_FROM, + 0, + TXOUT_HEX, + 1000n, + 0n, + contract, + ); + + try { + return { + asset: report.assetId, + entropy: report.entropy, + token: report.reissuanceTokenId, + }; + } finally { + report.free(); + } + } finally { + builder.free(); + } + }; + + expect(reportFor(undefined)).toEqual(reportFor(NO_CONTRACT)); + }); +}); diff --git a/packages/tx-manifest/src/__fixtures__/multiasset.manifest.json b/packages/tx-manifest/src/__fixtures__/multiasset.manifest.json new file mode 100644 index 0000000..f957b85 --- /dev/null +++ b/packages/tx-manifest/src/__fixtures__/multiasset.manifest.json @@ -0,0 +1,95 @@ +{ + "manifest_version": "0.1.0", + "attestation_version": "1", + "protocol": "multiasset-test", + "description": "A two-asset protocol, for exercising per-asset funding, issuance and blinding. Not a published document: it is the smallest thing that states each of those at once.", + "chain": "liquid", + "utxo_types": { + "p2pk_output": { + "description": "A Liquid UTXO locked to PUBKEY via the compiled p2pk.simf program.", + "script": { + "type": "simplicity", + "source": "./p2pk.simf" + }, + "asset": "lbtc" + } + }, + "actions": { + "PayToken": { + "description": "Pays a token to a covenant and returns the token's surplus to the wallet.", + "params": { + "pubkey": { "type": "pubkey", "description": "The spender's x-only key." }, + "token": { "type": "asset", "description": "The asset being paid." }, + "amount_sat": { "type": "u64", "description": "How much of it." }, + "fee_sat": { "type": "u64", "description": "What the covenant is paid in money." } + }, + "inputs": [ + { + "id": "token_in", + "utxo_source": "wallet", + "asset": "params.token" + } + ], + "outputs": [ + { + "id": "token_out", + "destination": "wallet", + "amount_sat": "params.amount_sat", + "asset": "params.token", + "confidential": false + }, + { + "id": "p2pk_out", + "destination": { + "utxo_type": "p2pk_output", + "compile_params": { "PUB_KEY": "params.pubkey" } + }, + "amount_sat": "params.fee_sat", + "asset": "lbtc" + }, + { + "id": "token_change", + "destination": "change", + "asset": "params.token" + }, + { + "id": "change_out", + "destination": "change", + "asset": "lbtc" + } + ] + }, + "Mint": { + "description": "Creates a new asset and pays every unit of it to the wallet.", + "params": { + "pubkey": { "type": "pubkey", "description": "The spender's x-only key." }, + "supply": { "type": "u64", "description": "How many units to create." } + }, + "inputs": [ + { + "id": "mint_in", + "utxo_source": "wallet", + "asset": "lbtc", + "issuance": { + "kind": "new", + "asset_amount_sat": "params.supply" + } + } + ], + "outputs": [ + { + "id": "minted_out", + "destination": "wallet", + "amount_sat": "params.supply", + "asset": "mint_in.asset", + "confidential": false + }, + { + "id": "change_out", + "destination": "change", + "asset": "lbtc" + } + ] + } + } +} diff --git a/packages/tx-manifest/src/chain/bytes.ts b/packages/tx-manifest/src/chain/bytes.ts new file mode 100644 index 0000000..192d9ab --- /dev/null +++ b/packages/tx-manifest/src/chain/bytes.ts @@ -0,0 +1,25 @@ +/** + * Reading and writing consensus-encoded bytes. + * + * Hex is how every id, script and payload crosses this package's boundary, and the + * conversion is written once here rather than at each place that needs it. Nothing in this + * module knows what the bytes mean — a caller that has to reverse an id does so itself, + * because whether a run of bytes is written forwards or backwards is a fact about what it + * is rather than about hexadecimal. + */ + +/** The bytes this hex spells, or nothing when the text is not hex at all. */ +export function decodeHex(hex: string): Uint8Array | undefined { + const digits = hex.startsWith("0x") ? hex.slice(2) : hex; + + if (digits.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(digits)) { + return undefined; + } + + return Uint8Array.from(digits.match(/../g) ?? [], (pair) => Number.parseInt(pair, 16)); +} + +/** The same conversion back, always lower case and always two digits a byte. */ +export function encodeHex(bytes: Uint8Array): string { + return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/packages/tx-manifest/src/chain/chainRead.ts b/packages/tx-manifest/src/chain/chainRead.ts index dccd6e2..b8617db 100644 --- a/packages/tx-manifest/src/chain/chainRead.ts +++ b/packages/tx-manifest/src/chain/chainRead.ts @@ -15,6 +15,18 @@ export type OutPoint = { txid: string; vout: number }; export type TxOutAtOutPoint = { + /** + * Base-unit amount, when the output states one rather than committing to it. + * + * Absent for a confidential output, and absent for a reader that does not report it. Never + * a stand-in: an amount nobody read is left unsaid rather than written as zero, because a + * covenant said to hold zero and one whose holding was never established are the same + * value and not the same fact, and only one of them can be safely netted against an + * action's cost. + */ + amountSats?: string; + /** The asset id the output states, under the same rule and for the same reason. */ + rawAssetId?: string; /** The output's scriptPubKey in hex — the locking condition itself. */ scriptPubKeyHex: string; }; diff --git a/packages/tx-manifest/src/chain/issuance.test.ts b/packages/tx-manifest/src/chain/issuance.test.ts new file mode 100644 index 0000000..bd055a1 --- /dev/null +++ b/packages/tx-manifest/src/chain/issuance.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test"; + +import { assetFromEntropy, deriveNewIssuance } from "./issuance"; + +/** + * Assets that exist on Liquid, and the outputs they were issued from. + * + * The derivation is nowhere in the format's own documents, so an expectation written from + * this implementation would prove only that it is consistent with itself. Each case below is + * one asset the chain already carries: its issuance outpoint, the issuer contract that + * issuance committed to, and the asset and reissuance-token ids that came out. Anything but + * the exact rule Elements uses reproduces none of them. + * + * Read on 2026-08-13 from Blockstream's Liquid Esplora, `GET /liquid/api/asset/`, which + * reports each asset's `issuance_prevout`, `contract_hash` and `reissuance_token`. + */ +const ON_CHAIN = [ + { + asset: "ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2", + contractHash: "3c7f0a53c2ff5b99590620d7f6604a7a3a7bfbaaa6aa61f7bfc7833ca03cde82", + name: "Tether USD", + reissuanceToken: "59fe4d2127ba9f16bd6850a3e6271a166e7ed2e1669f6c107d655791c94ee98f", + txid: "9596d259270ef5bac0020435e6d859aea633409483ba64e232b8ba04ce288668", + vout: 0, + }, + { + asset: "123465c803ae336c62180e52d94ee80d80828db54df9bedbb9860060f49de2eb", + contractHash: "d6cb01732239e8c317699c33ef525a8a1419ebf9a2ad318edbf8135f1665a773", + name: "Scamcoinbot token", + reissuanceToken: "2f7179e260a8046f02be25dec6abcf0a2c1bd3e6e13dd29ed67570e1e71a55b7", + txid: "fc2535f2e4fc2ef1d19b832248e3edc2c3f4c4e3ee9c2bc51777bd738a6f9582", + // The index is part of what is hashed, so at least one case has to be issued from + // somewhere other than the first output or a reader of the index proves nothing. + vout: 10, + }, + { + asset: "4d4354944366ea1e33f27c37fec97504025d6062c551208f68597d1ed40ec53e", + contractHash: "56cbf179ec75145ef54d88ff50284175852f926bf2d8d06f3e2deedbdf623779", + name: "Magical Crypto Friends", + reissuanceToken: "bc1e0094f30bc863610baf601ede6b3dda5cdb1b7d1a7831c93f011282924da3", + txid: "839e819d74ac98110fce63a3dab3a1075bbddcad811e0e125641989581919ab0", + vout: 1, + }, + { + asset: "beebee1a548fbb20280e539b697de076d87859a25c2983ebc55f2d8bec40abc3", + contractHash: "6e8198a20900717b87437261967214e2af0bb4d73c1134580b25ec597887203a", + name: "Beebee", + reissuanceToken: "fc061c7585a4f166d251ef4f5afd7c63e33358582426f06070cfb286249926cb", + txid: "27e6bd36daef786775768a6b106053d0f2f10e03b6f278715931caa00662138d", + vout: 3, + }, +]; + +describe("the asset a first issuance creates", () => { + for (const known of ON_CHAIN) { + test(`is the one Liquid holds for ${known.name}`, () => { + const derived = deriveNewIssuance({ txid: known.txid, vout: known.vout }, known.contractHash); + + expect(derived?.asset).toBe(known.asset); + }); + + test(`carries ${known.name}'s reissuance token`, () => { + const derived = deriveNewIssuance({ txid: known.txid, vout: known.vout }, known.contractHash); + + expect(derived?.reissuanceToken).toBe(known.reissuanceToken); + }); + } + + // Every issuance a manifest declares commits to nothing, so the default is the case this + // wallet actually runs and it must be the empty commitment rather than a repeat of one. + test("commits to no issuer contract unless one is given", () => { + const [known] = ON_CHAIN; + + if (!known) { + throw new Error("no chain vectors"); + } + + const withoutContract = deriveNewIssuance({ txid: known.txid, vout: known.vout }); + const withZeroes = deriveNewIssuance({ txid: known.txid, vout: known.vout }, "0".repeat(64)); + + expect(withoutContract?.asset).toBe(withZeroes?.asset ?? ""); + expect(withoutContract?.asset).not.toBe(known.asset); + }); + + test("changes when the output it is issued from changes", () => { + const [known] = ON_CHAIN; + + if (!known) { + throw new Error("no chain vectors"); + } + + const first = deriveNewIssuance({ txid: known.txid, vout: 0 }); + const second = deriveNewIssuance({ txid: known.txid, vout: 1 }); + + expect(first?.asset).not.toBe(second?.asset ?? ""); + }); + + test("is not derivable from something that is not an outpoint", () => { + expect(deriveNewIssuance({ txid: "aabb", vout: 0 })).toBeUndefined(); + expect(deriveNewIssuance({ txid: "a".repeat(64), vout: -1 })).toBeUndefined(); + }); +}); + +describe("the asset a reissuance mints", () => { + // A reissuance has no outpoint to derive from: it mints the asset that already exists, + // which is why the entropy is the thing a protocol has to have kept. + test("is the same asset, from the entropy the first issuance left", () => { + const [known] = ON_CHAIN; + + if (!known) { + throw new Error("no chain vectors"); + } + + const first = deriveNewIssuance({ txid: known.txid, vout: known.vout }, known.contractHash); + + expect(assetFromEntropy(first?.entropy ?? "")).toBe(known.asset); + }); + + test("is not derivable from something that is not an entropy", () => { + expect(assetFromEntropy("")).toBeUndefined(); + expect(assetFromEntropy("zz".repeat(32))).toBeUndefined(); + }); +}); diff --git a/packages/tx-manifest/src/chain/issuance.ts b/packages/tx-manifest/src/chain/issuance.ts new file mode 100644 index 0000000..12c9711 --- /dev/null +++ b/packages/tx-manifest/src/chain/issuance.ts @@ -0,0 +1,165 @@ +/** + * Deriving the asset an issuance creates. + * + * Neither the format's specification nor its standards draft says how the asset id is + * computed; both say only that it is, and that the result is readable afterwards. The rule + * belongs to Elements rather than to the format, so it is written here from what the chain + * does and checked against assets that exist on Liquid. + * + * Three facts decide everything below. An asset is a function of the transaction output the + * issuing input spends, which is why nothing here can run before that output is chosen. The + * hash is not the usual double SHA-256 but a single compression of two 32-byte halves with + * no padding, which Elements calls a fast merkle root and uses nowhere else a wallet meets. + * And every id is written in reverse of how it is serialised, the same way a transaction id + * is, so the boundary of this module converts and the middle of it does not. + */ + +import { SHA256, sha256 } from "@noble/hashes/sha2.js"; + +import { decodeHex, encodeHex } from "./bytes"; +import type { Outpoint } from "./outpoint"; + +export type { Outpoint }; + +/** What one issuance produces, in the form ids are written and read. */ +export type DerivedIssuance = { + /** The asset the issuance creates. */ + asset: string; + /** + * What a later reissuance of this same asset is derived from. + * + * Kept because it is the only thing that survives the transaction: the outpoint is spent + * and cannot be asked again, so a protocol that ever reissues has to have recorded this. + */ + entropy: string; + /** + * The token that authorises reissuing this asset. + * + * Derived whether or not any is minted, because the format exposes it by name inside an + * input's own hook and does not condition that on the amount. This is the unblinded form; + * the chain derives a different id when the issuance is blinded, and this wallet builds + * explicit transactions. + */ + reissuanceToken: string; +}; + +/** + * The asset, token and entropy a first issuance on this outpoint produces. + * + * `contractHash` is the issuer contract the issuance commits to. Every asset in Liquid's + * public registry commits to one; a manifest declares no such thing at any position, so the + * commitment is empty here and saying that explicitly is the difference between a wallet + * that established there is no contract and one that never looked for it. + */ +export function deriveNewIssuance( + outpoint: Outpoint, + contractHash = ZERO_HASH, +): DerivedIssuance | undefined { + const spent = serialiseOutpoint(outpoint); + const contract = readId(contractHash); + + if (!spent || !contract) { + return undefined; + } + + // The outpoint is hashed the ordinary way, twice, and only the combining step is the + // unusual one. + const entropy = combine(sha256(sha256(spent)), contract); + + return { + asset: writeId(combine(entropy, ASSET)), + entropy: writeId(entropy), + reissuanceToken: writeId(combine(entropy, TOKEN)), + }; +} + +/** + * The asset a reissuance produces, from the entropy the first issuance left behind. + * + * Separate from {@link deriveNewIssuance} because a reissuance has no outpoint of its own to + * derive from — the asset it mints is the one that already exists, and the input it sits on + * spends the token rather than the origin. + * + * Nothing in this wallet reissues, and a request carries no entropy to reissue from, so no + * caller here mints with this. It is kept because it is the only independent check on the + * entropy a new issuance reports: that value is carried out of here, compared against what the + * signing module derives, and would otherwise be thirty-two bytes nothing ever verified. + * Running it back to an asset the chain already holds is what makes it a checked figure. + */ +export function assetFromEntropy(entropy: string): string | undefined { + const bytes = readId(entropy); + + return bytes ? writeId(combine(bytes, ASSET)) : undefined; +} + +/** No issuer contract, which is what every issuance a manifest declares commits to. */ +const ZERO_HASH = "0".repeat(64); + +/** The second half Elements combines an entropy with to reach the asset itself. */ +const ASSET = new Uint8Array(32); + +/** The second half that reaches the reissuance token instead, in its unblinded form. */ +const TOKEN = Uint8Array.from([1, ...Array.from({ length: 31 }, () => 0)]); + +/** + * Elements' fast merkle root of two 32-byte values. + * + * One SHA-256 compression of the pair as a single block, taken before the padding and length + * that finish an ordinary hash. A plain `sha256(left || right)` is a different value, and + * one that would look entirely reasonable in a test that only checked its own output. + */ +function combine(left: Uint8Array, right: Uint8Array): Uint8Array { + const block = new Uint8Array(64); + + block.set(left, 0); + block.set(right, 32); + + return new Midstate().compress(block); +} + +/** + * SHA-256 stopped after one block, which the audited implementation exposes only to itself. + * + * Subclassed rather than reimplemented: the compression function is the whole of the hash, + * and a hand-written copy of it would be the least reviewed cryptography in this wallet. + */ +class Midstate extends SHA256 { + compress(block: Uint8Array): Uint8Array { + this.process(new DataView(block.buffer, block.byteOffset, block.byteLength), 0); + + const out = new Uint8Array(32); + const writer = new DataView(out.buffer); + + this.get().forEach((word, at) => writer.setUint32(at * 4, word >>> 0, false)); + + return out; + } +} + +/** The 36 bytes an outpoint occupies: the transaction as serialised, then the index. */ +function serialiseOutpoint(outpoint: Outpoint): Uint8Array | undefined { + const transaction = readId(outpoint.txid); + + if (!transaction || !Number.isInteger(outpoint.vout) || outpoint.vout < 0) { + return undefined; + } + + const bytes = new Uint8Array(36); + + bytes.set(transaction, 0); + new DataView(bytes.buffer).setUint32(32, outpoint.vout, true); + + return bytes; +} + +/** An id as it is written turned into the bytes it is made of, which are the other way round. */ +function readId(hex: string): Uint8Array | undefined { + const bytes = decodeHex(hex); + + return bytes?.length === 32 ? bytes.toReversed() : undefined; +} + +/** The same conversion back, because an id leaves here in the form everything else reads. */ +function writeId(bytes: Uint8Array): string { + return encodeHex(bytes.toReversed()); +} diff --git a/packages/tx-manifest/src/chain/outpoint.ts b/packages/tx-manifest/src/chain/outpoint.ts new file mode 100644 index 0000000..7f75c40 --- /dev/null +++ b/packages/tx-manifest/src/chain/outpoint.ts @@ -0,0 +1,52 @@ +/** + * Which transaction output a thing is, as one answer the whole package shares. + * + * An outpoint is the only identity a transaction output has. It is not the object the wallet + * described it with: a wallet assembling a snapshot from more than one source, or answering + * two questions about two assets, hands back two objects for one output that share no + * identity at all. Anything comparing those objects — or comparing keys it spelled for + * itself — decides that one output is two, and a transaction that spends one output twice is + * not a transaction. + * + * So the key is written once, here, and every part of this package that has to say "the same + * output" asks for it rather than building `${txid}:${vout}` again. The casing is why that + * matters more than tidiness: a txid is bytes, and the same bytes written in two cases are + * the same output. Two places that each spelled their own key would agree until one of them + * met a wallet that upper-cased its ids, and then would silently disagree. + */ + +/** One transaction output, named the way the chain names it. */ +export type Outpoint = { txid: string; vout: number }; + +/** + * The one spelling of "this output" that everything here compares by. + * + * Lower-cased and trimmed, because a txid is thirty-two bytes and their spelling is not part + * of which output they name. + */ +export function outpointKey(outpoint: Outpoint): string { + return `${outpoint.txid.trim().toLowerCase()}:${outpoint.vout}`; +} + +/** + * One entry per outpoint, keeping the first each was described by. + * + * The first rather than the largest or the newest: the wallet listed them in an order, and + * two descriptions of one output are the same output, so there is nothing to choose between + * them. Keeping the first is what makes the same snapshot answer the same way twice. + */ +export function byOutpoint(entries: T[]): T[] { + const seen = new Set(); + + return entries.filter((entry) => { + const key = outpointKey(entry); + + if (seen.has(key)) { + return false; + } + + seen.add(key); + + return true; + }); +} diff --git a/packages/tx-manifest/src/document/asset.test.ts b/packages/tx-manifest/src/document/asset.test.ts new file mode 100644 index 0000000..f78077f --- /dev/null +++ b/packages/tx-manifest/src/document/asset.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; + +import { statedAsset } from "./asset"; + +const POLICY = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; +const TOKEN = "ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2"; + +describe("what a document has said about an asset", () => { + // The keyword and the id are two spellings of one asset, and a document may write either. + test("the network's own asset, under either spelling", () => { + expect(statedAsset("lbtc", POLICY)).toEqual({ kind: "network" }); + expect(statedAsset("LBTC", POLICY)).toEqual({ kind: "network" }); + expect(statedAsset(POLICY, POLICY)).toEqual({ kind: "network" }); + expect(statedAsset(POLICY.toUpperCase(), POLICY)).toEqual({ kind: "network" }); + }); + + test("an asset it names outright, which is not this network's own", () => { + expect(statedAsset(TOKEN, POLICY)).toEqual({ id: TOKEN, kind: "identified" }); + }); + + test("and a lookup it leaves to be resolved later", () => { + expect(statedAsset("params.token", POLICY)).toEqual({ + kind: "deferred", + reference: "params.token", + }); + expect(statedAsset("instance.PRINCIPAL", POLICY)).toEqual({ + kind: "deferred", + reference: "instance.PRINCIPAL", + }); + }); + + /** + * The order that decides it, and the reason it is that way round. + * + * A bare reference and an asset id are both runs of `[A-Za-z0-9_]` to a parser, so an id + * beginning with a letter parses as a perfectly good reference to something of that name. + * Length and alphabet separate the two and nothing else does, so the id is tested first. + */ + test("an id that would also parse as a name is read as the id", () => { + const looksLikeAName = `feb3d9${"0".repeat(58)}`; + + expect(statedAsset(looksLikeAName, POLICY)).toEqual({ + id: looksLikeAName, + kind: "identified", + }); + }); + + // A spelling that is neither a resolvable lookup nor an id is treated as an asset the + // document identified, which is the safe direction: it is refused where it is funded, + // rather than deferred into a check that will never be reached. + test("and something that is neither is an asset rather than a lookup", () => { + expect(statedAsset("not an asset", POLICY)).toEqual({ + id: "not an asset", + kind: "identified", + }); + }); +}); diff --git a/packages/tx-manifest/src/document/asset.ts b/packages/tx-manifest/src/document/asset.ts new file mode 100644 index 0000000..6b28c87 --- /dev/null +++ b/packages/tx-manifest/src/document/asset.ts @@ -0,0 +1,61 @@ +import { parseReference } from "./references"; + +/** + * What a document has actually said about the asset a piece of value is in. + * + * Three different statements, and the format writes all three as a plain string. The first two + * are the document committing to an asset. The third is it deferring the answer to a file the + * document does not contain — this deployment's fields, or the request's own parameters. + * + * Telling them apart is the whole point. A runtime that reads a deferred lookup as a committed + * asset is answering a question the document has not asked yet, and the answer it reaches is + * about the spelling rather than about the money. + */ +export type StatedAsset = + /** A lookup this document leaves to be resolved later. */ + | { kind: "deferred"; reference: string } + /** An asset this document names outright, and which is not this network's own. */ + | { kind: "identified"; id: string } + /** The asset this network charges its fees in, however the document spelled it. */ + | { kind: "network" }; + +/** The word every generation of the format uses for the asset its network charges fees in. */ +const NETWORK_ASSET = "lbtc"; + +/** + * An asset id as the format writes one: thirty-two bytes of hex and nothing else. + * + * Tested before the text is offered to the reference parser, and that order is load-bearing. A + * bare reference and an asset id are both runs of `[A-Za-z0-9_]` to a parser, so an id that + * happens to begin with a letter — this project already has `feb3d9…` on file — parses as a + * perfectly good reference to something named `feb3d9…`. Length and alphabet separate the two. + * Nothing else does, and asking the parser first gets the answer backwards on real ids. + */ +const ASSET_ID = /^[0-9a-f]{64}$/; + +/** + * Reads what a document has said about one asset. + * + * The network's own asset is accepted under either spelling the corpus uses: the keyword, and + * the id itself. They are the same asset and a document may write either. + * + * Anything that is neither the network's asset nor a resolvable lookup is treated as an asset + * the document identified, which is the safe direction — an unrecognisable spelling is refused + * rather than deferred into a check that will never be reached. + */ +export function statedAsset(declared: string, policyAsset: string): StatedAsset { + const text = declared.trim(); + const lowered = text.toLowerCase(); + + if (lowered === NETWORK_ASSET || lowered === policyAsset.trim().toLowerCase()) { + return { kind: "network" }; + } + + if (ASSET_ID.test(lowered)) { + return { id: lowered, kind: "identified" }; + } + + return parseReference(text) + ? { kind: "deferred", reference: text } + : { id: text, kind: "identified" }; +} diff --git a/packages/tx-manifest/src/document/references.test.ts b/packages/tx-manifest/src/document/references.test.ts index 879f53b..5dfa328 100644 --- a/packages/tx-manifest/src/document/references.test.ts +++ b/packages/tx-manifest/src/document/references.test.ts @@ -113,17 +113,32 @@ describe("what a position refuses", () => { expect(found.ok ? "" : found.reason).toContain("cannot be used as a destination"); }); - test("an attribute of a transaction input is recognised and refused by name", () => { + test("an attribute of a transaction input is recognised as the lookup it is", () => { expect(parseReference("vault_in.amount_sat")).toEqual({ attribute: "amount_sat", form: "input-attribute", name: "vault_in", }); + }); + + // It reads what the wallet established about that input and nothing else: the chain's word + // at the outpoint it spends, or — where the input issues — what that issuance created. + test("and resolves against the inputs this action actually resolved", () => { + const found = resolveReference("vault_in.amount_sat", "amount", { + ...SCOPE, + inputs: { vault_in: { amount_sat: 50_000n } }, + }); + + expect(found).toEqual({ form: "input-attribute", ok: true, value: 50_000n }); + }); + // A name for an input nothing resolved is refused as the lookup it is, rather than falling + // through to something that happens to have that name. + test("and refuses an input this action never resolved, by name", () => { const found = resolveReference("vault_in.amount_sat", "amount", SCOPE); expect(found.ok).toBe(false); - expect(found.ok ? "" : found.reason).toContain("vault_in.amount_sat"); + expect(found.ok ? "" : found.reason).toContain("vault_in"); }); /** diff --git a/packages/tx-manifest/src/document/references.ts b/packages/tx-manifest/src/document/references.ts index 709aa4e..67393a9 100644 --- a/packages/tx-manifest/src/document/references.ts +++ b/packages/tx-manifest/src/document/references.ts @@ -15,9 +15,12 @@ import { namedUtxoTypes } from "./sites"; * `bare` is whichever of the last two has the name, and `input-attribute` is something about a * transaction input the wallet would have had to read the chain to know. * - * `input-attribute` is parsed and accepted nowhere in this slice. It is here so that a dotted - * name in an unknown namespace is recognised as the lookup it is and refused for what it is, - * rather than falling through to something that happens to resolve. + * `input-attribute` names something about a transaction input that only the wallet can know: + * what the chain reported at the outpoint it spends, or — where the input issues an asset — + * what that issuance turned out to create. It resolves against inputs this action already + * resolved and against nothing else, so a name for an input that was never resolved is + * refused as the lookup it is rather than falling through to something that happens to + * resolve. */ export type ReferenceForm = "args" | "bare" | "input-attribute" | "instance" | "params"; @@ -40,6 +43,14 @@ export type ParsedReference = { */ export type ReferenceScope = { args?: Record; + /** + * What the wallet established about each named input, keyed by the manifest's id. + * + * Written by the review as each input resolves rather than supplied by a caller: an + * input's asset and amount are things the wallet read or derived, and a caller holding + * them would be telling the wallet what it just worked out. + */ + inputs?: Record>; /** This deployment's field values. */ instance?: Record; params: Record; @@ -65,11 +76,31 @@ export type ReferenceResolution = * in another, and the difference is not detectable from the string. Listing the accepted forms * per site makes the wrong ones unrepresentable rather than a mistake to be caught downstream. */ -export type ReferenceSiteKind = "amount" | "compileParam" | "destination"; +export type ReferenceSiteKind = + | "amount" + | "asset" + | "compileParam" + | "destination" + | "issuedAmount"; const SITES: Record = { /** An output's amount, or an input's minimum. */ - amount: { accepts: ["instance", "params", "args", "bare"], describes: "an amount" }, + amount: { + accepts: ["instance", "params", "args", "input-attribute", "bare"], + describes: "an amount", + }, + /** + * The asset an input or output carries. + * + * Every form the corpus writes at this position: this deployment's fields, the request's + * parameters and arguments, a bare name, and an attribute of an input the wallet already + * resolved — `payout_in.asset`, which says "the same asset that one arrived in" without + * naming it. + */ + asset: { + accepts: ["instance", "params", "args", "input-attribute", "bare"], + describes: "an asset", + }, /** A value compiled into a contract, which therefore decides its address. */ compileParam: { accepts: ["instance", "params", "args", "bare"], @@ -77,6 +108,17 @@ const SITES: Record): CovenantSite[] { const site = covenantReference(asRecord(entry)?.utxo_source); if (site) { - sites.push({ ...site, role: "spent" }); + sites.push({ ...site, id: identifierOf(entry), role: "spent" }); } } @@ -30,7 +38,7 @@ export function covenantSites(action: Record): CovenantSite[] { const site = covenantReference(asRecord(entry)?.destination); if (site) { - sites.push({ ...site, role: "created" }); + sites.push({ ...site, id: identifierOf(entry), role: "created" }); } } @@ -60,3 +68,9 @@ function covenantReference( return { utxoType, wiring: asRecord(record?.compile_params) ?? {} }; } + +function identifierOf(entry: unknown): string { + const id = asRecord(entry)?.id; + + return typeof id === "string" ? id : ""; +} diff --git a/packages/tx-manifest/src/evaluation/assetLedger.test.ts b/packages/tx-manifest/src/evaluation/assetLedger.test.ts new file mode 100644 index 0000000..caa1420 --- /dev/null +++ b/packages/tx-manifest/src/evaluation/assetLedger.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, test } from "bun:test"; + +import multiassetManifest from "../__fixtures__/multiasset.manifest.json"; +import { findAction, type NormalisedAction, normaliseManifest } from "../document/normalise"; +import { assetLedger, type HeldValue, resolveAsset } from "./assetLedger"; +import { planAction } from "./plan"; + +const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; +const TOKEN = "ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2"; +const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + +const { manifest } = normaliseManifest(multiassetManifest as unknown as Record); + +function payToken(params: Record) { + const action = findAction(manifest, "PayToken"); + + if (!action) { + throw new Error("the fixture declares no PayToken"); + } + + const scope = { params }; + const plan = planAction(action, scope); + + if (!plan.ok) { + throw new Error(plan.reason); + } + + return { action, plan: plan.plan, scope }; +} + +function ledgerOf(params: Record, held: HeldValue[] = []) { + const { action, plan, scope } = payToken(params); + + return assetLedger(action, plan.outputs, { held, policyAsset: POLICY_ASSET, scope }); +} + +const PARAMS = { amount_sat: 1000, fee_sat: 700, pubkey: PUBKEY, token: TOKEN }; + +describe("which asset a declared site is in", () => { + const context = { policyAsset: POLICY_ASSET, scope: { params: {} } }; + + // A site that says nothing is paying in the one asset every reader of the document already + // shares, which is the one the network charges its fees in. + test("nothing stated means the asset the network charges fees in", () => { + expect(resolveAsset(undefined, "output out", context)).toEqual({ + id: POLICY_ASSET, + ok: true, + }); + }); + + test("a literal id stays exactly itself", () => { + expect(resolveAsset(TOKEN, "output out", context)).toEqual({ id: TOKEN, ok: true }); + }); + + test("and a lookup becomes whatever the deployment or the request supplied", () => { + expect( + resolveAsset("instance.PRINCIPAL", "output out", { + policyAsset: POLICY_ASSET, + scope: { instance: { PRINCIPAL: TOKEN }, params: {} }, + }), + ).toEqual({ id: TOKEN, ok: true }); + }); + + // Not knowing what is being paid in is exactly the moment not to pay. + test("a lookup nothing resolves is not an asset yet", () => { + const resolved = resolveAsset("instance.PRINCIPAL", "output out", context); + + expect(resolved.ok).toBe(false); + expect(resolved.ok ? "" : resolved.reason).toContain("instance.PRINCIPAL"); + expect(resolved.ok ? "" : resolved.reason).toContain("could not establish"); + }); + + test("and one that resolves to a second lookup is refused rather than chased", () => { + const resolved = resolveAsset("params.token", "output out", { + policyAsset: POLICY_ASSET, + scope: { params: { token: "instance.PRINCIPAL" } }, + }); + + expect(resolved.ok).toBe(false); + expect(resolved.ok ? "" : resolved.reason).toContain("another lookup"); + }); + + /** + * A literal id and a lookup are different statements, and they stay different. + * + * The corpus states an asset as a lookup far more often than as an id, so the two meet + * constantly — and a runtime that read one as the other would be answering a question + * about the spelling rather than about the money. + */ + test("a literal id and a lookup resolving elsewhere stay distinct and exact", () => { + const other = "aa".repeat(32); + const supplied = { policyAsset: POLICY_ASSET, scope: { params: { token: other } } }; + + expect(resolveAsset(TOKEN, "output one", supplied)).toEqual({ id: TOKEN, ok: true }); + expect(resolveAsset("params.token", "output two", supplied)).toEqual({ id: other, ok: true }); + }); + + // The keyword and the id are the same asset, whichever the lookup lands on. + test("a lookup that resolves to the network's own asset is that asset", () => { + expect( + resolveAsset("params.token", "output out", { + policyAsset: POLICY_ASSET, + scope: { params: { token: "lbtc" } }, + }), + ).toEqual({ id: POLICY_ASSET, ok: true }); + }); +}); + +describe("reading one action as a statement about several assets", () => { + test("keeps each asset's cost to itself rather than adding them together", () => { + const result = ledgerOf(PARAMS); + + expect(result.ok).toBe(true); + + if (result.ok) { + expect(result.ledger.entries).toEqual([ + // The network's own asset is always part of the reckoning, whether or not the + // action mentions it: the fee is paid in it and the wallet pays the fee. + { + asset: POLICY_ASSET, + change: { blinded: false, id: "change_out" }, + held: 0n, + needed: 700n, + }, + { asset: TOKEN, change: { blinded: false, id: "token_change" }, held: 0n, needed: 1000n }, + ]); + } + }); + + test("says which asset each planned output pays in, in the plan's own order", () => { + const result = ledgerOf(PARAMS); + + expect(result.ok ? result.ledger.outputs : []).toEqual([ + TOKEN, + POLICY_ASSET, + TOKEN, + POLICY_ASSET, + ]); + }); + + test("and names the wallet inputs the action needs, with the asset each is in", () => { + const result = ledgerOf(PARAMS); + + expect(result.ok ? result.ledger.walletInputs : []).toEqual([{ asset: TOKEN, id: "token_in" }]); + }); + + // What a covenant already holds is netted against what the outputs cost — within one asset + // and never across two. + test("nets what the transaction already brings, asset by asset", () => { + const result = ledgerOf(PARAMS, [ + { asset: TOKEN, id: "token_in", sats: 400n }, + { asset: POLICY_ASSET, id: "money_in", sats: 100n }, + ]); + + expect(result.ok ? result.ledger.entries : []).toEqual([ + { + asset: POLICY_ASSET, + change: { blinded: false, id: "change_out" }, + held: 100n, + needed: 700n, + }, + { asset: TOKEN, change: { blinded: false, id: "token_change" }, held: 400n, needed: 1000n }, + ]); + }); + + // A plan that ever stopped lining up with the document is refused here, rather than + // silently attributing an amount to the wrong asset. + test("refuses a plan that does not line up with the outputs the document declares", () => { + const { action, plan, scope } = payToken(PARAMS); + const result = assetLedger(action, plan.outputs.slice(1), { + held: [], + policyAsset: POLICY_ASSET, + scope, + }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reject).toBe("document-fault"); + }); + + test("and refuses an asset it cannot establish rather than assuming one", () => { + const result = ledgerOf({ ...PARAMS, token: undefined }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reject).toBe("foreign-asset"); + }); + + /** + * The check that keeps a document's word about a covenant honest. + * + * A covenant input's asset is whatever the chain says is at that outpoint. The document + * states one too, and the two disagreeing means the covenant is not holding what the + * action says it holds — which would fund the stated asset and strand the real one. + */ + test("refuses a covenant input the chain says holds a different asset", () => { + const action: NormalisedAction = { + isConstructor: false, + name: "Spend", + node: { + inputs: [{ asset: TOKEN, id: "vault_in", utxo_source: { utxo_type: "vault" } }], + outputs: [{ amount_sat: 10, destination: "wallet", id: "out" }], + }, + }; + const plan = planAction(action, { params: {} }); + + if (!plan.ok) { + throw new Error(plan.reason); + } + + const result = assetLedger(action, plan.plan.outputs, { + held: [{ asset: POLICY_ASSET, id: "vault_in", sats: 500n }], + policyAsset: POLICY_ASSET, + scope: { params: {} }, + }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("vault_in"); + }); +}); diff --git a/packages/tx-manifest/src/evaluation/assetLedger.ts b/packages/tx-manifest/src/evaluation/assetLedger.ts new file mode 100644 index 0000000..a2b96fc --- /dev/null +++ b/packages/tx-manifest/src/evaluation/assetLedger.ts @@ -0,0 +1,291 @@ +import { statedAsset } from "../document/asset"; +import { asArray, asRecord } from "../document/json"; +import type { NormalisationNote, NormalisedAction } from "../document/normalise"; +import { type ReferenceScope, resolveReference } from "../document/references"; +import type { PlannedOutput } from "./plan"; + +/** + * What one asset costs this transaction, and what the transaction already brings in it. + * + * One of these per asset, rather than one number for the whole transaction. A single running + * total is only sound while there is a single asset: added together, three units of a + * one-of-a-kind token and three thousand base units of money make six of nothing, and a wallet + * that funds six of nothing is a wallet that funds neither. + */ +export type AssetEntry = { + /** The asset id, as the chain writes it. */ + asset: string; + /** + * The declared output this asset's surplus returns to, when the document declares one. + * + * Only the asset the network charges its fees in can be left to the signing module, because + * only that one has a fee taken out of it and therefore an amount nobody knows until the + * transaction has been weighed. Every other asset's change is an exact figure, and an exact + * figure needs an output to land in. + */ + change?: { blinded: boolean; id: string }; + /** + * Base units this transaction already brings in this asset before the wallet adds any of + * its own: what the covenants it spends hold, and what its issuances create. + */ + held: bigint; + /** Base units the action's outputs pay in this asset. Change is not counted; it has no amount. */ + needed: bigint; +}; + +/** Which asset each piece of an action is in, and what each of those assets needs. */ +export type AssetLedger = { + /** Every asset this action moves, in the order a person reading the document meets it. */ + entries: AssetEntry[]; + /** The asset of each planned output, in the plan's own order. */ + outputs: string[]; + /** Every input the wallet has to find for itself, in the order the action declares them. */ + walletInputs: { asset: string; id: string }[]; +}; + +export type AssetLedgerResult = + | { ok: false; reason: string; reject: "document-fault" | "foreign-asset" } + | { ok: true; ledger: AssetLedger }; + +/** What this transaction brings in an asset without the wallet spending anything of its own. */ +export type HeldValue = { + asset: string; + /** + * Whether the transaction creates these units rather than finding them at an outpoint. + * + * One input can bring both: a covenant holding one asset, spent on the path that mints + * another, arrives here twice under one id. Both are really in the transaction and both are + * counted — but only the first is what the input *spends*, and it is the only one the + * document's word about that input can be checked against. + */ + created?: true; + /** The input this value arrives on, so a disagreement can name it. */ + id: string; + sats: bigint; +}; + +type Context = { + notes?: NormalisationNote[]; + policyAsset: string; + scope: ReferenceScope; +}; + +export type AssetResolution = { ok: false; reason: string } | { ok: true; id: string }; + +/** + * Which asset a declared `asset` field is, once the deployment and the request have been read. + * + * The corpus states an asset as a lookup far more often than as an id — every asset in every + * published protocol, in fact — so this is where most of them first become a thing rather than + * a spelling. A site that states none is stating the asset the network charges fees in: that is + * the only asset a document can leave unsaid and still be understood by everyone reading it. + */ +export function resolveAsset(declared: unknown, at: string, context: Context): AssetResolution { + if (declared === undefined) { + return { id: context.policyAsset.trim().toLowerCase(), ok: true }; + } + + if (typeof declared !== "string") { + return { ok: false, reason: `The asset at ${at} is not written as text.` }; + } + + const stated = statedAsset(declared, context.policyAsset); + + if (stated.kind === "network") { + return { id: context.policyAsset.trim().toLowerCase(), ok: true }; + } + + if (stated.kind === "identified") { + return { id: stated.id, ok: true }; + } + + const found = resolveReference(stated.reference, "asset", context.scope, context.notes); + + if (!found.ok) { + return { + ok: false, + reason: + `The asset at ${at} is stated as ${stated.reference}, and this wallet could not ` + + `establish what that is: ${found.reason}`, + }; + } + + if (typeof found.value !== "string") { + return { + ok: false, + reason: + `The asset at ${at} is stated as ${stated.reference}, which resolved to something ` + + "that is not an asset id.", + }; + } + + const resolved = statedAsset(found.value, context.policyAsset); + + if (resolved.kind === "deferred") { + return { + ok: false, + reason: + `The asset at ${at} is stated as ${stated.reference}, which resolved to ` + + `${found.value} — another lookup rather than an asset.`, + }; + } + + return { + id: resolved.kind === "network" ? context.policyAsset.trim().toLowerCase() : resolved.id, + ok: true, + }; +} + +/** + * Reads one action as a statement about several assets rather than about one amount. + * + * Everything here is a rule of the format: an output pays in the asset it states, an input + * arrives in the asset it states, a covenant holds whatever the chain says it holds, and an + * issuance creates what it declares. Nothing recognises a protocol, a deployment or a name. + * + * The plan is read positionally against the action's own outputs, which is exactly how the plan + * was built — one planned output per declared record, in order. The ids are compared as well, so + * a plan that ever stopped lining up is refused here rather than silently attributing an amount + * to the wrong asset. + */ +export function assetLedger( + action: NormalisedAction, + planned: PlannedOutput[], + context: Context & { held: HeldValue[] }, +): AssetLedgerResult { + const declaredOutputs = asArray(action.node.outputs) + .map((entry) => asRecord(entry)) + .filter((entry) => entry !== undefined); + + if (declaredOutputs.length !== planned.length) { + return { + ok: false, + reason: + `${action.name} plans ${planned.length} outputs against ${declaredOutputs.length} ` + + "declared ones, so this wallet cannot say which asset each one pays in.", + reject: "document-fault", + }; + } + + const entries = new Map(); + const entryFor = (asset: string): AssetEntry => { + const existing = entries.get(asset); + + if (existing) { + return existing; + } + + const created: AssetEntry = { asset, held: 0n, needed: 0n }; + + entries.set(asset, created); + + return created; + }; + + // The asset the network charges its fees in is always part of the reckoning, whether or not + // the action mentions it: the fee is paid in it and the wallet pays the fee. + entryFor(context.policyAsset.trim().toLowerCase()); + + const walletInputs: { asset: string; id: string }[] = []; + const outputs: string[] = []; + // Only what the chain reports, keyed by the input it arrived on. An input that issues an + // asset also reports one here, under the same id — and letting that win turns the check + // below into a comparison of the document's word against the asset this very input just + // created, which disagree for every covenant-sourced issuance and should. + const heldById = new Map( + context.held.filter((value) => value.created !== true).map((value) => [value.id, value]), + ); + + for (const entry of asArray(action.node.inputs)) { + const declared = asRecord(entry); + + if (!declared) { + continue; + } + + const id = typeof declared.id === "string" ? declared.id : "(unnamed)"; + const resolved = resolveAsset(declared.asset, `input ${id}`, context); + + if (!resolved.ok) { + return { ok: false, reason: resolved.reason, reject: "foreign-asset" }; + } + + entryFor(resolved.id); + + if (typeof asRecord(declared.utxo_source)?.utxo_type === "string") { + // A covenant input's asset is whatever the chain says is at that outpoint. The document + // states one too, and the two disagreeing means the covenant is not holding what the + // action says it holds — which would fund the stated asset and strand the real one. + const held = heldById.get(id); + + if (declared.asset !== undefined && held && held.asset !== resolved.id) { + return { + ok: false, + reason: + `${action.name} says input ${id} is in ${resolved.id}, and the output it spends ` + + `holds ${held.asset}.`, + reject: "foreign-asset", + }; + } + + continue; + } + + walletInputs.push({ asset: resolved.id, id }); + } + + for (const [at, declared] of declaredOutputs.entries()) { + const output = planned[at]; + + if (!output) { + continue; + } + + const id = typeof declared.id === "string" ? declared.id : ""; + + if (id !== output.id) { + return { + ok: false, + reason: + `${action.name} declares ${id || "(unnamed)"} where its plan has ` + + `${output.id || "(unnamed)"}, so this wallet cannot say which asset that output ` + + "pays in.", + reject: "document-fault", + }; + } + + const resolved = resolveAsset(declared.asset, `output ${id || "(unnamed)"}`, context); + + if (!resolved.ok) { + return { ok: false, reason: resolved.reason, reject: "foreign-asset" }; + } + + const entry = entryFor(resolved.id); + + outputs.push(resolved.id); + + if (output.target.kind === "change") { + // The first one wins. A document declaring two change outputs for one asset is + // declaring one place for its surplus twice, and splitting a surplus between them + // would be the wallet deciding something the document did not say. + entry.change ??= { blinded: output.blinding.blinding === "hidden", id }; + + continue; + } + + entry.needed += output.sats ?? 0n; + } + + for (const value of context.held) { + entryFor(value.asset).held += value.sats; + } + + return { + ledger: { + entries: [...entries.values()], + outputs, + walletInputs, + }, + ok: true, + }; +} diff --git a/packages/tx-manifest/src/evaluation/blinding.test.ts b/packages/tx-manifest/src/evaluation/blinding.test.ts new file mode 100644 index 0000000..248aa9b --- /dev/null +++ b/packages/tx-manifest/src/evaluation/blinding.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test"; + +import { resolveBlinding } from "./blinding"; + +describe("the order the format resolves blinding in", () => { + test("the output's own word comes first, over the document's", () => { + expect(resolveBlinding({ declared: false, documentDefault: true })).toEqual({ + blinding: "open", + decidedBy: "output", + }); + expect(resolveBlinding({ declared: true, documentDefault: false })).toEqual({ + blinding: "hidden", + decidedBy: "output", + }); + }); + + test("the document's word comes next, when the output says nothing", () => { + expect(resolveBlinding({ documentDefault: false })).toEqual({ + blinding: "open", + decidedBy: "document", + }); + }); + + // The step that makes silence a decision. On Liquid an output nobody spoke about is hidden, + // so a runtime that read the first two steps and stopped would build the opposite. + test("and silence means hidden, because that is this network's own default", () => { + expect(resolveBlinding({})).toEqual({ blinding: "hidden", decidedBy: "chain" }); + }); + + // Before the precedence is consulted at all: a Simplicity program reads exact amounts + // through jets that cannot introspect a commitment, and an OP_RETURN carries no value. + test("a covenant output and an OP_RETURN are open whatever anything says", () => { + expect(resolveBlinding({ declared: true, unblindable: "covenant" })).toEqual({ + blinding: "open", + decidedBy: "unblindable", + }); + expect(resolveBlinding({ documentDefault: true, unblindable: "data" })).toEqual({ + blinding: "open", + decidedBy: "unblindable", + }); + }); +}); + +/** + * The one deviation, and what it costs. + * + * A contract action can be funded only by outputs that hide nothing: unblinding one needs the + * secrets that go with it, and the signing module is handed an outpoint and its bytes and + * nothing more. So change returned hidden is money the next action cannot reach, and a sequence + * of actions starves itself after the first. + * + * The wallet publishes it instead. That is against the format, which says silence about + * confidentiality is itself a decision and that this network's decision is to hide, and the + * change amount is on the chain as a result. The word that was set aside is carried out of here + * so a person can be told which one it was. + */ +describe("what this wallet does with a contract action's own change", () => { + test("publishes it, over the network's default that would have hidden it", () => { + expect(resolveBlinding({ change: true })).toEqual({ + blinding: "open", + decidedBy: "spendable-change", + overrode: "chain", + }); + }); + + test("and over the document's own default, carrying that word instead", () => { + expect(resolveBlinding({ change: true, documentDefault: true })).toEqual({ + blinding: "open", + decidedBy: "spendable-change", + overrode: "document", + }); + }); + + // The case a person is owed the most: the protocol asked for this outright and the wallet + // published it anyway, because honouring the request would have stranded their money. + test("and over the protocol asking for it outright, carrying that word instead", () => { + expect(resolveBlinding({ change: true, declared: true })).toEqual({ + blinding: "open", + decidedBy: "spendable-change", + overrode: "output", + }); + }); + + // Only where the format would have hidden. A protocol asking for open change is agreed + // with, and nothing was overridden, so nothing claims to have been. + test("but overrides nothing when the protocol asked for open change itself", () => { + expect(resolveBlinding({ change: true, declared: false })).toEqual({ + blinding: "open", + decidedBy: "output", + }); + }); + + // The deviation is exactly this wide. An output paid to the wallet is not change, however + // much it looks like money coming back, and it is left hidden where the format hides it. + test("and reaches nothing that is not change", () => { + expect(resolveBlinding({})).toEqual({ blinding: "hidden", decidedBy: "chain" }); + expect(resolveBlinding({ declared: true })).toEqual({ + blinding: "hidden", + decidedBy: "output", + }); + }); +}); diff --git a/packages/tx-manifest/src/evaluation/blinding.ts b/packages/tx-manifest/src/evaluation/blinding.ts new file mode 100644 index 0000000..e1ce2fc --- /dev/null +++ b/packages/tx-manifest/src/evaluation/blinding.ts @@ -0,0 +1,112 @@ +/** + * Whether one output hides what it carries. + * + * The format states the order and it is short: the output's own word, then the document's + * file-level word, then the chain's. On Liquid the chain's word is that an output is hidden, + * which makes silence a decision rather than an absence — and a runtime that read the first + * two and stopped would build an open output for every document that says nothing, which is + * every document in the published corpus. + * + * One destination is answered against that order rather than by it, and it is the only one: + * a contract action's own change. See `resolveBlinding` for what that costs and why it was + * chosen anyway. + */ + +/** What an output does with the value it carries. */ +export type Blinding = "hidden" | "open"; + +/** + * Whose word decided an output's blinding, or which rule answered instead of a word. + * + * The first three are the format's precedence. `unblindable` is a destination that could + * never hide whatever anyone says. `spendable-change` is this wallet's own rule, and it is + * the one place the wallet answers over the format rather than under it. + */ +export type BlindingWord = "chain" | "document" | "output" | "spendable-change" | "unblindable"; + +/** + * Where an output's blinding was decided, so a refusal can say whose word it was. + * + * The word matters more than the answer: "this protocol asked for it" and "nobody said, and + * the network's own default is to hide" are the same outcome and different sentences, and a + * person deciding whether to trust a site is owed the difference. + */ +export type BlindingDecision = { + blinding: Blinding; + decidedBy: BlindingWord; + /** + * The word this wallet set aside, present only where it overrode the format. + * + * Carried rather than dropped because publishing an amount the protocol asked to hide and + * publishing one nobody spoke about are the same output and not the same sentence, and the + * person is owed that difference here for exactly the reason they are owed it above. + */ + overrode?: BlindingWord; +}; + +/** A destination that can never hide what it carries, whatever anything says. */ +export type UnblindableTarget = "covenant" | "data"; + +/** + * Resolves one output's blinding by the precedence the format defines. + * + * A covenant output and an OP_RETURN are answered before the precedence is consulted at all. + * A Simplicity program reads exact amounts and asset ids through jets that cannot introspect a + * commitment, so a hidden covenant output is one its own contract could never check; an + * OP_RETURN carries bytes rather than value and has nothing to hide. + * + * A contract action's own change is answered after it, and against it. This is a deliberate + * deviation from the format and the only one: the format says an output's silence about + * confidentiality is itself a decision, and that on this network the decision is to hide. The + * wallet keeps that rule everywhere else and breaks it here, so the change amount is published + * on chain where the format would have kept it. That is the price and it was accepted knowingly. + * + * What it buys is that the money comes back spendable. A contract action can be funded only by + * outputs that hide nothing — unblinding one needs the secrets that go with it, and the signing + * module is handed an outpoint and its bytes and nothing more — so change returned hidden is + * money the next action cannot reach, and a sequence of actions starves itself after the first. + * + * The deviation is exactly this wide: change, and nothing else. It fires only where the format + * would have hidden, so a protocol that asks for its change in the open is simply agreed with, + * and it never touches an output that pays anywhere but back to this person. + */ +export function resolveBlinding(input: { + /** Set when this output is the action's own change, which the wallet returns spendable. */ + change?: boolean; + /** The output's own declaration, when it states one. */ + declared?: unknown; + /** The document's file-level default, when it states one. */ + documentDefault?: unknown; + /** Set when the destination cannot hide anything whatever the document says. */ + unblindable?: UnblindableTarget; +}): BlindingDecision { + if (input.unblindable) { + return { blinding: "open", decidedBy: "unblindable" }; + } + + const format = byPrecedence(input); + + // Only where the format would have hidden. Where it already answers open there is nothing + // to override and no deviation to declare — the protocol and this wallet agree. + if (input.change && format.blinding === "hidden") { + return { blinding: "open", decidedBy: "spendable-change", overrode: format.decidedBy }; + } + + return format; +} + +/** The order the format itself defines, with nothing of this wallet's in it. */ +function byPrecedence(input: { declared?: unknown; documentDefault?: unknown }): BlindingDecision { + if (typeof input.declared === "boolean") { + return { blinding: input.declared ? "hidden" : "open", decidedBy: "output" }; + } + + if (typeof input.documentDefault === "boolean") { + return { blinding: input.documentDefault ? "hidden" : "open", decidedBy: "document" }; + } + + // Liquid hides by default. The format also defines a Bitcoin default of open, and this + // runtime builds Liquid transactions and refuses every other chain before reaching here, + // so there is no second branch to write rather than a branch left unwritten. + return { blinding: "hidden", decidedBy: "chain" }; +} diff --git a/packages/tx-manifest/src/evaluation/issuance.test.ts b/packages/tx-manifest/src/evaluation/issuance.test.ts new file mode 100644 index 0000000..13b37cf --- /dev/null +++ b/packages/tx-manifest/src/evaluation/issuance.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, test } from "bun:test"; + +import { deriveNewIssuance } from "../chain/issuance"; +import { declaredIssuance, issuanceAttributes, resolveIssuance } from "./issuance"; + +const OUTPOINT = { txid: "c".repeat(64), vout: 2 }; +const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + +function resolve(declared: Record, params: Record = {}) { + return resolveIssuance({ declared, id: "mint_in", outpoint: OUTPOINT }, { params }); +} + +describe("the issuance an input declares", () => { + test("is found where the input declares one, and nowhere else", () => { + expect(declaredIssuance({ issuance: { kind: "new" } })).toEqual({ kind: "new" }); + expect(declaredIssuance({ id: "funding_in" })).toBeUndefined(); + }); +}); + +describe("what this wallet will mint", () => { + // The whole capability: a new issuance of an explicit, positive amount, with no + // reissuance token minted alongside it. + test("a new asset, derived from the very output the input spends", () => { + const result = resolve({ asset_amount_sat: 1000, kind: "new" }); + const derived = deriveNewIssuance(OUTPOINT); + + expect(result.ok).toBe(true); + + if (result.ok) { + expect(result.issuance).toEqual({ + asset: derived?.asset ?? "", + assetAmountSats: 1000n, + entropy: derived?.entropy ?? "", + inflationAmountSats: 0n, + inputId: "mint_in", + kind: "new", + outpoint: OUTPOINT, + reissuanceToken: derived?.reissuanceToken ?? "", + }); + } + }); + + test("with the amount taken from the request where the document names one", () => { + const result = resolve({ asset_amount_sat: "params.supply", kind: "new" }, { supply: 21 }); + + expect(result.ok ? result.issuance.assetAmountSats : 0n).toBe(21n); + }); + + // Base units stay exact end to end; a double rounds past 2^53. + test("and keeps a supply beyond a double's range exact", () => { + const result = resolve( + { asset_amount_sat: "params.supply", kind: "new" }, + { supply: "9007199254740993" }, + ); + + expect(result.ok ? result.issuance.assetAmountSats : 0n).toBe(9_007_199_254_740_993n); + }); + + // Moving the issuance to another output mints a different asset, which is the whole + // reason the outpoint is settled before anything else. + test("changing the output it derives from changes the asset", () => { + const here = resolve({ asset_amount_sat: 1, kind: "new" }); + const there = resolveIssuance( + { + declared: { asset_amount_sat: 1, kind: "new" }, + id: "mint_in", + outpoint: { ...OUTPOINT, vout: 3 }, + }, + { params: {} }, + ); + + expect(here.ok && there.ok && here.issuance.asset === there.issuance.asset).toBe(false); + }); +}); + +describe("what it will not mint, and says so rather than modelling", () => { + /** + * A reissuance mints an asset that already exists, so it is derived from the entropy the + * first issuance left behind rather than from anything in this transaction — and that + * entropy reaches a request nowhere. Deriving it from this input's outpoint instead would + * mint a different asset under the protocol's name. + */ + test("a reissuance, because it has nothing to derive the asset from", () => { + const result = resolve({ asset_amount_sat: 1000, kind: "reissue" }); + + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.reject).toBe("unimplemented-construct"); + expect(result.reason).toContain("entropy"); + expect(result.reason).toContain("mint_in"); + } + }); + + /** + * Liquid requires a reissuance token to be held confidentially, and this path builds + * transactions whose values are all explicit — a covenant cannot introspect a blinded + * value, which is why the whole path is explicit. Minting one anyway produces a token + * nobody can spend. + */ + test("a reissuance token, because it would have to be confidential to be spendable", () => { + const result = resolve({ asset_amount_sat: 1000, inflation_amount_sat: 1, kind: "new" }); + + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.reject).toBe("unimplemented-construct"); + expect(result.reason).toContain("confidential"); + } + }); + + // Zero is the case this wallet does run, and it is not the same as one. + test("but a stated zero of them is the ordinary case and is built", () => { + expect(resolve({ asset_amount_sat: 1000, inflation_amount_sat: 0, kind: "new" }).ok).toBe(true); + }); + + test("an issuance of no units, which creates no asset", () => { + const result = resolve({ asset_amount_sat: 0, kind: "new" }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reject).toBe("document-fault"); + }); + + test("a kind the format does not define", () => { + const result = resolve({ asset_amount_sat: 1000, kind: "burn" }); + + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.reject).toBe("document-fault"); + expect(result.reason).toContain('"burn"'); + } + }); + + test("an amount it cannot work out, rather than one nobody chose", () => { + const result = resolve({ asset_amount_sat: "params.supply", kind: "new" }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reject).toBe("document-fault"); + }); + + test("and an outpoint that is not one", () => { + const result = resolveIssuance( + { + declared: { asset_amount_sat: 1, kind: "new" }, + id: "mint_in", + outpoint: { txid: "aabb", vout: 0 }, + }, + { params: {} }, + ); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("aabb"); + }); +}); + +/** + * An issuing input's `asset` is what it creates, not what the output it spends held. + * + * That is the whole reason a protocol writes the name: an action that mints a token and pays + * it out has no other way to say which asset the output pays in. + */ +describe("what an issuance says about itself, for a later name to read", () => { + test("the asset it creates and the token that would reissue it", () => { + const result = resolve({ asset_amount_sat: 1000, kind: "new" }); + + expect(result.ok).toBe(true); + + if (result.ok) { + expect(issuanceAttributes(result.issuance)).toEqual({ + asset: result.issuance.asset, + reissuance_token: result.issuance.reissuanceToken, + }); + expect(result.issuance.asset).not.toBe(PUBKEY); + } + }); +}); diff --git a/packages/tx-manifest/src/evaluation/issuance.ts b/packages/tx-manifest/src/evaluation/issuance.ts new file mode 100644 index 0000000..e473b93 --- /dev/null +++ b/packages/tx-manifest/src/evaluation/issuance.ts @@ -0,0 +1,222 @@ +import { type DerivedIssuance, deriveNewIssuance, type Outpoint } from "../chain/issuance"; +import { asRecord } from "../document/json"; +import type { NormalisationNote } from "../document/normalise"; +import { type ReferenceScope, resolveReference } from "../document/references"; + +/** + * What one input's issuance block asks for, once its amounts are worked out. + * + * The kind is narrowed to the one this wallet can carry out. A reissuance is refused rather + * than represented, because a value that stands for something the runtime will not do is a + * value some later branch treats as a case to handle. + */ +export type IssuanceRequest = { + /** Units of the asset to create. */ + assetAmountSats: bigint; + /** Units of the token that would authorise reissuing it. Always zero here; see below. */ + inflationAmountSats: bigint; + kind: "new"; +}; + +/** One input's issuance, worked out and derived against the output that input spends. */ +export type PlannedIssuance = DerivedIssuance & + IssuanceRequest & { + /** The manifest's id for the input carrying it. */ + inputId: string; + /** The output the asset is derived from, which is what makes the id what it is. */ + outpoint: Outpoint; + }; + +export type IssuanceResult = + | { issuance: PlannedIssuance; ok: true } + | { ok: false; reason: string; reject: IssuanceReject }; + +/** + * Why an issuance was refused: the document is wrong, or the wallet will not mint that. + * + * Two words rather than one because they are answers to different questions. A document + * fault is something whoever wrote the manifest can fix. An unimplemented construct is the + * format asking for something this wallet has deliberately not built, and no edit to the + * document makes it buildable here. + */ +export type IssuanceReject = "document-fault" | "unimplemented-construct"; + +/** The issuance an input declares, if it declares one. */ +export function declaredIssuance( + input: Record, +): Record | undefined { + return asRecord(input.issuance); +} + +/** + * Works out what an input's issuance creates, and from which of the wallet's outputs. + * + * Every refusal here is about what this wallet will not mint rather than about a malformed + * document, which is why they name the construct: a protocol whose asset can only exist as a + * blinded one is not a protocol this wallet builds badly, it is one it does not build. + */ +export function resolveIssuance( + input: { + declared: Record; + id: string; + outpoint: Outpoint; + }, + scope: ReferenceScope, + notes?: NormalisationNote[], +): IssuanceResult { + const kind = input.declared.kind; + + // The format defines two kinds and this wallet carries out one. A reissuance mints an + // asset that already exists, so it is derived from the entropy the first issuance left + // behind rather than from anything in this transaction — and that entropy reaches a + // request only on a supplied input, which this wallet does not read. Deriving it from + // this input's outpoint instead would mint a different asset under the protocol's name. + if (kind === "reissue") { + return { + ok: false, + reason: + `Input ${input.id} reissues an asset, and this wallet has nothing to derive it from: ` + + "the entropy of the original issuance is not part of what a site sends it.", + reject: "unimplemented-construct", + }; + } + + if (kind !== "new") { + return { + ok: false, + reason: + `Input ${input.id} declares an issuance of kind ${JSON.stringify(kind)}, and the ` + + 'format defines "new" and "reissue".', + reject: "document-fault", + }; + } + + const assetAmount = amountOf(input.declared.asset_amount_sat, scope, notes); + + if (!assetAmount.ok) { + return { + ok: false, + reason: `Input ${input.id} does not say how much it issues: ${assetAmount.reason}`, + reject: "document-fault", + }; + } + + if (assetAmount.value <= 0n) { + return { + ok: false, + reason: `Input ${input.id} issues ${assetAmount.value} units, which creates no asset.`, + reject: "document-fault", + }; + } + + const inflation = + input.declared.inflation_amount_sat === undefined + ? { ok: true as const, value: 0n } + : amountOf(input.declared.inflation_amount_sat, scope, notes); + + if (!inflation.ok) { + return { + ok: false, + reason: `Input ${input.id} does not say how many reissuance tokens it mints: ${inflation.reason}`, + reject: "document-fault", + }; + } + + // Liquid requires a reissuance token to be held confidentially, and this wallet builds + // explicit transactions — a covenant cannot introspect a blinded value, which is why the + // whole path is explicit. Minting one anyway would produce a token nobody can spend. + if (inflation.value !== 0n) { + return { + ok: false, + reason: + `Input ${input.id} mints ${inflation.value} reissuance tokens, which have to be held ` + + "confidentially, and this wallet builds transactions whose values are all explicit.", + reject: "unimplemented-construct", + }; + } + + const derived = deriveNewIssuance(input.outpoint); + + if (!derived) { + return { + ok: false, + reason: + `Input ${input.id} issues an asset from ${input.outpoint.txid}:${input.outpoint.vout}, ` + + "which is not an output this wallet can read.", + reject: "document-fault", + }; + } + + return { + issuance: { + ...derived, + assetAmountSats: assetAmount.value, + inflationAmountSats: 0n, + inputId: input.id, + kind: "new", + outpoint: input.outpoint, + }, + ok: true, + }; +} + +/** + * What an issuance says about its own values, for a later expression to read. + * + * Two bare names mean the input being resolved, and this is what they resolve to. The asset + * is the issued one rather than the one the spent output held: an issuing input's `asset` is + * what it creates, which is the whole reason a protocol writes the hook. + */ +export function issuanceAttributes(issuance: PlannedIssuance): Record { + return { asset: issuance.asset, reissuance_token: issuance.reissuanceToken }; +} + +/** + * A literal count, or a lookup the issued-amount site accepts. + * + * The site accepts what a compile parameter accepts and no more: this deployment's fields, + * the request's parameters and arguments, and a bare name. The fee is not among them because + * the fee comes from the shape of the transaction, and how much of an asset exists cannot + * depend on what it costs to say so; an attribute of a resolved input is not among them + * because this issuance is what makes that input's asset what it is. + */ +function amountOf( + declared: unknown, + scope: ReferenceScope, + notes?: NormalisationNote[], +): { ok: false; reason: string } | { ok: true; value: bigint } { + const literal = asCount(declared); + + if (literal !== undefined) { + return { ok: true, value: literal }; + } + + if (typeof declared !== "string") { + return { ok: false, reason: "it is neither a number nor a name." }; + } + + const found = resolveReference(declared, "issuedAmount", scope, notes); + + if (!found.ok) { + return { ok: false, reason: found.reason }; + } + + const resolved = asCount(found.value); + + return resolved === undefined + ? { ok: false, reason: `${declared} resolved to something that is not a count of units.` } + : { ok: true, value: resolved }; +} + +/** A whole number of base units, however the document or the request spelled it. */ +function asCount(value: unknown): bigint | undefined { + if (typeof value === "bigint") { + return value; + } + + if (typeof value === "number") { + return Number.isSafeInteger(value) ? BigInt(value) : undefined; + } + + return typeof value === "string" && /^-?\d+$/.test(value) ? BigInt(value) : undefined; +} diff --git a/packages/tx-manifest/src/evaluation/plan.test.ts b/packages/tx-manifest/src/evaluation/plan.test.ts index 63cb00e..16aab2e 100644 --- a/packages/tx-manifest/src/evaluation/plan.test.ts +++ b/packages/tx-manifest/src/evaluation/plan.test.ts @@ -1,12 +1,20 @@ import { describe, expect, test } from "bun:test"; import p2pkManifest from "../__fixtures__/p2pk.manifest.json"; +import type { NormalisedAction } from "../document/normalise"; import type { ReferenceScope } from "../document/references"; import { planAction } from "./plan"; const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; const MANIFEST = p2pkManifest as unknown as Record; -const PAY = (MANIFEST.actions as Record>).Pay; +const PAY = action( + "Pay", + (MANIFEST.actions as Record>).Pay as Record, +); + +function action(name: string, node: Record): NormalisedAction { + return { isConstructor: false, name, node }; +} function scope(params: Record): ReferenceScope { return { params }; @@ -22,7 +30,10 @@ describe("planAction", () => { if (result.ok) { expect(result.plan.fundingSats).toBe(50_000n); + // A covenant output could never hide what it carries, whatever the document says, + // so its blinding is answered before the format's own order is consulted. expect(result.plan.outputs).toContainEqual({ + blinding: { blinding: "open", decidedBy: "unblindable" }, id: "p2pk_out", sats: 50_000n, target: { kind: "covenant", utxoType: "p2pk_output" }, @@ -78,7 +89,7 @@ describe("planAction", () => { test("refuses a destination it does not resolve", () => { const result = planAction( - { outputs: [{ amount_sat: 1, destination: { if: "something" }, id: "odd" }] }, + action("Odd", { outputs: [{ amount_sat: 1, destination: { if: "something" }, id: "odd" }] }), scope({ amount_sat: 1, pubkey: PUBKEY }), ); @@ -86,7 +97,10 @@ describe("planAction", () => { }); test("refuses an action with no outputs", () => { - const result = planAction({ outputs: [] }, scope({ amount_sat: 1, pubkey: PUBKEY })); + const result = planAction( + action("Empty", { outputs: [] }), + scope({ amount_sat: 1, pubkey: PUBKEY }), + ); expect(result).toMatchObject({ ok: false }); }); diff --git a/packages/tx-manifest/src/evaluation/plan.ts b/packages/tx-manifest/src/evaluation/plan.ts index 5a330a5..448f22e 100644 --- a/packages/tx-manifest/src/evaluation/plan.ts +++ b/packages/tx-manifest/src/evaluation/plan.ts @@ -1,6 +1,7 @@ import { asArray, asRecord } from "../document/json"; -import type { NormalisationNote } from "../document/normalise"; +import type { NormalisationNote, NormalisedAction } from "../document/normalise"; import { type ReferenceScope, resolveReference } from "../document/references"; +import { type BlindingDecision, resolveBlinding } from "./blinding"; /** * A concrete amount the wallet worked out for one of the action's outputs. @@ -9,12 +10,18 @@ import { type ReferenceScope, resolveReference } from "../document/references"; * 2^53 is representable in a transaction and not in a double. */ export type PlannedOutput = { + /** Whether this output hides what it carries, and whose word decided that. */ + blinding: BlindingDecision; /** The manifest's id for this output, for anything that has to name it. */ id: string; /** Absent for change, whose amount is whatever is left after the fee. */ sats?: bigint; - /** Where it pays: a covenant type the wallet derived, the wallet, or change. */ - target: { kind: "change" } | { kind: "covenant"; utxoType: string } | { kind: "wallet" }; + /** Where it pays: a covenant type the wallet derived, the wallet, change, or nowhere. */ + target: + | { kind: "change" } + | { kind: "covenant"; utxoType: string } + | { kind: "data"; hex: string } + | { kind: "wallet" }; }; export type PlannedSpend = { @@ -37,14 +44,16 @@ export type PlanResult = { ok: false; reason: string } | { ok: true; plan: Plann * evaluate is a refusal naming the output rather than a number nobody chose. */ export function planAction( - action: Record, + action: NormalisedAction, scope: ReferenceScope, notes?: NormalisationNote[], + /** The document's file-level blinding default, which no published manifest states. */ + documentDefault?: unknown, ): PlanResult { const outputs: PlannedOutput[] = []; let fundingSats = 0n; - for (const declared of asArray(action.outputs)) { + for (const declared of asArray(action.node.outputs)) { const output = asRecord(declared); if (!output) { @@ -52,7 +61,7 @@ export function planAction( } const id = typeof output.id === "string" ? output.id : ""; - const target = resolveTarget(output.destination); + const target = resolveTarget(output.destination, output.data); if (!target) { return { @@ -61,8 +70,34 @@ export function planAction( }; } + // Decided here rather than answered later, because this is the one place that knows an + // output is the action's own change while the document's word about hiding it is still + // in hand. The resolver publishes that one and carries the word it set aside; see there + // for why the trade was made. + const blinding = resolveBlinding({ + declared: output.confidential, + documentDefault, + ...(target.kind === "change" ? { change: true } : {}), + ...(target.kind === "covenant" + ? { unblindable: "covenant" as const } + : target.kind === "data" + ? { unblindable: "data" as const } + : {}), + }); + if (target.kind === "change") { - outputs.push({ id, target }); + outputs.push({ blinding, id, target }); + + continue; + } + + // An op_return carries bytes rather than value and almost always pays nothing. A + // document that states an amount at one is burning it: paying an asset to a provably + // unspendable output is how a token is destroyed, and there is no other way to do it. + // Dropping the amount would leave the transaction still holding what the action + // declared gone, which is a transaction nothing can balance. + if (target.kind === "data" && output.amount_sat === undefined) { + outputs.push({ blinding, id, sats: 0n, target }); continue; } @@ -81,7 +116,7 @@ export function planAction( } fundingSats += amount; - outputs.push({ id, sats: amount, target }); + outputs.push({ blinding, id, sats: amount, target }); } if (outputs.length === 0) { @@ -91,7 +126,7 @@ export function planAction( return { ok: true, plan: { fundingSats, outputs } }; } -function resolveTarget(destination: unknown): PlannedOutput["target"] | undefined { +function resolveTarget(destination: unknown, data: unknown): PlannedOutput["target"] | undefined { if (destination === "change") { return { kind: "change" }; } @@ -100,9 +135,22 @@ function resolveTarget(destination: unknown): PlannedOutput["target"] | undefine return { kind: "wallet" }; } - const utxoType = asRecord(destination)?.utxo_type; + const record = asRecord(destination); + const utxoType = record?.utxo_type; + + if (typeof utxoType === "string") { + return { kind: "covenant", utxoType }; + } - return typeof utxoType === "string" ? { kind: "covenant", utxoType } : undefined; + // A burn states no payload at all. The output exists to hold value where nothing can spend + // it rather than to publish anything, and `6a` on its own is that script: an output whose + // first opcode is OP_RETURN cannot be spent by anyone, which is the whole of what a burn + // needs. An op_return that does carry a payload is a published record, and encoding one is + // a vocabulary of typed parts this slice does not read — so it is refused by name below + // rather than published as an empty burn that would destroy the value instead. + return record?.type === "op_return" && data === undefined + ? { hex: "6a", kind: "data" } + : undefined; } /** diff --git a/packages/tx-manifest/src/review/assetFunding.test.ts b/packages/tx-manifest/src/review/assetFunding.test.ts new file mode 100644 index 0000000..be82424 --- /dev/null +++ b/packages/tx-manifest/src/review/assetFunding.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from "bun:test"; + +import type { AssetEntry } from "../evaluation/assetLedger"; +import { fundAssets } from "./assetFunding"; +import type { SelectableUtxo } from "./coinSelection"; + +const POLICY = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; +const TOKEN = "aa".repeat(32); + +function utxo(amount: string, overrides: Partial = {}): SelectableUtxo { + return { + amount, + spendable: true, + txid: amount.padStart(64, "0"), + txOut: "00", + vout: 0, + ...overrides, + }; +} + +function entry(asset: string, needed: bigint, overrides: Partial = {}): AssetEntry { + return { asset, held: 0n, needed, ...overrides }; +} + +const CHANGE = { blinded: true, id: "token_change" }; + +function fund(entries: AssetEntry[], holdings: Record, feeSats = 500n) { + return fundAssets(entries, { + feeSats, + headroomSats: 0n, + holdings: (asset) => holdings[asset] ?? [], + policyAsset: POLICY, + reserved: [], + }); +} + +describe("funding an action asset by asset", () => { + test("takes each asset out of what the wallet holds in that one", () => { + const result = fund([entry(POLICY, 0n), entry(TOKEN, 1000n, { change: CHANGE })], { + [POLICY]: [utxo("900")], + [TOKEN]: [utxo("1500")], + }); + + expect( + result.ok && result.funded.map((funded) => funded.selected.map((one) => one.amount)), + ).toEqual([["900"], ["1500"]]); + }); + + // The fee is charged in one asset and is added to that one alone. A second asset picking up + // a second fee would make the wallet demand money nobody is asking for. + test("adds the fee to the network's own asset and to no other", () => { + const short = fund([entry(POLICY, 0n)], { [POLICY]: [utxo("400")] }, 500n); + const exact = fund([entry(TOKEN, 400n, { change: CHANGE })], { [TOKEN]: [utxo("400")] }, 500n); + + expect(short.ok).toBe(false); + expect(exact.ok).toBe(true); + }); + + test("returns the exact surplus of every asset but the network's own", () => { + const result = fund([entry(POLICY, 0n), entry(TOKEN, 1000n, { change: CHANGE })], { + [POLICY]: [utxo("9000")], + [TOKEN]: [utxo("1500")], + }); + + expect(result.ok && result.funded.map((funded) => funded.changeSats)).toEqual([0n, 500n]); + }); + + // What a covenant already holds is what the wallet does not have to find. Netting it per + // asset is what lets an action pay out of the covenant it spends. + test("counts what the transaction already brings before asking the wallet for anything", () => { + const result = fund([entry(POLICY, 0n), entry(TOKEN, 1000n, { held: 1000n })], { + [POLICY]: [utxo("9000")], + }); + + expect(result.ok && result.funded[1]?.selected).toEqual([]); + }); + + test("and asks for only the difference when it brings some of it", () => { + const result = fund([entry(POLICY, 0n), entry(TOKEN, 1000n, { change: CHANGE, held: 600n })], { + [POLICY]: [utxo("9000")], + [TOKEN]: [utxo("500"), utxo("50")], + }); + + // Four hundred short, and the largest single output covers it. A wallet that had ignored + // what the covenant holds would have taken both and still been short. + expect(result.ok && result.funded[1]?.selected.map((one) => one.amount)).toEqual(["500"]); + expect(result.ok && result.funded[1]?.changeSats).toBe(100n); + }); +}); + +describe("when one asset cannot be funded", () => { + test("the refusal names the asset and what the account holds of it", () => { + const result = fund([entry(POLICY, 0n), entry(TOKEN, 1000n)], { + [POLICY]: [utxo("9000")], + [TOKEN]: [utxo("40")], + }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain(TOKEN); + expect(result.ok ? "" : result.reason).toContain("40"); + expect(result.ok ? "" : result.reject).toBe("shortfall"); + }); + + // The refusal for another asset never mentions the fee, because no fee is charged in it. + test("and never explains a shortfall in one asset by the fee charged in another", () => { + const result = fund([entry(TOKEN, 1000n)], { [TOKEN]: [utxo("40")] }); + + expect(result.ok ? "" : result.reason).not.toContain("fee"); + }); + + test("a confidential holding is named as held back rather than counted", () => { + const result = fund([entry(TOKEN, 1000n)], { + [TOKEN]: [utxo("5000", { confidential: true })], + }); + + expect(result.ok ? "" : result.reason).toContain("5000"); + expect(result.ok ? "" : result.reason).toContain("unblinded address"); + }); + + // Surplus with nowhere declared to go is value the transaction would destroy, so it is + // refused rather than built. The network's own asset is exempt: its surplus is the fee's. + test("a surplus the document declares no change output for is refused", () => { + const result = fund([entry(TOKEN, 1000n)], { [TOKEN]: [utxo("1500")] }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("500"); + expect(result.ok ? "" : result.reject).toBe("document-fault"); + }); + + test("and the same surplus in the network's own asset is not, because the fee takes it", () => { + const result = fund([entry(POLICY, 1000n)], { [POLICY]: [utxo("9000")] }); + + expect(result.ok).toBe(true); + }); +}); + +describe("an output already committed to for an issuance", () => { + const reserved = utxo("700"); + + function withReserved(entries: AssetEntry[], holdings: Record) { + return fundAssets(entries, { + feeSats: 0n, + headroomSats: 0n, + holdings: (asset) => holdings[asset] ?? [], + policyAsset: POLICY, + reserved: [{ asset: TOKEN, utxo: reserved }], + }); + } + + // It is an input of this transaction whether or not the arithmetic would have chosen it, so + // what it brings counts and it is never chosen twice. + test("counts towards its own asset and is not selected again", () => { + const result = withReserved([entry(TOKEN, 1000n, { change: CHANGE })], { + [TOKEN]: [reserved, utxo("400")], + }); + + expect(result.ok && result.funded[0]?.selected.map((one) => one.amount)).toEqual([ + "700", + "400", + ]); + expect(result.ok && result.funded[0]?.changeSats).toBe(100n); + }); + + test("and comes first, because the asset it mints is a statement about that output", () => { + const result = withReserved([entry(TOKEN, 2000n, { change: CHANGE })], { + [TOKEN]: [reserved, utxo("5000")], + }); + + expect(result.ok && result.funded[0]?.selected[0]).toBe(reserved); + }); +}); diff --git a/packages/tx-manifest/src/review/assetFunding.ts b/packages/tx-manifest/src/review/assetFunding.ts new file mode 100644 index 0000000..b959b19 --- /dev/null +++ b/packages/tx-manifest/src/review/assetFunding.ts @@ -0,0 +1,158 @@ +import { byOutpoint, outpointKey } from "../chain/outpoint"; +import type { AssetEntry } from "../evaluation/assetLedger"; +import { type SelectableUtxo, selectCoins, toSats, withheldSentence } from "./coinSelection"; + +/** The wallet's spendable outputs in one asset, asked for by the id the chain knows it as. */ +export type AssetHoldings = (asset: string) => SelectableUtxo[]; + +/** What one asset ended up funded by, and what comes back in it. */ +export type FundedAsset = { + asset: string; + /** + * What returns to the wallet in this asset as an output the wallet builds itself. + * + * Zero for the asset the network charges its fees in: that surplus is the fee's to take + * from, and what is left of it is change the signing module works out from the finished + * weight. Every other asset's surplus is exact here, because nothing takes a bite out of it. + */ + changeSats: bigint; + /** The wallet's own outputs paying for this asset, in the order they will be added. */ + selected: SelectableUtxo[]; +}; + +export type AssetFundingResult = + | { funded: FundedAsset[]; ok: true } + | { ok: false; reason: string; reject: "document-fault" | "shortfall" }; + +export type AssetFundingContext = { + /** What the wallet worked the fee out to be, which only the network's own asset pays. */ + feeSats: bigint; + /** What selection adds on top for a fee that is not final until the transaction is weighed. */ + headroomSats: bigint; + holdings: AssetHoldings; + policyAsset: string; + /** + * Outputs already committed to before funding was worked out, with the asset they are in. + * + * An issuance derives its asset id from the output its input spends, so that output is + * chosen before anything else — and it is an input of this transaction whether or not the + * arithmetic below would have picked it. Counting it twice would fund the action twice; not + * counting it would fund it once too little. + */ + reserved: { asset: string; utxo: SelectableUtxo }[]; +}; + +/** + * Funds every asset an action moves, each out of what the wallet holds in that asset. + * + * The rule is one sentence applied per asset: what the outputs cost, less what the transaction + * already brings, is what the wallet has to find — and the network's own asset carries the fee + * on top because the fee is charged in it and in nothing else. A second asset never becomes a + * second fee. + * + * Where an asset comes up short the refusal names it. A person told "you do not have enough" by + * a wallet holding plenty of money is being told something true about an asset they were not + * thinking about, and which one it is, is the whole of the answer. + */ +export function fundAssets( + entries: AssetEntry[], + context: AssetFundingContext, +): AssetFundingResult { + const policyAsset = context.policyAsset.trim().toLowerCase(); + const funded: FundedAsset[] = []; + /** + * Every output this transaction has already committed to spending, across every asset. + * + * One set for the whole transaction rather than one per asset, because an outpoint is an + * outpoint. A wallet asked what it holds in two assets answers from one snapshot, and + * nothing stops it offering the same output in both replies — a mis-labelled holding, a + * cache keyed by something other than the asset, a token and the money in one list. Per- + * asset sets would each be satisfied, and the transaction would spend that output twice + * and count its value twice while doing it. + */ + const committed = new Set(context.reserved.map(({ utxo }) => outpointKey(utxo))); + + for (const entry of entries) { + const isPolicy = entry.asset === policyAsset; + // One entry per outpoint here too: two descriptions of a reserved output would be + // counted twice into what the transaction brings and added twice as inputs. + const reserved = byOutpoint( + context.reserved.filter((held) => held.asset === entry.asset).map((held) => held.utxo), + ); + const brought = reserved.reduce((total, utxo) => total + toSats(utxo.amount), entry.held); + const fee = isPolicy ? context.feeSats : 0n; + const outstanding = entry.needed + fee - brought; + let selected = reserved; + let total = brought; + + if (outstanding > 0n) { + const pool = context + .holdings(entry.asset) + .filter((utxo) => !committed.has(outpointKey(utxo))); + const selection = selectCoins(pool, outstanding, isPolicy ? context.headroomSats : 0n); + + if (!selection.ok) { + return { + ok: false, + reason: isPolicy ? selection.reason : shortOf(entry.asset, outstanding, pool), + reject: "shortfall", + }; + } + + selected = [...reserved, ...selection.selected]; + total = brought + selection.totalSats; + } + + // Committed as this asset finishes rather than at the end, so the next asset's pool is + // what is genuinely left. An output funding a token cannot also fund the fee. + for (const utxo of selected) { + committed.add(outpointKey(utxo)); + } + + const surplus = total - entry.needed - fee; + + // The asset the network charges in is the signing module's to balance: it takes the fee + // out of this surplus and returns what is left. Any other asset has to be balanced here, + // exactly, and an asset with more coming in than going out and nowhere declared to put + // the difference is an action this wallet cannot build without destroying value. + if (!isPolicy && surplus > 0n && !entry.change) { + return { + ok: false, + reason: + `This action leaves ${surplus} of ${entry.asset} over, and declares no change ` + + "output to return it to. Building it would destroy that amount.", + reject: "document-fault", + }; + } + + funded.push({ + asset: entry.asset, + changeSats: isPolicy ? 0n : surplus, + selected, + }); + } + + return { funded, ok: true }; +} + +/** + * Why the wallet is short of one asset, said in terms of that asset. + * + * Written here rather than taken from selection because selection's own sentence names the fee, + * and the fee is charged in one asset only. Telling someone they cannot pay the fee in a token + * would be a wallet explaining its refusal with something that was never true. + */ +function shortOf(asset: string, needed: bigint, pool: SelectableUtxo[]): string { + // Both figures are counted from outputs already reduced to one entry each. A wallet that + // described an output twice would otherwise be told it holds twice what it holds, in the + // very sentence explaining that it does not hold enough. + const distinct = byOutpoint(pool.filter((utxo) => utxo.spendable)); + const usable = distinct + .filter((utxo) => !utxo.confidential) + .reduce((sum, utxo) => sum + toSats(utxo.amount), 0n); + + return ( + `This action pays ${needed} of ${asset}, and this account holds ${usable} of it.` + + withheldSentence(distinct.filter((utxo) => utxo.confidential)) + ); +} diff --git a/packages/tx-manifest/src/review/classAction.test.ts b/packages/tx-manifest/src/review/classAction.test.ts index 0918fa0..25b0bed 100644 --- a/packages/tx-manifest/src/review/classAction.test.ts +++ b/packages/tx-manifest/src/review/classAction.test.ts @@ -33,6 +33,16 @@ const DERIVED_SCRIPT = `5120${"11".repeat(32)}`; const ELSEWHERE_SCRIPT = `5120${"22".repeat(32)}`; const WALLET_SCRIPT = `0014${"33".repeat(20)}`; +/** + * What the chain reports a spent covenant holds, beside where it pays. + * + * Stated rather than omitted because a covenant output on this network cannot be confidential + * and still work — a Simplicity program reads exact amounts through jets that cannot + * introspect a commitment — so a reader that left these out would stand in for something no + * legitimate deployment produces, and the review refuses it rather than assuming a balance. + */ +const COVENANT_HOLDING = { amountSats: "50000", rawAssetId: POLICY_ASSET }; + const SOURCES = Object.fromEntries( ["vault", "reserve", "guard", "left", "right"].map((name) => [ `./${name}.simf`, @@ -100,7 +110,10 @@ function review( network: "liquid", policyAsset: POLICY_ASSET, readFeeRate: async () => 1000, - readTxOut: async () => ({ scriptPubKeyHex: overrides.onChain ?? DERIVED_SCRIPT }), + readTxOut: async () => ({ + ...COVENANT_HOLDING, + scriptPubKeyHex: overrides.onChain ?? DERIVED_SCRIPT, + }), scriptPubKeyOf: ({ argumentsJson, includeDebugSymbols, source }) => { hashed.push({ includeDebugSymbols, source }); @@ -236,7 +249,16 @@ describe("a class method against a deployment that exists", () => { const reviewed = await result; expect(isRefusal(reviewed) ? [] : reviewed.outputs).toEqual([ - { asset: POLICY_ASSET, id: "withdrawn", sats: 50_000n, scriptPubKeyHex: WALLET_SCRIPT }, + // An output paid to this wallet is not change, so the format's own order decides it — + // and on this network silence means hidden. + { + asset: POLICY_ASSET, + blinded: true, + decidedBy: "chain", + id: "withdrawn", + sats: 50_000n, + scriptPubKeyHex: WALLET_SCRIPT, + }, ]); }); }); @@ -291,7 +313,7 @@ describe("the constructor of the same class", () => { readTxOut: async () => { asked += 1; - return { scriptPubKeyHex: DERIVED_SCRIPT }; + return { ...COVENANT_HOLDING, scriptPubKeyHex: DERIVED_SCRIPT }; }, scriptPubKeyOf: () => DERIVED_SCRIPT, walletScriptPubKeyHex: WALLET_SCRIPT, @@ -407,7 +429,7 @@ describe("what a review still refuses", () => { network: "liquid", policyAsset: POLICY_ASSET, readFeeRate: async () => 1000, - readTxOut: async () => ({ scriptPubKeyHex: DERIVED_SCRIPT }), + readTxOut: async () => ({ ...COVENANT_HOLDING, scriptPubKeyHex: DERIVED_SCRIPT }), scriptPubKeyOf: () => DERIVED_SCRIPT, walletScriptPubKeyHex: WALLET_SCRIPT, }, @@ -616,7 +638,7 @@ describe("when the hash compiler fails", () => { network: "liquid", policyAsset: POLICY_ASSET, readFeeRate: async () => 1000, - readTxOut: async () => ({ scriptPubKeyHex: DERIVED_SCRIPT }), + readTxOut: async () => ({ ...COVENANT_HOLDING, scriptPubKeyHex: DERIVED_SCRIPT }), scriptPubKeyOf: () => DERIVED_SCRIPT, walletScriptPubKeyHex: WALLET_SCRIPT, }, diff --git a/packages/tx-manifest/src/review/coinSelection.test.ts b/packages/tx-manifest/src/review/coinSelection.test.ts index 8b284f6..5f94ae8 100644 --- a/packages/tx-manifest/src/review/coinSelection.test.ts +++ b/packages/tx-manifest/src/review/coinSelection.test.ts @@ -104,4 +104,103 @@ describe("selectCoins", () => { expect(result.selected.map((selected) => selected.txid)).toEqual([big.txid]); } }); + + /** + * A confidential output cannot fund a contract action. + * + * Unblinding one needs the secrets that go with it, and nothing in this package or in the + * module that signs is ever handed one — an outpoint and its bytes is the whole of what + * they get. Selecting one produces a transaction that fails inside the signing module, far + * from the output that caused it. + */ + describe("what it will not spend", () => { + test("never selects a confidential output, however much it holds", () => { + const result = selectCoins( + [ + { + amount: "1000000", + confidential: true, + spendable: true, + txOut: "00", + txid: "a".repeat(64), + vout: 0, + }, + { amount: "5000", spendable: true, txOut: "00", txid: "b".repeat(64), vout: 0 }, + ], + 4000n, + 0n, + ); + + expect(result.ok).toBe(true); + expect(result.ok ? result.selected.map((chosen) => chosen.txid) : []).toEqual([ + "b".repeat(64), + ]); + }); + + // A person looking at a balance that covers the amount has to be told why it does not + // count, rather than told they are short of money they can see. + test("and refuses when the balance only covers it with them, saying so", () => { + const result = selectCoins( + [ + { + amount: "1000000", + confidential: true, + spendable: true, + txOut: "00", + txid: "a".repeat(64), + vout: 0, + }, + { amount: "500", spendable: true, txOut: "00", txid: "b".repeat(64), vout: 0 }, + ], + 4000n, + 0n, + ); + + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.reason).toContain("1000000"); + expect(result.reason).toContain("confidential outputs"); + expect(result.reason).toContain("unblinded address"); + } + }); + + // Nothing is said about money that was never there to begin with. + test("but says nothing about confidential outputs when there are none", () => { + const result = selectCoins( + [{ amount: "500", spendable: true, txOut: "00", txid: "b".repeat(64), vout: 0 }], + 4000n, + 0n, + ); + + expect(result.ok ? "" : result.reason).not.toContain("confidential"); + }); + + // An output is the same output however many times it is described. Two of them selected + // is one output spent twice, which is not a transaction at all. + test("takes an outpoint once, however many objects describe it", () => { + const duplicated = { + amount: "900", + spendable: true, + txOut: "00", + txid: "a".repeat(64), + vout: 0, + }; + const result = selectCoins( + [ + duplicated, + { ...duplicated }, + { amount: "900", spendable: true, txOut: "00", txid: "b".repeat(64), vout: 0 }, + ], + 1700n, + 0n, + ); + + expect(result.ok).toBe(true); + expect(result.ok ? result.selected.map((chosen) => chosen.txid) : []).toEqual([ + "a".repeat(64), + "b".repeat(64), + ]); + }); + }); }); diff --git a/packages/tx-manifest/src/review/coinSelection.ts b/packages/tx-manifest/src/review/coinSelection.ts index 47b0289..a124f93 100644 --- a/packages/tx-manifest/src/review/coinSelection.ts +++ b/packages/tx-manifest/src/review/coinSelection.ts @@ -1,6 +1,19 @@ +import { byOutpoint } from "../chain/outpoint"; + /** One wallet output the selector may spend, as the wallet already describes it. */ export type SelectableUtxo = { amount: string; + /** + * Whether this output's amount and asset are hidden on chain. + * + * A confidential one cannot fund a contract action: unblinding it needs the secrets that + * go with it, and nothing in this package or in the module that signs is ever handed one — + * an outpoint and its bytes is the whole of what they get. Selecting one produces a + * transaction that fails inside the signing module, far from the output that caused it, + * so it is excluded here where the reason can still be said. Optional because a caller + * assembling a list by hand has nothing to hide. + */ + confidential?: boolean; spendable: boolean; txOut: string; txid: string; @@ -33,7 +46,11 @@ export function selectCoins( } const needed = targetSats + headroomSats; - const spendable = available.filter((utxo) => utxo.spendable).toSorted(byLargestFirst); + // One entry per outpoint before anything is counted or chosen, so that both halves of the + // answer below are about outputs rather than about descriptions of them. + const distinct = byOutpoint(available.filter((utxo) => utxo.spendable)); + const spendable = distinct.filter((utxo) => !utxo.confidential).toSorted(byLargestFirst); + const withheldFrom = distinct.filter((utxo) => utxo.confidential); const selected: SelectableUtxo[] = []; let totalSats = 0n; @@ -50,13 +67,32 @@ export function selectCoins( if (totalSats < needed) { return { ok: false, - reason: `This account holds ${totalSats} of the ${needed} needed to perform the action and pay its fee.`, + reason: + `This account holds ${totalSats} of the ${needed} needed to perform the action and pay its fee.` + + withheldSentence(withheldFrom), }; } return { ok: true, selected, totalSats }; } +/** + * What is there and cannot be used, said only when there is some. + * + * A person looking at a balance that covers the amount has to be told why it does not count, + * rather than told they are short of money they can see on their own screen. Written from + * outputs already reduced to one entry each: a total that counted a description twice would + * quote them a figure larger than they hold, in the same sentence that told them it was + * unusable. + */ +export function withheldSentence(confidential: SelectableUtxo[]): string { + const withheld = confidential.reduce((sum, utxo) => sum + toSats(utxo.amount), 0n); + + return withheld > 0n + ? ` A further ${withheld} is in confidential outputs, which a contract action cannot spend — send it to this account's unblinded address to use it.` + : ""; +} + /** * Largest first, and equal amounts in the order the wallet listed them. * diff --git a/packages/tx-manifest/src/review/index.test.ts b/packages/tx-manifest/src/review/index.test.ts index 29b4a5a..8c3c7d5 100644 --- a/packages/tx-manifest/src/review/index.test.ts +++ b/packages/tx-manifest/src/review/index.test.ts @@ -48,10 +48,23 @@ const deps = { walletScriptPubKeyHex: WALLET_SCRIPT, }; +/** + * What the chain says sits at an outpoint, as a reader that reports everything. + * + * The amount and the asset are stated rather than left out, because a covenant output on this + * network cannot be confidential and still work — a Simplicity program reads exact amounts + * through jets that cannot introspect a commitment — so a reader that omitted them would be + * standing in for something no legitimate deployment produces, and the review refuses it. + */ const chainHolding = (scriptPubKeyHex: string) => async (): Promise => ({ + amountSats: COVENANT_HOLDS, + rawAssetId: POLICY_ASSET, scriptPubKeyHex, }); +/** What every covenant in these cases is holding, in the asset the network charges fees in. */ +const COVENANT_HOLDS = "50000"; + function request( overrides: Partial = {}, ): ParsedLiquidProcessCtParams { @@ -115,7 +128,11 @@ describe("reviewManifestAction", () => { readTxOut: async () => { asked += 1; - return { scriptPubKeyHex: DERIVED_SCRIPT }; + return { + amountSats: COVENANT_HOLDS, + rawAssetId: POLICY_ASSET, + scriptPubKeyHex: DERIVED_SCRIPT, + }; }, }); @@ -263,8 +280,13 @@ describe("reviewManifestAction", () => { if (!isRefusal(result)) { expect(result.outputs).toEqual([ + // A covenant output is answered before the format's precedence is consulted: a + // Simplicity program reads exact amounts through jets that cannot introspect a + // commitment, so a hidden one is an output its own contract could never check. { asset: POLICY_ASSET, + blinded: false, + decidedBy: "unblindable", id: "p2pk_out", sats: 1000n, scriptPubKeyHex: DERIVED_SCRIPT, diff --git a/packages/tx-manifest/src/review/index.ts b/packages/tx-manifest/src/review/index.ts index 3959aee..6a811a4 100644 --- a/packages/tx-manifest/src/review/index.ts +++ b/packages/tx-manifest/src/review/index.ts @@ -1,4 +1,5 @@ import type { ReadFeeRate, ReadTxOut } from "../chain/chainRead"; +import { byOutpoint, outpointKey } from "../chain/outpoint"; import { type CompileCovenant, type ContractParamTypesOf, @@ -17,15 +18,25 @@ import { asArray, asRecord } from "../document/json"; import { findAction, type NormalisationNote, + type NormalisedAction, normaliseInstance, normaliseManifest, } from "../document/normalise"; import type { ReferenceScope } from "../document/references"; import { covenantSites } from "../document/sites"; +import { assetLedger, type HeldValue, resolveAsset } from "../evaluation/assetLedger"; +import type { BlindingWord } from "../evaluation/blinding"; +import { + declaredIssuance, + issuanceAttributes, + type PlannedIssuance, + resolveIssuance, +} from "../evaluation/issuance"; import { planAction } from "../evaluation/plan"; import type { ParsedLiquidProcessCtParams } from "../request/request"; import { resolveActionRequirements } from "../request/requirements"; -import { type CoinSelection, type SelectableUtxo, selectCoins } from "./coinSelection"; +import { type AssetHoldings, fundAssets } from "./assetFunding"; +import { type SelectableUtxo, toSats, withheldSentence } from "./coinSelection"; /** * What the wallet established for itself about one covenant this action touches. @@ -57,7 +68,31 @@ export type ReviewedOutput = { * is still written down, because the builder is told it rather than left to guess. */ asset: string; + /** + * Whether this output hides what it carries, decided by the order the format defines. + * + * Carried rather than left to the builder, because the decision is the document's and the + * builder has never read the document. An output built the wrong way here is one whose + * amount is published when the protocol meant it kept, and nothing later could tell. + */ + blinded: boolean; + /** + * Whose word that was: the output's own, the document's, the network's, or this wallet's. + * + * The answer and the word behind it are different facts, and only the answer reaches the + * builder. "This protocol asked for it" and "nobody said, and this network hides by + * default" build the identical output and are not the identical sentence, and a person + * deciding whether to trust a site is owed the difference. + */ + decidedBy: BlindingWord; id: string; + /** + * The word this wallet set aside, present only on change it published over the format. + * + * Absent everywhere else, because everywhere else the wallet follows the format and has + * nothing to have overridden. + */ + overrode?: BlindingWord; sats: bigint; /** What the output actually pays to. Hex the builder decodes, never an address. */ scriptPubKeyHex: string; @@ -77,6 +112,27 @@ export type ReviewedOutput = { */ export type ManifestReview = { action: string; + /** + * Whether the change this transaction returns hides what it carries. + * + * Change is an output like any other in the document's eyes, and the corpus declares one + * for almost every action while saying nothing about it — so the format's own answer is + * the network's default, and on Liquid that means hidden. This wallet publishes it + * instead, so the money returns in a form the next action can be funded from. + * + * Still derived rather than written as `false`, because anything checking what was built + * against what was decided and handed a constant checks nothing. + */ + changeBlinded: boolean; + /** + * Whose word this wallet set aside to publish that change. + * + * Present whenever the format would have hidden it, which is every action in the published + * corpus — including the ones declaring no change output at all, whose change the signing + * module appends and whose silence the network answers the same way. Absent only where a + * protocol asked for open change itself, because then nothing was overridden. + */ + changeOverrode?: BlindingWord; /** * The class this action is a method of, when the document declares it inside one. * @@ -99,6 +155,14 @@ export type ManifestReview = { createdInstance?: CreatedInstance; /** What the wallet will pay, established from the chain rather than from the request. */ feeRateSatsPerKvb: number; + /** + * The assets this action creates, each with the output of the wallet's it is derived from. + * + * An asset id is a function of the output the issuing input spends, so the two are kept + * together: separated, nothing downstream could tell whether the id belongs to the output + * the transaction actually spends or to one considered and dropped. + */ + issuances: PlannedIssuance[]; /** Legacy spellings the document used, so the generation it came from can be reported. */ normalisation: NormalisationNote[]; outputs: ReviewedOutput[]; @@ -156,8 +220,17 @@ export async function reviewManifestAction( contractParamTypes?: ContractParamTypesOf; /** The wallet's spendable outputs in the asset the network charges its fees in. */ fundingUtxos: SelectableUtxo[]; + /** + * The wallet's spendable outputs in any other asset, asked for by id. + * + * Optional and asked lazily, because which assets an action moves cannot be known + * before the document has been read: a caller cannot be expected to hand over a + * balance for every asset that exists. A caller supplying none holds nothing in any + * other asset, which comes out as a shortfall naming the asset rather than as silence. + */ + holdingsOf?: AssetHoldings; network: string; - /** The asset this wallet pays fees in and is the only one this slice moves. */ + /** The asset the network charges its fees in, which is the only asset that pays them. */ policyAsset: string; readFeeRate: ReadFeeRate; readTxOut: ReadTxOut; @@ -204,6 +277,33 @@ export async function reviewManifestAction( return { reason: `This request cannot be built. ${named}`, refused: true }; } + /** + * The wallet's own outputs in one asset, whichever asset an action turns out to move. + * + * The network's own asset comes from the list the caller always supplies; every other one + * is asked for by id. Asked once per asset and kept, because a wallet answering from its + * own snapshot builds the list fresh each time — so asking twice yields two lists of equal + * outputs that share no identity, and an output already committed to for an issuance would + * be offered again as though it were a different one. + */ + const pools = new Map(); + const holdings: AssetHoldings = (asset) => { + const existing = pools.get(asset); + + if (existing) { + return existing; + } + + const pool = + asset === input.policyAsset.trim().toLowerCase() + ? input.fundingUtxos + : (input.holdingsOf?.(asset) ?? []); + + pools.set(asset, pool); + + return pool; + }; + const declaredTypes = declaredParamTypes(manifest, action); const hashCovenant = covenantHashFrom(input.scriptPubKeyOf, buildMode.includeDebugSymbols); const covenants: CovenantFinding[] = []; @@ -215,7 +315,26 @@ export async function reviewManifestAction( * constructor starts with. The constructor's own fields are folded in below, once they have * been worked out. */ - let scope: ReferenceScope = { instance: deployment.instance.fields, params: request.params }; + /** + * What the wallet established about each named input, for the names that read one. + * + * Written as each input resolves rather than gathered afterwards: an input's asset and + * amount are things the wallet read from the chain or derived for itself, and a later + * reference to `payout_in.asset` means whichever of those it turned out to be. + */ + const inputs: Record> = {}; + /** + * What this transaction already brings, before the wallet spends anything of its own. + * + * Only what a spent covenant explicitly holds, and a covenant whose holding could not be + * established never reaches here: the action is refused where it is read. + */ + const chainHeld: HeldValue[] = []; + let scope: ReferenceScope = { + inputs, + instance: deployment.instance.fields, + params: request.params, + }; // The covenants this action spends, which are the ones there is something on chain to compare // against. They are derived first because a spent covenant is named by an input and a created @@ -273,9 +392,78 @@ export async function reviewManifestAction( return { reason: matched.reason, refused: true }; } + /** + * What the covenant holds, which is the chain's word rather than the document's. + * + * Both halves or neither. An action spending a covenant that already holds part of what + * its outputs cost needs the wallet to find only the rest, and that subtraction is the + * only reason this figure is read at all — so a covenant whose amount or asset the + * wallet could not establish is refused rather than treated as holding nothing. + * + * Treating it as zero is the failure this replaces, and it is not a conservative one. + * The wallet would fund every output in full out of its own money, the covenant's real + * balance would arrive in the transaction unaccounted for, and the whole of it would + * fall into the change the signing module appends — an unknown balance swept somewhere + * nobody was shown, off the back of a plan that called itself settled. + * + * On this network a covenant output cannot be confidential and still work: a Simplicity + * program reads exact amounts and asset ids through jets that cannot introspect a + * commitment. So this is either an output no contract could have spent, or a chain + * reader that does not report what it holds — and the refusal says which of those the + * wallet can tell, which is that it was not told. + */ + if (onChain.amountSats === undefined || onChain.rawAssetId === undefined) { + return { + reason: + `The ${site.utxoType} at ${outpoint.txid}:${outpoint.vout} did not come back with an ` + + "explicit amount and asset, so this wallet cannot say what it holds. It will not " + + "assume a balance for an output it is about to spend.", + refused: true, + }; + } + + // Named, or the holding has nowhere to be attributed to. A covenant input the document + // gives no id cannot be subtracted from any asset's cost — the ledger keys what the + // transaction brings by the input that brings it — and dropping it silently is the same + // arithmetic mistake as reading it as zero. + if (!site.id) { + return { + reason: + `The ${site.utxoType} this action spends holds ${onChain.amountSats} of ` + + `${onChain.rawAssetId}, and the manifest gives that input no id, so this wallet ` + + "cannot account for what it brings.", + refused: true, + }; + } + + // Recorded beside the amount because an input's own name reads the asset as + // `.asset`, and a name that could not see it would resolve to nothing. + inputs[site.id] = { amount_sat: BigInt(onChain.amountSats), asset: onChain.rawAssetId }; + chainHeld.push({ + asset: onChain.rawAssetId, + id: site.id, + sats: BigInt(onChain.amountSats), + }); + covenants.push({ ...derived.derivation, role: "spent", verified: "matches-chain" }); } + // An asset an action creates is derived from the output its issuing input spends, so that + // output is settled here rather than at the funding below: an input's own name reads the + // asset as soon as the input resolves, and an id derived from an output the wallet had not + // yet committed to spending would be an id for an asset that never comes to exist. + const issued = resolveIssuances(action, { + holdings, + inputs, + notes, + policyAsset: input.policyAsset, + scope, + }); + + if (!issued.ok) { + return { reason: issued.reason, refused: true }; + } + // The deployment this action creates, worked out before anything is derived from it. Its // covenant-hash fields are compiled here rather than asked for, because nothing but a wallet // can produce one — and they are worked out together rather than in an order, because one may @@ -326,32 +514,168 @@ export async function reviewManifestAction( covenants.push({ ...derived.derivation, role: "created", verified: "not-yet-on-chain" }); } - const plan = planAction(action.node, scope, notes); + const plan = planAction(action, scope, notes, manifest.raw.confidential_outputs); if (!plan.ok) { return { reason: plan.reason, refused: true }; } - // Keyed by the script rather than by the address. They are two spellings of one fact, and only - // one of them is hex: a builder hex-decodes every output script it is given, so handing it a - // bech32 address fails inside the module with an error naming neither the output nor what was - // wrong with it. + let feeRateSatsPerKvb: number; + + try { + feeRateSatsPerKvb = await input.readFeeRate(FEE_TARGET_BLOCKS); + } catch (error) { + return { + reason: `The wallet could not establish a fee rate, so it will not build this: ${String(error)}`, + refused: true, + }; + } + + // Read as a statement about several assets rather than about one amount. A single running + // total is only sound while there is a single asset: added together, three units of a + // one-of-a-kind token and three thousand base units of money make six of nothing, and a + // wallet that funds six of nothing funds neither. + const reckoned = assetLedger(action, plan.plan.outputs, { + held: [ + ...chainHeld, + // An issuance creates its units out of nothing, so the transaction brings them + // rather than the wallet finding them. Left out, the wallet would go looking for an + // asset that does not exist yet and refuse the action for holding none of it. + ...issued.issuances.map((issuance) => ({ + asset: issuance.asset, + created: true as const, + id: issuance.inputId, + sats: issuance.assetAmountSats, + })), + ], + notes, + policyAsset: input.policyAsset, + scope, + }); + + if (!reckoned.ok) { + return { reason: reckoned.reason, refused: true }; + } + + const ledger = reckoned.ledger; + const policyAsset = input.policyAsset.trim().toLowerCase(); + + /** + * The change output the signing module appends for itself. + * + * Only the network's own asset gets one: the fee is charged in it, and what the fee leaves + * behind is not known until the signed transaction has been weighed. Every other asset's + * change is an exact figure this wallet works out and builds in the position the document + * declares it, because nothing takes a bite out of it. + */ + const networkChange = plan.plan.outputs.filter( + (planned, at) => planned.target.kind === "change" && ledger.outputs[at] === policyAsset, + ); + /** Whether the transaction's own change hides what it carries, by the same order. */ + const changeBlinded = networkChange[0]?.blinding.blinding === "hidden"; + /** + * Whose word was set aside to publish it. + * + * An action declaring no change output still gets one — the module appends it — and the + * document's silence about an output it never declared is answered by this network exactly + * as its silence about one it did. So the two say the same thing to a person rather than + * one of them saying nothing. + */ + const changeOverrode: BlindingWord | undefined = + networkChange.length === 0 ? "chain" : networkChange[0]?.blinding.overrode; + + // An output the document wants hidden is hidden with a blinding key of the address it pays + // to. That holds for this wallet's own outputs and for its change, and not for an address + // the document names — there the key belongs to whoever owns that address, and this wallet + // has no way to obtain it. Refused here rather than built open, which would publish an + // amount the protocol asked to keep. + const foreign = plan.plan.outputs.find( + (planned) => + planned.blinding.blinding === "hidden" && + planned.target.kind !== "change" && + planned.target.kind !== "wallet", + ); + + if (foreign) { + return { + reason: + `The output ${foreign.id || "(unnamed)"} must hide what it carries and pays somewhere ` + + "this wallet holds no blinding key for.", + refused: true, + }; + } + + // Each asset funded out of what the wallet holds in that asset, and short in one of them is + // a refusal that says which one. The fee is added to the network's own asset and to no + // other: a second asset never becomes a second fee. + const funding = fundAssets(ledger.entries, { + // The fee has no figure until the transaction has been weighed, which is after this. So + // the network's asset is over-selected by a kilo-vbyte at the chosen rate — enough for a + // transaction of this size with room to spare — and whatever is left comes back as + // change. No other asset carries any of it. + feeSats: feeHeadroomSats(feeRateSatsPerKvb), + headroomSats: 0n, + holdings, + policyAsset: input.policyAsset, + reserved: issued.reserved, + }); + + if (!funding.ok) { + return { reason: funding.reason, refused: true }; + } + + const fundedFor = new Map(funding.funded.map((entry) => [entry.asset, entry])); + + // Keyed by the script rather than by the address. They are two spellings of one fact, and + // only one of them is hex: a builder hex-decodes every output script it is given, so handing + // it a bech32 address fails inside the module with an error naming neither the output nor + // what was wrong with it. const covenantScripts = new Map( covenants.map((found) => [found.utxoType, found.scriptPubKeyHex]), ); const outputs: ReviewedOutput[] = []; - for (const planned of plan.plan.outputs) { - if (planned.target.kind === "change" || planned.sats === undefined) { + for (const [at, planned] of plan.plan.outputs.entries()) { + const asset = ledger.outputs[at] ?? policyAsset; + + // Change in the network's own asset is the module's to work out and to append. Change in + // any other asset is this wallet's, built here in the position the document declares it, + // for exactly what is left over — and skipped when nothing is, because an output paying + // nothing is not an output. + if (planned.target.kind === "change") { + const surplus = asset === policyAsset ? 0n : (fundedFor.get(asset)?.changeSats ?? 0n); + + if (surplus <= 0n) { + continue; + } + + outputs.push({ + asset, + blinded: planned.blinding.blinding === "hidden", + decidedBy: planned.blinding.decidedBy, + id: planned.id, + ...(planned.blinding.overrode === undefined ? {} : { overrode: planned.blinding.overrode }), + sats: surplus, + scriptPubKeyHex: input.walletScriptPubKeyHex, + }); + + continue; + } + + if (planned.sats === undefined) { continue; } // A covenant output pays the script the wallet derived, never one the request supplied. - // There is no path from a site-supplied address to a transaction output. + // There is no path from a site-supplied address to a transaction output. An op_return + // pays to the bytes the plan encoded: those bytes are the output, and paying it to the + // wallet instead would drop what the protocol published. const scriptPubKeyHex = planned.target.kind === "covenant" ? covenantScripts.get(planned.target.utxoType) - : input.walletScriptPubKeyHex; + : planned.target.kind === "data" + ? planned.target.hex + : input.walletScriptPubKeyHex; if (!scriptPubKeyHex) { return { @@ -360,41 +684,197 @@ export async function reviewManifestAction( }; } - outputs.push({ asset: input.policyAsset, id: planned.id, sats: planned.sats, scriptPubKeyHex }); - } - - let feeRateSatsPerKvb: number; - - try { - feeRateSatsPerKvb = await input.readFeeRate(FEE_TARGET_BLOCKS); - } catch (error) { - return { - reason: `The wallet could not establish a fee rate, so it will not build this: ${String(error)}`, - refused: true, - }; + outputs.push({ + asset, + blinded: planned.blinding.blinding === "hidden", + decidedBy: planned.blinding.decidedBy, + id: planned.id, + sats: planned.sats, + scriptPubKeyHex, + }); } - const selection: CoinSelection = selectCoins( - input.fundingUtxos, - plan.plan.fundingSats, - feeHeadroomSats(feeRateSatsPerKvb), - ); - - if (!selection.ok) { - return { reason: selection.reason, refused: true }; - } + // The wallet's own outputs, one asset's worth at a time, in the order the action declares + // the inputs that need them — with the output an issuance was derived from first within its + // asset, because funding put it there and moving it would mint a different asset. + const fundedOrder = [ + ...new Set([ + ...ledger.walletInputs.map((wallet) => wallet.asset), + ...funding.funded.map((entry) => entry.asset), + ]), + ]; + const selected = fundedOrder.flatMap((asset) => fundedFor.get(asset)?.selected ?? []); return { action: request.action, ...(action.boundTo === undefined ? {} : { boundTo: action.boundTo }), + changeBlinded, + ...(changeOverrode === undefined ? {} : { changeOverrode }), covenants, ...(created === undefined ? {} : { createdInstance: created.instance }), feeRateSatsPerKvb, + issuances: issued.issuances, normalisation: notes, outputs, protocol: manifest.protocol ?? "", - selected: selection.selected, + selected, + }; +} + +type ResolvedIssuances = + | { issuances: PlannedIssuance[]; ok: true; reserved: { asset: string; utxo: SelectableUtxo }[] } + | { ok: false; reason: string }; + +/** + * Works out every asset this action creates, and which output each one is derived from. + * + * An input the wallet funds has no outpoint until the wallet picks one, and this is where it + * gets one — the asset id is a function of that output, so choosing it later would mean + * deriving an id for an output the transaction might not spend. + * + * The chosen output is returned as reserved rather than merely noted. Everything after this + * treats the funding pool as what is left, because an output spent twice is not a transaction, + * and an issuance derived from one the wallet then declined to spend is worse: it is a + * well-formed id for an asset that would never exist. + */ +function resolveIssuances( + action: NormalisedAction, + context: { + /** The wallet's spendable outputs in one asset, which is where an issuing input comes from. */ + holdings: AssetHoldings; + /** What the wallet established about each input, which the issued asset joins. */ + inputs: Record>; + notes: NormalisationNote[]; + policyAsset: string; + scope: ReferenceScope; + }, +): ResolvedIssuances { + const issuances: PlannedIssuance[] = []; + const reserved: { asset: string; utxo: SelectableUtxo }[] = []; + const policyAsset = context.policyAsset.trim().toLowerCase(); + const pools = new Map(); + /** + * Every output already reserved, across every asset and every issuance. + * + * One set for all of them, because two issuances reserving one output would derive two + * different assets from it and then ask the transaction to spend it twice to create both. + * A per-pool set would not see it: the same output can be offered under two assets, and + * the same output can be described by two objects inside one. + */ + const taken = new Set(); + + /** + * The wallet's own outputs an issuing input may be derived from, in the order to take them. + * + * Per asset, because an issuing input is an input like any other: it carries the asset the + * action says it carries, and deriving an asset id from an output in a different one commits + * this transaction to spending an output that has no business in it. + * + * Smallest first in the asset the network charges its fees in — an issuance needs an + * output's identity rather than its value, so taking the smallest leaves the most behind to + * pay with. Largest first in any other asset, where the same input is usually also the one + * carrying that asset's amount, and where moving the issuance to a second output would mint + * a different asset. + */ + const candidatesIn = (asset: string): SelectableUtxo[] => { + const existing = pools.get(asset); + + if (existing) { + return existing; + } + + const ordered = byOutpoint(context.holdings(asset).filter((utxo) => utxo.spendable)).toSorted( + (one, other) => bySize(toSats(one.amount), toSats(other.amount), asset === policyAsset), + ); + + pools.set(asset, ordered); + + return ordered; }; + + /** + * The next output in this asset that nothing has reserved yet, if there is one. + * + * Confidential ones are stepped over rather than filtered away, so that running out of + * usable outputs and running out of outputs altogether stay distinguishable — the two are + * different things to tell a person, and only one of them is about their balance. + */ + const spareIn = (asset: string): SelectableUtxo | undefined => + candidatesIn(asset).find((utxo) => !utxo.confidential && !taken.has(outpointKey(utxo))); + + for (const entry of asArray(action.node.inputs)) { + const declared = asRecord(entry); + const issuance = declared && declaredIssuance(declared); + + if (!declared || !issuance) { + continue; + } + + const id = typeof declared.id === "string" ? declared.id : "(unnamed)"; + + // A covenant can issue an asset too, on the input that spends it, and the module has a + // separate call for it. Satisfying a covenant input is not something this wallet can do + // yet at all, so an issuance sitting on one is refused by name rather than derived from + // an outpoint that would then be added as an ordinary input. + if (typeof asRecord(declared.utxo_source)?.utxo_type === "string") { + return { + ok: false, + reason: + `Input ${id} issues an asset from a covenant this wallet spends, and this wallet ` + + "cannot yet satisfy a covenant input.", + }; + } + + const asset = resolveAsset(declared.asset, `input ${id}`, { + notes: context.notes, + policyAsset: context.policyAsset, + scope: context.scope, + }); + + if (!asset.ok) { + return { ok: false, reason: asset.reason }; + } + + const funding = spareIn(asset.id); + + if (!funding) { + // What is there and cannot be used is said here as well as at funding. A person + // whose only spare output is confidential is not short of outputs — they are being + // told that this path cannot spend the one they can see, which is a different + // sentence and the only one that tells them what to do about it. + const withheld = candidatesIn(asset.id).filter( + (utxo) => utxo.confidential && !taken.has(outpointKey(utxo)), + ); + + return { + ok: false, + reason: + `Input ${id} issues an asset, which needs one of this wallet's own outputs in ` + + `${asset.id} to derive it from, and there is none left to use.` + + withheldSentence(withheld), + }; + } + + const resolved = resolveIssuance( + { declared: issuance, id, outpoint: { txid: funding.txid, vout: funding.vout } }, + context.scope, + context.notes, + ); + + if (!resolved.ok) { + return { ok: false, reason: resolved.reason }; + } + + taken.add(outpointKey(funding)); + reserved.push({ asset: asset.id, utxo: funding }); + issuances.push(resolved.issuance); + // The issued asset and its reissuance token, under the input's own name. An input's + // `asset` is what it creates rather than what the output it spends held, which is the + // whole reason a protocol writes the name at all. + context.inputs[id] = { ...context.inputs[id], ...issuanceAttributes(resolved.issuance) }; + } + + return { issuances, ok: true, reserved }; } /** Confirmation target for the fee estimate, in blocks. */ @@ -435,3 +915,20 @@ function stateOutpoint( return undefined; } + +/** + * Largest first, or smallest first, and equal amounts in the order the wallet listed them. + * + * Returning 0 for a tie is what makes that last part true. A comparator answering -1 to both + * "a before b" and "b before a" contradicts itself, and a sort is free to act on either + * answer — so two outputs of the same size could come out in either order, and which of them + * an issuance derived its asset from would depend on the engine rather than on anything the + * wallet decided. The same request has to mint the same asset twice. + */ +function bySize(left: bigint, right: bigint, smallestFirst: boolean): number { + if (left === right) { + return 0; + } + + return left > right === smallestFirst ? 1 : -1; +} diff --git a/packages/tx-manifest/src/review/multiAsset.test.ts b/packages/tx-manifest/src/review/multiAsset.test.ts new file mode 100644 index 0000000..db8f135 --- /dev/null +++ b/packages/tx-manifest/src/review/multiAsset.test.ts @@ -0,0 +1,805 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +import multiassetManifest from "../__fixtures__/multiasset.manifest.json"; +import { deriveNewIssuance } from "../chain/issuance"; +import { isRefusal, reviewManifestAction } from "../index"; +import type { ParsedLiquidProcessCtParams } from "../request/request"; +import type { SelectableUtxo } from "./coinSelection"; + +// The whole of what this file exercises is that an action moving more than one asset is read, +// funded and planned per asset — and refused, per asset, when it cannot be. The fixture is a +// two-asset protocol written for exactly that; the compiler and the chain are fakes, because +// what is under test is the arithmetic and the refusals rather than either of them. + +const SOURCE_PATH = "./p2pk.simf"; +const SOURCE = readFileSync(new URL("../__fixtures__/p2pk.simf", import.meta.url), "utf8"); +const MANIFEST = multiassetManifest as unknown as Record; +const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; +const TOKEN = "ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2"; +const DERIVED_SCRIPT = `5120${"11".repeat(32)}`; +const WALLET_SCRIPT = `0014${"33".repeat(20)}`; +const MONEY_TXID = "c".repeat(64); +const TOKEN_TXID = "d".repeat(64); + +function utxo(amount: string, txid: string, overrides: Partial = {}) { + return { amount, spendable: true, txOut: "00", txid, vout: 0, ...overrides }; +} + +const deps = { + compile: () => ({ address: "tex1p_derived", scriptPubKeyHex: DERIVED_SCRIPT }), + network: "liquid", + policyAsset: POLICY_ASSET, + readFeeRate: async () => 1000, + readTxOut: async () => ({ scriptPubKeyHex: DERIVED_SCRIPT }), + scriptPubKeyOf: () => DERIVED_SCRIPT, + walletScriptPubKeyHex: WALLET_SCRIPT, +}; + +function request(overrides: Partial = {}) { + return { + action: "PayToken", + broadcast: false, + contractSources: { [SOURCE_PATH]: SOURCE }, + manifest: MANIFEST, + params: { amount_sat: 1000, fee_sat: 700, pubkey: PUBKEY, token: TOKEN }, + ...overrides, + } satisfies ParsedLiquidProcessCtParams; +} + +function pay( + overrides: { + holdings?: Record; + money?: SelectableUtxo[]; + params?: Record; + } = {}, +) { + const money = overrides.money ?? [utxo("1000000", MONEY_TXID)]; + const holdings = overrides.holdings ?? { [TOKEN]: [utxo("4000", TOKEN_TXID)] }; + + return reviewManifestAction( + request(overrides.params === undefined ? {} : { params: overrides.params }), + { ...deps, fundingUtxos: money, holdingsOf: (asset) => holdings[asset] ?? [] }, + ); +} + +describe("an action that moves two assets", () => { + test("funds each asset out of what the wallet holds in that one", async () => { + const result = await pay(); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + // The token's output first, because the action declares a token input and no money + // one — so the token is the asset the wallet was asked for, and the money follows it + // as the asset the fee is charged in. Deterministic either way: the same request + // selects the same outputs in the same order twice. + expect(result.selected.map((chosen) => chosen.txid)).toEqual([TOKEN_TXID, MONEY_TXID]); + } + }); + + // The one rule the single-total assumption broke. Three units of a one-of-a-kind token and + // three thousand base units of money do not make six of anything. + test("never adds one asset's amount to another's", async () => { + const result = await pay({ holdings: { [TOKEN]: [utxo("900", TOKEN_TXID)] } }); + + expect(isRefusal(result)).toBe(true); + + if (isRefusal(result)) { + // Named by the asset that is short and by what it is short of, because a person told + // "you do not have enough" by a wallet holding plenty of money is being told + // something true about an asset they were not thinking about. + expect(result.reason).toContain(TOKEN); + expect(result.reason).toContain("1000"); + expect(result.reason).toContain("900"); + } + }); + + // Only the network's own asset has a fee taken out of it, so only its surplus is left to + // the signing module. Every other asset's change is an exact figure with an output to land + // in, built in the position the document declares it. + test("plans an exact change output for the asset that is not the network's own", async () => { + const result = await pay(); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + expect(result.outputs).toEqual([ + { + asset: TOKEN, + blinded: false, + decidedBy: "output", + id: "token_out", + sats: 1000n, + scriptPubKeyHex: WALLET_SCRIPT, + }, + { + asset: POLICY_ASSET, + blinded: false, + decidedBy: "unblindable", + id: "p2pk_out", + sats: 700n, + scriptPubKeyHex: DERIVED_SCRIPT, + }, + { + asset: TOKEN, + blinded: false, + decidedBy: "spendable-change", + id: "token_change", + overrode: "chain", + sats: 3000n, + scriptPubKeyHex: WALLET_SCRIPT, + }, + ]); + } + }); + + // The network's own change stays the builder's, because the fee comes out of it and its + // amount is not known until the signed transaction has been weighed. + test("and leaves the network asset's change to the builder", async () => { + const result = await pay(); + + expect(isRefusal(result) ? [] : result.outputs.map((output) => output.id)).not.toContain( + "change_out", + ); + }); + + // An asset with more coming in than going out and nowhere declared to put the difference + // is an action that would destroy that amount. + test("refuses a surplus in an asset with no declared change output", async () => { + const withoutChange = structuredClone(MANIFEST) as Record; + const actions = withoutChange.actions as Record>; + const outputs = actions.PayToken?.outputs as Record[]; + + actions.PayToken!.outputs = outputs.filter((output) => output.id !== "token_change"); + + const result = await reviewManifestAction( + { ...request(), manifest: withoutChange }, + { + ...deps, + fundingUtxos: [utxo("1000000", MONEY_TXID)], + holdingsOf: () => [utxo("4000", TOKEN_TXID)], + }, + ); + + expect(isRefusal(result)).toBe(true); + expect(isRefusal(result) ? result.reason : "").toContain("destroy"); + }); + + // A wallet supplying no reader holds nothing in any other asset, which is a shortfall + // naming the asset rather than a silent refusal or an action funded out of money. + test("holding nothing in an asset is a shortfall named by that asset", async () => { + const result = await reviewManifestAction(request(), { + ...deps, + fundingUtxos: [utxo("1000000", MONEY_TXID)], + }); + + expect(isRefusal(result)).toBe(true); + expect(isRefusal(result) ? result.reason : "").toContain(TOKEN); + }); +}); + +describe("which of the wallet's outputs may be spent", () => { + // The same output described twice is one output. Spending it twice is not a transaction. + test("selects an outpoint at most once, however many objects describe it", async () => { + // Three descriptions of two outputs, all of equal size and none of them covering the + // amount alone. A selector that did not notice the repeat would take the first twice + // and stop, having covered the amount by spending one output two times over. + const result = await pay({ + money: [utxo("900", MONEY_TXID), { ...utxo("900", MONEY_TXID) }, utxo("900", "e".repeat(64))], + }); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + const keys = result.selected.map((chosen) => `${chosen.txid}:${chosen.vout}`); + + expect(keys).toEqual([`${TOKEN_TXID}:0`, `${MONEY_TXID}:0`, `${"e".repeat(64)}:0`]); + } + }); + + /** + * Identity spans the transaction, not one list. + * + * A wallet answers "what do I hold in this asset" from one snapshot, and nothing stops it + * offering the same physical output under two assets — a mis-labelled holding, a cache + * keyed by something other than the asset. Pools checked only against themselves would + * each be satisfied, and the transaction would spend that output twice while counting its + * value twice. + */ + test("never takes one outpoint for two assets, however it was offered", async () => { + const shared = utxo("1000000", MONEY_TXID); + const result = await pay({ + holdings: { [TOKEN]: [{ ...shared }, utxo("4000", TOKEN_TXID)] }, + money: [shared], + }); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + const keys = result.selected.map((chosen) => `${chosen.txid}:${chosen.vout}`); + + expect(new Set(keys).size).toBe(keys.length); + expect(keys).toEqual([`${TOKEN_TXID}:0`, `${MONEY_TXID}:0`]); + } + }); + + // A txid is thirty-two bytes, and the same bytes written in two cases are the same output. + // Identity spelled without saying so would agree until it met a wallet that upper-cases. + test("and treats a transaction id in either case as the same output", async () => { + const result = await pay({ + money: [ + utxo("900", MONEY_TXID), + utxo("900", MONEY_TXID.toUpperCase()), + utxo("900", "e".repeat(64)), + ], + }); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + const keys = result.selected.map((chosen) => `${chosen.txid.toLowerCase()}:${chosen.vout}`); + + expect(new Set(keys).size).toBe(keys.length); + } + }); + + /** + * A confidential wallet output cannot fund a contract action, and this path does not + * pretend otherwise. + * + * Unblinding one needs the secrets that go with it, and nothing here or in the module that + * signs is ever handed one. So a balance that covers the amount only with them is refused, + * and the refusal says why rather than telling a person they are short of money they can + * see on their own screen. + */ + test("refuses when only confidential outputs would cover it, and explains", async () => { + const result = await pay({ + holdings: { [TOKEN]: [utxo("1000000", TOKEN_TXID, { confidential: true })] }, + }); + + expect(isRefusal(result)).toBe(true); + + if (isRefusal(result)) { + expect(result.reason).toContain("confidential outputs"); + expect(result.reason).toContain("cannot spend"); + } + }); +}); + +describe("an action that creates an asset", () => { + function mint( + money: SelectableUtxo[] = [utxo("1000", "a".repeat(64)), utxo("1000000", MONEY_TXID)], + ) { + return reviewManifestAction( + request({ action: "Mint", params: { pubkey: PUBKEY, supply: 21 } }), + { ...deps, fundingUtxos: money }, + ); + } + + // The asset is a function of the output the issuing input spends, so that output is + // reserved before ordinary funding — an id derived from an output the wallet had not + // committed to spending would be an id for an asset that never comes to exist. + test("derives the asset from an output it has reserved for the purpose", async () => { + const result = await mint(); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + const [issuance] = result.issuances; + const derived = deriveNewIssuance(issuance?.outpoint ?? { txid: "", vout: 0 }); + + expect(issuance?.asset).toBe(derived?.asset ?? ""); + expect(issuance?.assetAmountSats).toBe(21n); + expect(issuance?.inflationAmountSats).toBe(0n); + expect(issuance?.inputId).toBe("mint_in"); + } + }); + + // Smallest first in the network's own asset: an issuance needs an output's identity rather + // than its value, so taking the smallest leaves the most behind to pay the fee with. + test("and reserves the smallest of them, leaving the most to pay with", async () => { + const result = await mint(); + + expect(isRefusal(result) ? "" : result.issuances[0]?.outpoint.txid).toBe("a".repeat(64)); + }); + + test("spends the reserved output once and once only", async () => { + const result = await mint(); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + const reserved = result.issuances[0]?.outpoint; + const spending = result.selected.filter( + (chosen) => chosen.txid === reserved?.txid && chosen.vout === reserved.vout, + ); + + expect(spending).toHaveLength(1); + } + }); + + // An issuing input's `asset` is what it creates rather than what the spent output held, + // which is the only way an action that mints a token can say what its output pays in. + test("pays the created asset out under the id the issuance derived", async () => { + const result = await mint(); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + expect(result.outputs).toContainEqual({ + asset: result.issuances[0]?.asset ?? "", + blinded: false, + decidedBy: "output", + id: "minted_out", + sats: 21n, + scriptPubKeyHex: WALLET_SCRIPT, + }); + } + }); + + // The units are created out of nothing, so the transaction brings them rather than the + // wallet finding them. Counted the other way, the wallet would go looking for an asset that + // does not exist yet and refuse the action for holding none of it. + test("does not go looking for the asset it is about to create", async () => { + expect(isRefusal(await mint())).toBe(false); + }); + + /** + * Two issuing inputs need two outputs, and the outputs have to be different ones. + * + * Each derives its asset from the output its input spends. Reserving one output twice + * would produce two well-formed ids for two different assets that both need that one + * output spent to exist, and the transaction can spend it once. + */ + test("never reserves one outpoint for two issuances, however it was described", async () => { + const shared = utxo("1000", "a".repeat(64)); + const result = await twoIssuances([shared, { ...shared }, utxo("1000000", MONEY_TXID)]); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + const derivedFrom = result.issuances.map( + (issuance) => `${issuance.outpoint.txid}:${issuance.outpoint.vout}`, + ); + + expect(result.issuances).toHaveLength(2); + expect(new Set(derivedFrom).size).toBe(2); + // And two different assets came out, which is the fact the outpoints were keeping + // apart in the first place. + expect(result.issuances[0]?.asset).not.toBe(result.issuances[1]?.asset ?? ""); + } + }); + + // Equal-sized candidates keep the order the wallet listed them, so the same request mints + // the same asset twice. A comparator answering -1 to both directions contradicts itself and + // lets the sort return either order. + test("takes equal-sized candidates in the order the wallet listed them", async () => { + const first = { ...utxo("1000", "a".repeat(64)), vout: 1 }; + const second = { ...utxo("1000", "a".repeat(64)), vout: 2 }; + const result = await twoIssuances([first, second, utxo("1000000", MONEY_TXID)]); + + expect(isRefusal(result)).toBe(false); + expect(isRefusal(result) ? [] : result.issuances.map((one) => one.outpoint.vout)).toEqual([ + 1, 2, + ]); + }); + + /** + * Running out of usable outputs and running out of outputs are different things. + * + * Only one of them is about the person's balance, and it is the one they can check. A + * refusal that said "none left to use" while the wallet showed a confidential output of + * plenty would be telling them something true and useless. + */ + test("explains a confidential candidate rather than saying there is none", async () => { + const result = await reviewManifestAction( + request({ action: "Mint", params: { pubkey: PUBKEY, supply: 21 } }), + { + ...deps, + fundingUtxos: [ + utxo("500", "a".repeat(64), { confidential: true }), + { ...utxo("500", "a".repeat(64), { confidential: true }) }, + ], + }, + ); + + expect(isRefusal(result)).toBe(true); + + if (isRefusal(result)) { + expect(result.reason).toContain("confidential outputs"); + expect(result.reason).toContain("unblinded address"); + // Counted once, not once per description. Quoting 1000 here would tell a person + // they hold twice what they hold, in the sentence explaining they cannot use it. + expect(result.reason).toContain("500"); + expect(result.reason).not.toContain("1000"); + } + }); + + // The same sentence is owed after the open candidates run out, not only when there were + // never any. + test("and still explains it once the open candidates are exhausted", async () => { + // One open output and one confidential. The first issuance takes the open one; the + // second finds nothing it can use, and what it cannot use is exactly what the person + // needs to be told about. + const result = await twoIssuances([ + utxo("1000", "a".repeat(64)), + utxo("900000", "b".repeat(64), { confidential: true }), + ]); + + expect(isRefusal(result)).toBe(true); + + if (isRefusal(result)) { + expect(result.reason).toContain("confidential outputs"); + expect(result.reason).toContain("900000"); + } + }); +}); + +/** The Mint action with a second issuing input, for the cases that need two of them. */ +function twoIssuances(money: SelectableUtxo[]) { + const document = structuredClone(MANIFEST) as Record; + const actions = document.actions as Record>; + const inputs = actions.Mint?.inputs as Record[]; + + inputs.push({ ...structuredClone(inputs[0]), id: "mint_two" }); + + // Its units need somewhere to go, or the action leaves an asset over with nowhere declared + // to put it — which is a refusal about the document rather than about the outpoints these + // cases are here to exercise. + const outputs = actions.Mint?.outputs as Record[]; + + outputs.splice(1, 0, { + amount_sat: "params.supply", + asset: "mint_two.asset", + confidential: false, + destination: "wallet", + id: "minted_two_out", + }); + + return reviewManifestAction( + { + ...request({ action: "Mint", params: { pubkey: PUBKEY, supply: 21 } }), + manifest: document, + }, + { ...deps, fundingUtxos: money }, + ); +} + +describe("the issuances this wallet refuses outright", () => { + async function mintDeclaring(issuance: Record) { + const document = structuredClone(MANIFEST) as Record; + const actions = document.actions as Record>; + const inputs = actions.Mint?.inputs as Record[]; + + inputs[0]!.issuance = issuance; + + return reviewManifestAction( + { + ...request({ action: "Mint", params: { pubkey: PUBKEY, supply: 21 } }), + manifest: document, + }, + { ...deps, fundingUtxos: [utxo("1000000", MONEY_TXID)] }, + ); + } + + test("a reissuance, because the request carries no entropy to derive it from", async () => { + const result = await mintDeclaring({ asset_amount_sat: 21, kind: "reissue" }); + + expect(isRefusal(result)).toBe(true); + expect(isRefusal(result) ? result.reason : "").toContain("entropy"); + }); + + test("and a reissuance token, which would have to be confidential to be spendable", async () => { + const result = await mintDeclaring({ + asset_amount_sat: 21, + inflation_amount_sat: 1, + kind: "new", + }); + + expect(isRefusal(result)).toBe(true); + expect(isRefusal(result) ? result.reason : "").toContain("confidential"); + }); +}); + +/** + * Which outputs hide what they carry, and whose word decided it. + * + * The format's order is the output's own word, then the document's, then the chain's — and on + * Liquid the chain's word is that an output is hidden, which makes a document's silence a + * decision rather than an absence. Two destinations are answered before the order is consulted + * at all, and a contract action's own change is answered after it and against it. + */ +const outputsOf = (document: Record) => + (document.actions as Record>).PayToken?.outputs as Record< + string, + unknown + >[]; + +describe("what each output does with the value it carries", () => { + function documentSaying(edit: (document: Record) => void) { + const document = structuredClone(MANIFEST) as Record; + + edit(document); + + return reviewManifestAction( + { ...request(), manifest: document }, + { + ...deps, + fundingUtxos: [utxo("1000000", MONEY_TXID)], + holdingsOf: () => [utxo("4000", TOKEN_TXID)], + }, + ); + } + + test("an output's own word comes first, over the document's", async () => { + const result = await documentSaying((document) => { + document.confidential_outputs = true; + outputsOf(document)[0]!.confidential = false; + }); + + expect(isRefusal(result)).toBe(false); + expect(isRefusal(result) ? undefined : result.outputs[0]).toMatchObject({ + blinded: false, + decidedBy: "output", + id: "token_out", + }); + }); + + test("the document's word comes next, when the output says nothing", async () => { + const result = await documentSaying((document) => { + document.confidential_outputs = false; + delete outputsOf(document)[0]!.confidential; + }); + + expect(isRefusal(result) ? undefined : result.outputs[0]).toMatchObject({ + blinded: false, + decidedBy: "document", + id: "token_out", + }); + }); + + /** + * The step that makes silence a decision, and the reason it cannot be built here yet. + * + * On this network an output nobody spoke about is hidden, and hiding one needs the blinding + * key of the address it pays to. For an output paying somewhere the document names, that + * key belongs to whoever owns that address and this wallet has no way to obtain it — so it + * is refused rather than published in the open, which cannot be taken back. + */ + test("and silence means hidden, which a wallet output can be and a covenant's cannot", async () => { + const result = await documentSaying((document) => { + delete outputsOf(document)[0]!.confidential; + }); + + expect(isRefusal(result) ? undefined : result.outputs[0]).toMatchObject({ + blinded: true, + decidedBy: "chain", + id: "token_out", + }); + }); + + /** + * Answered before the precedence is consulted at all. + * + * A Simplicity program reads exact amounts and asset ids through jets that cannot + * introspect a commitment, so a hidden covenant output is one its own contract could never + * check. An OP_RETURN carries bytes rather than value and has nothing to hide. + */ + test("a covenant output is open whatever the document says", async () => { + const result = await documentSaying((document) => { + document.confidential_outputs = true; + outputsOf(document)[1]!.confidential = true; + }); + + expect(isRefusal(result) ? undefined : result.outputs[1]).toMatchObject({ + blinded: false, + decidedBy: "unblindable", + id: "p2pk_out", + }); + }); + + test("and so is an OP_RETURN", async () => { + const result = await documentSaying((document) => { + outputsOf(document)[1] = { + confidential: true, + destination: { type: "op_return" }, + id: "burn_out", + }; + }); + + expect(isRefusal(result) ? undefined : result.outputs[1]).toMatchObject({ + blinded: false, + decidedBy: "unblindable", + id: "burn_out", + // `6a` on its own: an output whose first opcode is OP_RETURN cannot be spent by + // anyone, which is the whole of what a burn needs. + scriptPubKeyHex: "6a", + }); + }); + + /** + * The one place this wallet answers over the format rather than under it. + * + * A contract action can be funded only by outputs that hide nothing, so change returned + * hidden is money the next action cannot reach and a sequence of actions starves itself + * after the first. The change amount is published on chain as a result; that is the price, + * and the word that was set aside is carried out so a person can be told which one it was. + */ + test("a contract action's change is published, carrying the word that was set aside", async () => { + const result = await documentSaying((document) => { + document.confidential_outputs = true; + }); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + expect(result.changeBlinded).toBe(false); + expect(result.changeOverrode).toBe("document"); + expect(result.outputs.find((output) => output.id === "token_change")).toMatchObject({ + blinded: false, + decidedBy: "spendable-change", + overrode: "document", + }); + } + }); + + // It fires only where the format would have hidden. A protocol asking for open change is + // simply agreed with, and nothing claims to have been overridden. + test("but overrides nothing where the protocol asked for open change itself", async () => { + const result = await documentSaying((document) => { + outputsOf(document)[3]!.confidential = false; + }); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + expect(result.changeBlinded).toBe(false); + expect(result.changeOverrode).toBeUndefined(); + } + }); + + // Change that says nothing about itself gets this network's own answer, which is to hide — + // and this wallet publishes it anyway, saying whose word that was. + test("and says the same about change the document says nothing about", async () => { + const result = await reviewManifestAction( + request({ action: "Mint", params: { pubkey: PUBKEY, supply: 21 } }), + { ...deps, fundingUtxos: [utxo("1000000", MONEY_TXID)] }, + ); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + expect(result.changeBlinded).toBe(false); + expect(result.changeOverrode).toBe("chain"); + } + }); +}); + +/** + * What a covenant holds is the chain's word, and where the chain does not say it, nothing does. + * + * A covenant output on this network cannot be confidential and still work — a Simplicity + * program reads exact amounts and asset ids through jets that cannot introspect a commitment — + * so a read that comes back without them is either an output no contract could have spent or a + * reader that does not report what it holds. Either way the wallet has not been told, and it + * refuses rather than assuming. + * + * Reading it as zero is the alternative, and it is not the conservative one. The wallet would + * fund every output in full out of its own money, the covenant's real balance would arrive in + * the transaction unaccounted for, and the whole of it would fall into the change the signing + * module appends — an unknown balance swept somewhere nobody was shown, out of a plan calling + * itself settled. + */ +describe("a covenant that does not state what it holds", () => { + function spendReading( + txOut: { amountSats?: string; rawAssetId?: string }, + options: { asset?: string; named?: boolean } = {}, + ) { + const document = structuredClone(MANIFEST) as Record; + const actions = document.actions as Record>; + + (actions.PayToken!.inputs as Record[]).push({ + ...(options.named === false ? {} : { id: "vault_in" }), + utxo_source: { utxo_type: "p2pk_output" }, + }); + + // The token half of the action, removed where the case is about an action that moves + // nothing but the network's own asset. + if (options.asset === "policy-only") { + actions.PayToken!.inputs = []; + actions.PayToken!.outputs = (actions.PayToken!.outputs as Record[]).filter( + (output) => output.asset !== "params.token", + ); + (actions.PayToken!.inputs as Record[]).push({ + id: "vault_in", + utxo_source: { utxo_type: "p2pk_output" }, + }); + } + + return reviewManifestAction( + { + ...request(), + manifest: document, + state: { utxos: [{ txid: "f".repeat(64), utxo_type: "p2pk_output", vout: 0 }] }, + }, + { + ...deps, + fundingUtxos: [utxo("1000000", MONEY_TXID)], + holdingsOf: () => [utxo("4000", TOKEN_TXID)], + readTxOut: async () => ({ ...txOut, scriptPubKeyHex: DERIVED_SCRIPT }), + }, + ); + } + + // Unconditionally, and the reason says what the wallet was not told rather than naming an + // amount nobody supplied. + test("is refused where the action moves a second asset", async () => { + const result = await spendReading({}); + + expect(isRefusal(result)).toBe(true); + + if (isRefusal(result)) { + expect(result.reason).toContain("p2pk_output"); + expect(result.reason).toContain("explicit amount and asset"); + expect(result.reason).toContain("will not assume a balance"); + } + }); + + // The case that used to be let through. Every asset here is the network's own, so the + // arithmetic looks harmless — and it is exactly where an unknown balance would be swept + // into change. + test("and where the action moves nothing but the network's own asset", async () => { + const result = await spendReading({}, { asset: "policy-only" }); + + expect(isRefusal(result)).toBe(true); + expect(isRefusal(result) ? result.reason : "").toContain("explicit amount and asset"); + }); + + // Half an answer is not an answer. An amount without an asset cannot be netted against + // anything, because netting is only sound within one asset. + test("and where the chain reports only one half of what it holds", async () => { + const amountOnly = await spendReading({ amountSats: "600" }); + const assetOnly = await spendReading({ rawAssetId: POLICY_ASSET }); + + expect(isRefusal(amountOnly)).toBe(true); + expect(isRefusal(assetOnly)).toBe(true); + }); + + /** + * A holding with nowhere to be attributed is dropped, and dropping it is the same + * arithmetic mistake as reading it as zero. + * + * The ledger keys what the transaction brings by the input that brings it, so a covenant + * input the manifest gives no id cannot be subtracted from any asset's cost. This is the + * narrow guard that keeps that subtraction honest, not a check on the document at large. + */ + test("and where it states what it holds but the manifest gives the input no id", async () => { + const result = await spendReading( + { amountSats: "600", rawAssetId: POLICY_ASSET }, + { named: false }, + ); + + expect(isRefusal(result)).toBe(true); + + if (isRefusal(result)) { + expect(result.reason).toContain("no id"); + expect(result.reason).toContain("600"); + } + }); + + // Stated and named, it is netted against that asset's cost — within its own asset and no + // other. + test("but is netted against that asset's cost where it states and names it", async () => { + const result = await spendReading({ amountSats: "600", rawAssetId: POLICY_ASSET }); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + // The covenant brings 600 of the 700 the money output costs, so the wallet finds the + // rest and the fee — never the whole 700 again, and never any of the token. + expect(result.selected.map((chosen) => chosen.txid)).toEqual([TOKEN_TXID, MONEY_TXID]); + } + }); +});