Perform a contract action from a txManifest protocol - #5
Open
lukachi wants to merge 76 commits into
Open
Conversation
Adds lukachi/smplx at the repository root, following the convention the lwk submodule already sets: fork to the maintainer's account, pin a named working branch, vendor at the root rather than under packages/. The pinned branch humid/wasm-wallet-integration is local until the slice that finishes this work. Until it is pushed, `git submodule update` on this branch cannot resolve the gitlink for anyone who does not already hold both checkouts.
…t and lint The smplx submodule joins lwk in both ignore lists. A vendored fork must not be reformatted or linted by this repository: doing so rewrites files the fork owns and makes its diff unreadable. The wfctl-managed surface is excluded from formatting for the same reason it already excluded AGENTS.md and CLAUDE.md — those files are generated and synced by wfctl, so any formatting applied to them is undone on the next sync. Without this, twenty-six generated files failed `format:check` and blocked every commit in the repository.
Points the gitlink at humid/wasm-wallet-integration, which carries the provider feature gate, a signer usable without networking, the change target and per-input derivation path a ranged-descriptor wallet needs, and the browser-loadable wasm-bindgen package. The branch is still local, so this gitlink does not resolve for anyone who does not hold both checkouts until it is pushed.
The repository had no way to run a test: no `test` script, no framework in devDependencies, and no test file anywhere under apps/ or packages/. Anything not provable in a browser was not provable at all. Uses Bun's built-in runner rather than adding a framework. Bun is already the pinned package manager, so this costs no new dependency and needs no config: it runs TypeScript directly and finds nothing inside the lwk and smplx submodules, which carry no JavaScript tests. `test` and `test:watch` scripts are added and `check` now ends with the suite, so a failing test fails the same gate as a type error. The first tests cover `formatTimeAgo`, whose expectations come from its own documented contract — sub-minute gaps read as "just now", nothing rounds to "0m" — rather than from reading its body. `@types/bun` and `bun-types` are dev dependencies, and `bun-test-env.d.ts` states the type reference once instead of repeating it at the top of every test file.
Adds `loadSmplxWasm`, deliberately mirroring `loadLwkWasm`: the same streaming-with-fallback instantiation and the same wasm-bindgen start handshake, because both modules are produced the same way and a second shape here would be a difference nobody could explain later. Unlike lwk it needs no network, so it can initialize in any context the extension runs in rather than only where a `window` exists. The package is wired the way lwk already is — a `file:` dependency on the built package inside the submodule — plus a `build:wasm` script, which lwk does not have. Without it, producing that directory is undocumented knowledge that lives only in working copies. The ambient module declaration joins lwk's in `vite-env.d.ts`, since wasm-pack emits no types for the `_bg.js` entry point. Tests instantiate the same bindings the loader consumes, differing only in where the module bytes come from. They assert the module reports its SDK version, compiles the probe contract to 43041b02608dc3ba245a2e3dc7aa5bc991fcf6c097c6a165a18e97a486461729 — the CMR a native build produces for the same source — derives a covenant address, and refuses both an uncompilable source and an unknown network. That the optimised wasm build agrees with a native build is what makes recomputing a covenant address in the wallet meaningful.
…te one The request contract has six parts: the manifest, the sources of the contracts it references, the chosen action and its filled parameters, and the two mutable protocol files the site holds — the instance file and the state file. The fee is deliberately absent; the wallet establishes it. Two separate questions, answered separately. `parseLiquidProcessCtParams` checks the request is well-formed. `resolveActionRequirements` reads the chosen action to work out what that action actually needs, and reports what of it is absent — so a refusal can name the missing part and, where the part is a map, the exact keys. Requiring all six parts of every request would refuse valid ones: a protocol with no covenant parameters has no instance file, and an action that creates rather than spends reads nothing from state. The published p2pk manifest shows both cases in one document — `Pay` needs neither file, `Receive` locates its covenant UTXO through the state file — and it is the fixture the tests run against, unmodified at the corpus commit they name. `resolveActionRequirements` is knowingly narrow: it looks for referenced contract sources, instance references, state lookups and declared parameters, and it is not a general construct registry. The registry is a later slice and should replace this rather than grow out of it. Fixtures are excluded from the formatter, since a fixture that is a copy of a published document stops being that document once it is reformatted.
Taken from simplicityhl 0.6.0's own examples rather than written here, so the tracer's compile-and-derive path is checked against the compiler author's contract instead of against something we invented. Two identifiers renamed to match the published manifest's compile parameter and witness names; nothing else changed.
The wallet proves a contract is genuine by rebuilding it and checking the address matches where the funds sit. That check means nothing unless different compile parameters genuinely produce different addresses, and the same ones reproduce the same address, so both are asserted rather than assumed. Also asserted: a parameterised contract given no parameters is refused rather than compiled against defaults, and malformed argument JSON is refused when the contract is constructed.
…equest
A covenant address is derived from a contract source and the parameters it was
built with. The manifest wires those parameters to references — {"PUB_KEY":
"params.pubkey"} — and the request fills them, so something has to join the two
and hand the compiler its own argument shape.
Everything it cannot resolve refuses rather than resolving to something plausible:
a reference into the instance file, a declared type nobody has mapped, a parameter
with no declared type, a parameter the request left out. That strictness is the
point — these values participate in the address, so a wrong one produces a
well-formed address for the wrong contract instead of an error.
The type map is a closed list holding one entry. The corpus's other declared types
arrive with the slices that need them.
Both reference spellings the corpus carries are accepted: lending uses the
$-prefixed form where lending_v2 uses the bare one.
Two functions, deliberately separate. deriveCovenantAddress rebuilds the contract from the source the request supplied and the parameters the manifest wires into it. The site's contribution changes what the wallet computes; it never changes what that result is checked against. covenantMatchesChain does the checking, and takes the on-chain address as an explicit argument. The state file carries an outpoint and no scriptPubKey, which is the right shape rather than a gap: comparing two values the same site supplied would pass for any pair it chose to make consistent. What sits at an outpoint is read from the network, not told by the requester. A mismatch returns a refusal with both addresses named. There is no shape of this that returns a warning. Compilation is injected, so both are exercised without a wasm module and the module's lifecycle stays where it belongs.
The covenant address check needs a second value that the requester did not supply, and nothing the wallet ships can produce one. The wallet's UTXO snapshot only ever holds outputs it owns, and lwk's Esplora client scans a descriptor and broadcasts — it cannot fetch a transaction. So this reads the output directly from the endpoint the chain record already configures for lwk, headers included, so an authenticated backend does not have to be configured twice. What is read is public chain data: no key, no descriptor, no wallet state. That is what makes a direct read acceptable where a direct write would not be — broadcast stays with lwk. Every way the answer could be unusable fails loudly rather than returning something a comparison would silently match against: a malformed txid or index is refused before the request is made, and an output that comes back without a scriptPubKey is an error rather than an empty string.
…anyone approves it Ties the pieces together. For every covenant the action touches, the contract is rebuilt from the source the request supplied; a covenant being spent is then compared against what the chain says is at its outpoint, and a covenant being created is reported as derived-but-not-yet-on-chain rather than as verified. That distinction is the point. An action that creates a covenant has nothing to compare against, and saying so is more honest than reporting a check that did not happen. Its protection is different in kind: the destination is derived by the wallet rather than supplied by the site. Everything it cannot establish refuses, and the refusal says which thing: a missing request part named by key, a contract that will not compile, a state file listing no such covenant, a chain that cannot be read, an address that does not match. There is no return value meaning 'probably fine'. This runs before the permission gate, where a standing permission cannot skip it, which is why it refuses rather than warns.
…oves it Replaces the stub's four functions. parse accepts the six-part request; review loads the wasm module, rebuilds every covenant the action touches, and for one being spent compares the derived address against what the chain says is at its outpoint; the confirmation carries what the wallet established rather than what the site claimed. review runs before the permission gate, which is what makes this a control rather than a prompt: a standing permission skips the prompt, and would have skipped the verification with it. The stub's ELIP-1 Wallet ABI framing is gone. The flow sends a manifest. execute still refuses, and now says exactly what is missing rather than that the feature does not exist: the wasm module exposes compilation and address derivation, not transaction assembly or signing. The contract is verified by the time anything asks it to build.
…t values Six assertions over the key material now bound in the wasm module: an address on the network it was built for, a different address for the same mnemonic on a different network, the same values twice from one mnemonic, an x-only key shaped for a covenant parameter, a confidential address that differs from the plain one, and a refusal for an unknown network. The mnemonic is the BIP39 all-abandon test vector, not a wallet's.
…fuses what it cannot Six assertions over the assembly binding: an empty builder, a wallet input taken as an outpoint plus the encoded output it spends, an unblinded output, and refusals for a txid that is not one, an output encoding that will not parse, and an asset id that is not one. Each refusal also asserts nothing was added, so a rejected input cannot leave a half-built transaction behind. Amounts are passed as BigInt because they are u64 in the module.
…isation The whole shape a manifest Pay action produces, asserted in one place: a wallet output funds the transaction, an output pays somewhere, and the module returns a consensus-encoded transaction, a txid and a fee greater than zero. Two refusals beside it: inputs that cannot cover the outputs and the fee, and a change script that will not parse — the second because failing is better than finalising a transaction whose change goes nowhere. This is the first evidence in the initiative that smplx's blinding and signing work under wasm rather than only its compilation.
…without keeping it Two pieces the signing path needs, both shaped by what must not happen. The fee rate is read from the same endpoint the covenant check reads, and it fails rather than falling back to a default. The fee is the wallet's business, and a default would quietly turn 'we do not know' into 'we are sure' — which is exactly what refusing is for. When the requested confirmation target is absent it takes the nearest slower one, because being wrong towards a longer wait is the safe direction. withAccountMnemonic runs a callback with the account's mnemonic and takes it away again. It is the whole account secret, so it lives for one call in one place, nothing is cached, nothing is returned, and every wasm object that held it on the way is freed on the way out — including when the callback throws. The derivation is LWK's own, unchanged from how accounts resolve everywhere else, so the account model has one place to drift rather than two. Handing the mnemonic to smplx is the accepted debt this change records, not a shortcut; the conditions that should reopen it are recorded with it.
Turns the action's declared outputs into concrete amounts, so the thinnest real action can be built end to end. Knowingly minimal: it resolves a literal and a `params.` reference and refuses everything else by name. The format's amounts can be arithmetic over other outputs, the fee and chain state, and evaluating those is a dependency graph with a fee re-pass — a later slice's whole subject. This should be deleted when that lands rather than extended one form at a time, which is why it refuses loudly instead of falling through. Amounts are bigint throughout and never become number: a satoshi count above 2^53 is representable in a transaction and not in a double, and the test asserts one survives. Change carries no amount, because change is whatever is left after the fee — and the fee is not known until the transaction has a shape.
The manifest path does not produce a PSET. smplx blinds, signs and finalises internally and hands back a finished transaction, so there was nothing the existing broadcast could take. Adds broadcastTransaction across the same four places the PSET broadcast already lives: the scan core, the client interface, the offscreen client and its protocol, and the offscreen dispatcher. It goes through lwk's Esplora client like every other write, so this does not add a second way to put something on the network — reads were the thing that had to reach the endpoint directly, and writes stay where they were. The dedicated worker rejects it for the same stated reason it rejects the PSET broadcast: LWK's Esplora client needs a window that context does not have.
Largest-first, stopping once the target is covered, which keeps the input count and therefore the fee down. The caller passes headroom for a fee it cannot know exactly yet — the real figure comes from the assembled transaction's weight, and selecting for the outputs alone would leave nothing to pay it with. Selection lives on the wallet's side rather than in the signing module on purpose: the wallet knows which of its outputs it is willing to spend, and a module choosing on its behalf would be making that call somewhere the wallet cannot see it. Amounts are bigint end to end, asserted past 2^53, because a rounded balance is a wrong decision rather than a wrong display.
Completes the path. execute resolves which account acts, works out what the action's outputs pay, establishes a fee rate from the chain, selects coins to cover both, and hands the whole thing to the signing module — which blinds, signs and finalises. Broadcast happens only when the request asked for it; otherwise the signed transaction comes back unsent. Three things are deliberately where they are. The fee comes from the chain and refuses rather than defaulting, because the request carries none and a default would turn 'we do not know' into 'we are sure'. Coin selection stays on the wallet's side, because the wallet knows which of its outputs it is willing to spend. And the account mnemonic exists for the duration of one call inside withAccountMnemonic, which takes it back afterwards — including when the call throws. An output paying a covenant uses the address the wallet derived in review, not one the request supplied. There is no path from a site-supplied address to a transaction output. The resolved account now carries the BIP-85 index it derives at, which was already an input to resolution and simply never came back out. Without it a caller cannot derive the account's own key material without re-deciding which group it is looking at.
…g to execute The seam was recorded this way from the start — review interprets, compiles, verifies and builds; execute signs and optionally broadcasts — and the first implementation put building after the confirmation instead. Moving it back matters for a reason beyond tidiness: what a person is asked to approve should be the transaction that gets signed, not a description of one reassembled afterwards from the same inputs, which might not match. So review now also plans the outputs, establishes the fee rate, and selects the coins, and carries all three into the confirmation. execute rebuilds exactly that and signs it. The wallet's own script comes from its receive address rather than from the signer, so nothing in review touches key material. Deriving a script from an address is public work and should not require a seed. One test narrowed rather than passed: Receive verifies but cannot yet be built, because its output amount references another input and the planner does not evaluate that. It now asserts the refusal is about the amount and not about the covenant, which is what shows verification got past — a weaker claim than before, and the true one.
An action that spends a covenant says what its output pays by referring to the input — `p2pk_in.amount_sat`. That has to resolve against the chain, and it now does: the amount comes from the same read the covenant check already performs, so a request understating what a covenant holds cannot make the wallet pay out less than it should. The planner gains exactly one form for this, `<input_id>.amount_sat`, alongside the literal and the `params.` reference it already had. Everything else is still refused by name. This is what the Receive shape was missing: it verified and was then refused at planning. It now plans, and the test that recorded the limit is replaced by one asserting the amount comes from the chain rather than the requester.
…nant does It does not run. p2pk's program asserts a signature, so executing it before the signature exists fails — not because the binding is wrong, but because a program whose only content is 'this signature is valid' has nothing to check yet. Zero witnesses and pruning help a program with branches the spend does not take; they do not help this shape, and this shape is every covenant that authenticates a spender. The test asserts the observed behaviour rather than a hoped-for one, so a future change that makes it run is visible rather than silent. This is the evidence AC-04 needs to be amended against: it asks for a dry-run before the confirmation surface, and for a signature-bearing covenant there is no run to be had at that moment.
execute added only wallet inputs. An action spending a covenant reviewed it, planned against what it holds, and then built a transaction that did not spend it — a silently different transaction from the one that was approved. review now carries each covenant input out with what it was verified against: the outpoint, the re-encoded output, and the source and arguments the derivation actually used. Rebuilding from those rather than resolving the request a second time means the thing spent is the thing checked, by construction rather than by two paths agreeing. Re-encoding is only valid for an explicit output, which is not a limitation here: a covenant output cannot be confidential, because Simplicity's introspection jets cannot read a confidential commitment. One that comes back confidential is refused rather than guessed at, and a test asserts that. Covenant inputs go in before wallet inputs, since the manifest's own input order is what a covenant introspects and the wallet's funding is an addition to it.
…o load The method could only be exercised by building the extension and running it, which is why nothing exercised it. Its outside reaches are now one named object with the extension's wiring as the default, so a test can substitute them. Six assertions over the seam itself: it builds and signs and returns the transaction unsent by default; it broadcasts only when asked and returns the network's txid; it reads the account mnemonic exactly once; it refuses a missing contract source by name; it refuses a malformed request; it refuses an undeclared action. Three of them also assert the mnemonic was never reached, so a refusal cannot quietly touch the seed on its way out. Writing the test surfaced a real defect: importing the module pulled in webextension-polyfill, which throws outside an extension. The sync-worker client is now imported when a transaction is actually broadcast rather than at module load — nothing else in this method needs a browser, and now neither does loading it.
…eeds A document the wallet did not write arrives as parsed JSON with nothing guaranteed about its interior, so reading it is a sequence of "is this the shape I think it is" questions. Four files had answered them separately.
The extension does not build without smplx_wasm any more — the manifest runtime compiles Simplicity contracts and signs with them — and the workflow knew only about lwk. Mirrors the existing lwk action: cached by the pinned submodule commit, so the Rust build runs only when the submodule bumps. A C compiler with a WebAssembly backend is installed rather than exported, because the fork's build script already searches for one and fails with a clear message when there is none; putting one on PATH keeps that guard instead of bypassing it. This does not make CI pass yet. The fork's branch is unpublished, so a recursive checkout cannot resolve the gitlink — the cost recorded when publishing was moved to this slice, and it clears when the branch is pushed.
…tree Not this change's work — a wfctl upgrade someone applied, plus the one-line repair that keeps the stop guard's state out of the directory wfctl parses as work bindings. Committed because an uncommitted upgrade blocks the clean-checkout gate that closing any change needs, and because leaving it in the tree is how it gets discovered by whoever next runs git status rather than by whoever applied it. The guard's own state file is ignored: it is per-session runtime bookkeeping.
The four facts the wallet established and the protocol's own words are on one screen, each labelled with its origin in words rather than a badge — "claimed by the site" is the thing that has to be unmistakable, and a badge is the thing people stop seeing. They are not split into a summary and a detail screen. The distinction that matters here is not importance but authorship, and a first screen reads as the part that counts. Every value goes through one component that takes a provenanced value and nothing else, so an unattributed one cannot reach the surface without someone changing that signature.
…lared mode reaches the compiler Two criteria I had put on the live run and that did not belong there. The wallet's fee estimate and the figure the module charges are different numbers, so what has to hold is not that they agree but that the transaction balances against whichever is charged. Built through the real module: the charged fee covers what the wallet did not pay out, an over-estimate leaves it payable rather than short, and paying out everything is refused rather than producing something the network would drop. And the mode a protocol declares reaches the compiler rather than stopping at a flag — asserted for declared-on, declared-off and declared-nothing, with nothing in the request or the wallet able to change it.
… to run from The runtime read manifests for one wallet from inside that wallet's tree, so nothing but that wallet could use it and nothing but that wallet could show it working. Both were accidents of where the files sat. `@humid/tx-manifest` holds it now. The move cost one seam: the request validator threw the extension's RPC error, and a package with no transport cannot own how a refusal travels, so it returns the malformed request as a value and the method that does have a transport wraps it. The standing check that nothing reaches outside the runtime loses its one listed exception as a result. The web dashboard card claimed the method "returns a structured not_implemented error" — six slices stale — and offered a raw JSON textarea, which is not a surface anyone can run a protocol from. It now assembles the six-part request around the published p2pk manifest and its contract source. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
…signed with
A contract action is signed by one key at m/84h/{1|1776}h/0h/0/0, and the module
funds from that key's own unblinded address and returns change there. So an
action can only spend what sits at that address, and only what sits there
unblinded — while the wallet's own screens show lwk's confidential addresses
across a ranged descriptor. Neither the address nor the key was reachable from
anywhere, which made a live run impossible to aim: funding went to an address
the signer does not hold, and locking a covenant to "this wallet" required a key
nobody could read.
Settings → the account → Contract signing identity now reads both from the
background on demand, with what each is for. It loads the contract module, so it
is read on a click rather than with the page.
This is narrower than the real fix — the module signing each input at its own
path and taking a change address from the wallet (DISC-053) — and deliberately
so: it makes the one-address limit visible instead of hiding it behind a
transaction that fails late.
Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
The guard kept its state at `.workflow/current/stop-guard.json`, where wfctl reads every `*.json` as a work binding — which broke every `wfctl work` command until a local one-line patch moved it to `.workflow/runtime/`. Upstream has since fixed it, and to a better place: `.workflow/current/hooks/`, one level below the binding scan and still gitignored. `.workflow/runtime/` was the wrong home, because upgrades own it and Git tracks it. So the local patch is now the only thing holding a conflict open. The file is byte-identical to the shipped template again, and `wfctl upgrade --dry-run` reports 0 conflicts where it reported 1 on every run since 2026-08-03. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
…r checked
A card was shipped passing `{label, value}` objects to a field whose props say
`string[]`, and a checkbox given `value` where it takes `checked`. `bun run
check` passed on all of it, and the errors only appeared as a React crash in the
browser.
Two holes, neither of them in the code that broke:
`apps/web`'s typecheck script was `tsc --noEmit` against a config with `files:
[]` and project references. That combination checks nothing at all and exits 0 —
only `tsc -b` follows references. Every type error in the web app since that
config was written has been invisible.
The root `check` typechecked `apps/extension/src` and nothing else. `packages/`
was outside every project, so the runtime that just moved there stopped being
typechecked by the act of moving.
Both are closed: `apps/web` uses `tsc -b`, `packages/` gets a project of its own,
and `check` runs all three. Verified by reintroducing the exact bug — the gate now
fails on it and passes without it.
`packages/bun-test-env.d.ts` is new for the same reason the extension has one:
`bun:test` does not resolve without the reference. Nothing needed it while nothing
checked those files.
Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
…does The hook ran the root typecheck only, which covers apps/extension/src. A type error in apps/web or packages/ committed cleanly and would only have surfaced in CI, or — as it did — in the browser. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
…e the compiler sees it A confidential address pasted into the field that wants an x-only key was hex-prefixed and handed to the contract compiler, which failed inside its own parser: "Expected 'end of input', found 'xtlq1qqd54s2…' at line 1 column 143". That error is true and about the wrong thing. The fault is in the request, and nothing in the message says which compile parameter, which reference, or what shape was wanted — a person has to work backwards from a character position to find out they pasted the wrong one of the two values the wallet showed them. A type with a fixed width is now checked against it first, and the refusal names the parameter, the reference, what arrived and what an x-only key is. The card checks the same thing before it sends, because the mistake it catches is one glance away: the contract identity screen shows an address and a key together, and only one of them belongs here. Also adds the CI gate this repository never had. The only workflows were manual builds and deploys, so no test has ever run in CI — 455 of them protecting nothing that could block a merge. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
… are on The row read the selected account. The page it sits on is per-account and reached from a list, so opening a non-selected account's settings showed the selected account's address and key with nothing to say so. Those are the two values someone then funds and locks a covenant to, so the mismatch is not cosmetic: it sends money to an account that cannot spend it. The account is named in the request now rather than assumed, and an unknown one is an error instead of a silent fallback. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
The check workflow was written on the assumption that nothing in the gate touches the wasm packages, with a comment claiming the tests skip themselves without them. Measuring a clean non-recursive clone before pushing disproved it: bun install fails on both packages, typecheck reports eleven missing-declaration errors, and three test files fail on "Cannot find module 'smplx-wasm/smplx_wasm_bg.js'" — they drive the real module, which is the point of them. Recursive checkout and both wasm builds, on the dev profile: this job checks code rather than shipping it, so an unoptimised wasm is the right trade. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
…ed to render
Calling the method opened a black window that timed out into "User rejected the
request". Two faults stacked, and neither could be seen from the code that
contained them.
The method put `{action, broadcast, covenants, kind, protocol}` on the payload.
The renderer's guard requires `shown` to be an object, so it matched nothing,
returned null, and the window rendered empty until the confirmation timed out —
which the caller sees as a rejection, because that is what a timeout means here.
Underneath that, the model carries `bigint` amounts and the payload crosses the
extension's message bus, which serializes as JSON. `JSON.stringify` throws on a
bigint rather than losing it, so a corrected payload would have failed differently.
Amounts stay bigint everywhere they are computed and become decimal strings at that
one boundary — `toShownConfirmation`.
The reason both shipped is the same: the confirmation was only ever driven from
data a test wrote by hand. It is now driven from the method, and the two things
that broke are what the new tests assert — the renderer's own guard accepts the
real payload, and the real payload survives JSON.
Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
…shown as "Invalid script: Odd number of digits" — the transaction builder hex-decodes every output script it is given, and it was given `tex1p…`. A covenant output was paid to the bech32 address the wallet derived, because the address and the scriptPubKey were two spellings of one fact reached by two separate calls, and only one of them is hex. They now come from one compiled contract. The second call had already drifted in a way nothing would have caught: `scriptPubKeyOf` built its Contract without the extra taproot leaves, so for any covenant declaring them it derived the script of a different contract than the address. The test substitute's `addOutput` accepted anything and recorded nothing, which is why a bech32 string reached the module unremarked. It records now, and two tests assert what the real builder requires: every output script is decodable hex, and the covenant output pays the script rather than the address. Reintroducing the bug fails both. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
The wallet receives to confidential addresses, so most of what it holds is blinded. A contract action cannot spend one: unblinding needs the secrets that go with it, and the signing module is handed an outpoint and its bytes and nothing else. Selection is largest-first, so it would have reached for the biggest output the account holds — which on any real wallet is a blinded one — and built a transaction that fails inside the module, far from the output that caused it. The flag was already on every UTXO the backend returns. `SelectableUtxo` never declared it, so structural typing dropped it silently at the boundary and the runtime could not see what it was being handed. A shortfall now says how much is held back and what to do about it, because being told you are short of money you can see on your own balance is not an explanation. Found by auditing the remaining test substitutes rather than by a fourth failed attempt: the last two faults both reached a person through a substitute that accepted whatever it was given. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
…t stands for Two faults reached a person through a substitute that accepted whatever it was given: an output paid to a bech32 address, and before that a confirmation payload no renderer could read. The suite was green for both. So the substitutes now apply the module's own rule — what it decodes, they decode. Every hex argument is parsed, every txid is checked for length, and the change script `finalizeTransaction` receives is parsed rather than ignored. This closes the class rather than the two instances. Nothing new fails, which is the point: the checks are there for the next argument someone passes in the wrong form. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
Twelve skills from agent-skills/plugins/client — api-integration, code-quality, components, error-handling, file-structure, forms, localization, logging, native-integration, routing, state-management, user-feedback. Copied into both agent directories, matching how the workflow skills already sit here. Not recorded in skills-lock.json: that file is wfctl's account of what wfctl installed, and these are not its. `wfctl upgrade --dry-run` reports 0 to delete with them present, so they survive an upgrade rather than depending on the lock to protect them. They describe how to write code in this repository. They introduce no workflow step, no gate, and no product decision. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
The wasm build action pinned rustc 1.85.0 and failed on the first CI run this repository has ever had: `ar_archive_writer@0.5.2`, reached through wasm-pack, requires 1.88.0. `crates/simplex` declares 1.91.0 as its own minimum, so 1.85.0 was never right — it built locally only because the local toolchain is 1.94.1, which is exactly the divergence pinning exists to prevent. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
Five owned files, on their own commit as the installer asks. Two carry rules that bear on work already in flight: `project-work-lifecycle` reorders step 11 — completion approval comes before promotion, because a curated page cites the change as its authority and that citation resolves only once the change is receipt-ready. Drafting earlier is free; writing into `knowledge/` earlier leaves the corpus invalid so progress can look further along than it is. That is what the drafts under `artifacts/promotion-draft/` were moved out for, now stated rather than inferred. `maintainer-review` adds "re-establish the subject before asking about it": a claim is the unit of record and not the unit of a question, and a packet must be re-checked against current source rather than repeating a days-old record. The rest is the AGENTS.md managed block, the align-project-knowledge skill, and the lock and state files that follow them. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
…ises The testnet backend this wallet configures is a Waterfalls server, chosen because it answers a whole-wallet scan in one request. It serves the descriptor scan and the fee estimates, and answers 404 to /tx/:txid — which is the only route the covenant read used. So the first covenant a live run touched would have failed with "Could not read transaction …: 404", on the endpoint the wallet ships with, before anything else could be exercised. Every Esplora serves /tx/:txid/raw, Waterfalls included, so the read asks for the transaction's bytes and takes the output out of them. That removes the re-encoder as well: the output handed to the signing module is now the chain's own bytes rather than a second opinion assembled from three fields of a summary, which is the same class of fault as paying a covenant its address instead of its script. The address comparison becomes a script comparison. The script is the locking condition; an address is one rendering of it, and rendering is where a difference can hide. Evidence: the parser reads three real Liquid testnet transactions — coinbase, confidential, and an explicit taproot output — and agrees with what an Esplora server independently reports about every output of each. Both production readers were then driven against the configured testnet endpoint: 377 sats/kvb, all three outputs read, the out-of-range refusal intact. 479 tests, gate green. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
Every failure the popup showed was "[object Object]". The background
serialises a thrown error structurally — {message, code, data} — so a dapp
can branch on the code rather than parse a sentence, and the popup client
put that object through String(). So the one place the message is written
for a person is the one place it did not arrive, and has not since the
serialisation became structured.
Found on the send screen while funding a contract action: a real refusal,
with a real explanation, rendered as its own type name.
The client now reads the structured shape and keeps the code and data on
the error, since a caller here has as much right to branch as a dapp does.
Anything neither string nor message-shaped is rendered as JSON rather than
as its type name — an unreadable error is worse than an ugly one.
Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
The send adapter attaches what actually went wrong as the cause, with a comment saying it does so "so real errors (broadcast, insufficient funds, address) stay diagnosable instead of collapsing into an opaque WALLET_TRANSFER_FAILED". The serialiser then dropped the cause at the message boundary, so the collapse happened anyway — one layer further out and invisible from the code that took care to prevent it. The cause chain now crosses the boundary, bounded because it can be circular, and the message a person sees carries it: "Could not build, sign, and broadcast the Liquid transfer. — caused by: InsufficientFunds…". Only the message reaches a screen, so a cause kept solely as a field would have been preserved and still unread. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
A contract action can only spend an explicit output, and the wallet could only receive to and pay a confidential address. So nobody could put money where a contract action could reach it — not from a faucet through the wallet, and not from the wallet itself. The contract identity screen exists to show the address to fund, and funding it was impossible. No fork patch needed: the chain library already has an explicit-recipient path and its wasm binding is already exposed. The ordinary path refuses an address with no blinding key, which is what "Address must be confidential" was, so the recipient's own shape picks the path. Draining already took the address as it was. The confidentiality lost is the point of the address, and the review screen has always said so before anyone confirms. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
Wollet::utxos walks the unspent cache and then skips every entry whose amount is explicit, so an unblinded output at one of the wallet's own scripts is never listed — the library says as much elsewhere, that such outputs "are considered external". The output is in the cache; only the listing drops it. That is the money, and the only money, a contract action can spend: the signing module gets an outpoint and its bytes, and unblinding needs secrets it is never given. So the wallet could be funded and still report nothing available, which is what happened an hour after it learned to do the funding. The list is built from the wallet's own transactions — each reports which outputs are its own and which inputs spent its own outputs, so unspent is the difference. No network call, no second source, and nothing counted as the wallet's that its own scan did not already claim. Kept separate from getUtxos rather than folded in. That one answers the dapp-facing getUTXOs and the portfolio snapshot, which describe the wallet as the chain library reports it; widening them would change an existing contract to fix a different problem. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
The signing module is handed an outpoint and its bytes and no derivation path, so it signs every wallet input with one key: the account's first external address. An explicit output anywhere else in the range is real money the wallet owns and cannot spend here. Offering it to coin selection would buy a failure inside the signing module — after the person approved — in place of a shortfall stated plainly beforehand. Today only one such output exists and it happens to sit at the right index, so this costs nothing now and is the difference between a refusal and a crash the first time it does not. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
Arvolear
reviewed
Aug 5, 2026
Comment on lines
+168
to
+186
| for (const covenant of review.covenantInputs) { | ||
| builder.addCovenantInput( | ||
| covenant.txid, | ||
| covenant.vout, | ||
| covenant.txOutHex, | ||
| covenant.source, | ||
| covenant.argumentsJson, | ||
| // No witness values: a covenant that authenticates its spender needs a | ||
| // signature over this transaction, which only the signer can make, and | ||
| // naming it is what asks for one. | ||
| undefined, | ||
| covenant.signatureWitness, | ||
| sequenceFor(review, covenant.id), | ||
| ); | ||
| } | ||
|
|
||
| for (const utxo of review.selected) { | ||
| builder.addWalletInput(utxo.txid, utxo.vout, utxo.txOut); | ||
| } |
Member
There was a problem hiding this comment.
We generally don't know which inputs go first. They should be added in the same order they are declared in the manifest.
Arvolear
reviewed
Aug 5, 2026
Comment on lines
+17
to
+25
| * This is not the wallet's own address and is not interchangeable with it. Contract | ||
| * actions are signed inside the smplx module by a single key at | ||
| * `m/84h/{1|1776}h/0h/0/0`, and the module funds and returns change to that key's own | ||
| * unblinded address rather than to a wallet change address — so a covenant action can | ||
| * only spend what sits there, and only what sits there unblinded. | ||
| * | ||
| * Both values are read-only and public: an address anyone can pay, and the x-only form | ||
| * of the same key. Nothing here derives, stores or returns a secret. | ||
| */ |
Arvolear
reviewed
Aug 5, 2026
| return { ok: false, reason: `Compile parameter ${name}: ${found.reason}` }; | ||
| } | ||
|
|
||
| if (typeof found.value !== "string") { |
Member
There was a problem hiding this comment.
This runtime will have to understand the case when a parameter is another covenant scriptHash.
Arvolear
reviewed
Aug 5, 2026
| } | ||
|
|
||
| /** The names a Liquid protocol's `chain` can carry. */ | ||
| const LIQUID_CHAINS = new Set(["elements", "elements-regtest", "liquid", "liquid-testnet"]); |
Arvolear
reviewed
Aug 5, 2026
| } | ||
|
|
||
| const needed = targetSats + headroomSats; | ||
| const usable = available.filter((utxo) => utxo.spendable && !utxo.confidential); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Performs one action of a txManifest protocol: a site sends the protocol document, the sources of the contracts it references, the chosen action and its filled parameters, and the wallet returns a signed Liquid transaction.
The point is that the wallet does not have to know the protocol in advance. It reads the document, works out what the step does, rebuilds every contract from the source supplied, and — for a holding being spent — compares the address it derived against where the network says the funds actually are. A mismatch refuses, and there is no way to click through it.
Depends on BlockstreamResearch/smplx#117; the
smplxsubmodule pins that branch.What a person gets
A confirmation screen showing the effect on their own balance, the fee the wallet worked out, which account is acting, and whether each contract was checked against the network. Every value says where it came from — established by the wallet, read from the chain, or claimed by the site. The site's own words are shown, because someone deciding needs to know what the site says it is doing, and labelled, because the wallet checked none of them.
Two of the seven published example protocols can be performed today. The other five are refused, each naming the first construct in its document this runtime does not read. A refusal is a protocol this wallet cannot perform — the coverage is asserted as a standing test so it cannot drift silently.
How it is built
packages/tx-manifest— the runtime, wallet-agnostic: it holds no keys, opens no connection of its own, and remembers nothing between calls. The same request twice produces the same transaction, enforced structurally rather than intended.Five modules the rest reads through, because the format changes faster than its own specification and a runtime built by accumulating special cases would be rewritten at every generation: one normalisation layer producing a canonical document from either declaration shape and every legacy spelling; a construct registry saying what is read and what is load-bearing; references resolved by position rather than by syntax; the covenant sites of an action; and the shape tests a document nobody wrote demands.
apps/extensionowns coin selection, the signing key, the change address, the fee rate, the confirmation surface and broadcast. The signing module owns assembly, blinding, signing and finalisation.Composition of this branch
smplxsubmodule + CI actionapps/webdashboard card.workflow/,.claude/,.agents/,skills-lock.jsonChecks
465 tests across 27 files, including the seven published protocol documents run through the runtime, the deployed
simplicity-lendingcontracts compiled with their commitment merkle roots pinned, and the fee model's transaction weights measured against the real module rather than modelled.This branch also adds the CI gate the repository never had — the only workflows were a manual build and two deploys, so no test had ever run in CI — and closes two holes in the local gate:
apps/webrantsc --noEmitagainst a solution config, which checks nothing and exits 0, andpackages/was outside every project.What is not verified
No transaction from this path has reached a network. The runtime is exercised end to end against a substituted signing module and the real wasm one, but not against the chain.
A contract action is signed by a single key at
m/84h/{1|1776}h/0h/0/0, and the module funds from that key's own unblinded address and returns change there — so an action can only spend what sits at that address, unblinded. The wallet now shows that address and its x-only key (Settings → the account → Contract signing identity) rather than hiding the limit. Removing it needs the SDK to sign each input at its own path, which #117 lays the groundwork for.