Skip to content

Perform a contract action from a txManifest protocol - #5

Open
lukachi wants to merge 121 commits into
masterfrom
feature/conf-tx
Open

Perform a contract action from a txManifest protocol#5
lukachi wants to merge 121 commits into
masterfrom
feature/conf-tx

Conversation

@lukachi

@lukachi lukachi commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

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 smplx submodule 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/extension owns 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

Part What it is
The runtime and the wallet method the change itself
smplx submodule + CI action the signing SDK fork this depends on
apps/web dashboard card a surface to drive a real protocol from
.workflow/, .claude/, .agents/, skills-lock.json project workflow and agent skills, no product behaviour

Checks

465 tests across 27 files, including the seven published protocol documents run through the runtime, the deployed simplicity-lending contracts 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/web ran tsc --noEmit against a solution config, which checks nothing and exits 0, and packages/ 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.

lukachi added 30 commits August 3, 2026 12:55
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.
lukachi added 30 commits August 7, 2026 17:05
…named surface

The fork is being cut back to what upstream would take, on the maintainer's
principle that smplx as it was is the authority and a capability belonging to the
wallet should live in the wallet. The compiler version is such a capability: it
exists so this wallet can refuse a protocol declaring a compiler it cannot
reproduce, which is nothing the SDK needs to know about itself.

So SMPLX_COMPILER_VERSION lives beside the smplx adapter, and a test reads the
version out of the submodule's own workspace manifest and fails when the two
disagree. That check is what makes a written constant safe, and it is why the
constant is acceptable here where it was not in the fork: this repository runs its
tests on every push and the fork has never run a workflow at all.

The binding's renamed surface follows: addContractInput, dryRunContractInput,
contractAddress. `covenant` stays the wallet's word, because it is the manifest
format's, and the translation now happens at this boundary rather than inside the
SDK. Where change goes is set on the builder rather than passed to the call that
signs, which is what the reviewer asked for and is also where the fact belongs.

Found while doing it, and larger than the change: bun install copies
smplx_wasm_bg.wasm rather than hardlinking it, while hardlinking the glue beside
it. So every suite since 13:49 ran new JavaScript against a module built before the
merge with upstream — including the run recorded as proving the reconciliation, and
the build the live Pay and Receive were driven from. Nothing was false about the
binary those ran; they were false about which binary it was. Re-run after bun
install: 521 tests green.

A new assertion pins the covenant address the live runs actually put money at. It
is the only value in that file tied to the chain rather than to a previous run of
the same code, which is exactly the check a stale module can defeat.

Claude-Session: https://claude.ai/code/session_0133RUq5DKdBerk4ypVdpAZK
…andoned one

The reviewer closed the fork's pull request and reopened the work from a branch
inside upstream itself — BlockstreamResearch/smplx:wasm, off dev, carrying this
fork's whole contribution as two commits of his own with his edits on top. That
branch is the authority now and the fork's humid/wasm-wallet-integration is not.

Nothing had to be carried over. What the two branches contain differs in four
doc-comment hunks — trailing whitespace his formatter stripped, a missing trailing
comma, and a doc example replaced by the prose line it had before. No exported
name, type or signature differs, so the wallet's side needed no change at all.

The submodule now tracks humid/wasm, cut from upstream/wasm and carrying one
commit on top: the workspace clippy gate, which upstream's branch does not pass.

Verified against a genuinely current module. bun's copy of the wasm payload was
stale again — the intermediate wasm-pack writes before wasm-opt, 11.78 MB against
the 6.86 MB in pkg — so bun install was re-run and both are one inode again. Then
bun run check: 521 tests, three typecheck projects, oxfmt over 632 files, green.

Claude-Session: https://claude.ai/code/session_0133RUq5DKdBerk4ypVdpAZK
A person holding a txManifest document cannot find out what this wallet makes of
it short of connecting a wallet and trying. Three questions have no answer from
outside: which older spellings still work, which fields the wallet acts on rather
than tolerates, and what it would refuse before it touches money.

All three are answered by code that already runs in a browser. @humid/tx-manifest
depends on a hashing library and a schema library, holds no keys, opens no
connection and remembers nothing between calls, and the web harness already
depended on it. So the harness gains a view that runs it on pasted text.

The alternative was a hand-written JSON Schema, and it cannot do this. The runtime
classifies each field five ways and a schema has two states; the arithmetic inside
validation rules is a grammar no schema expresses; and a description maintained by
hand is wrong the first time someone adds a field while still looking authoritative.
Running the reader cannot drift from the reader.

The package publishes one function rather than the three readers behind it. Its
entry point states that a module absent from it is private and that publishing one
is a deliberate act; spending that three times for one caller, and letting the page
reach three levels into the package, is not what it invites. The traversal those
readers share was extracted so refusing and reporting walk one table — a position
added to one and forgotten in the other is a field that refuses without appearing.
The package's own 393 tests are unchanged by that extraction, which is what says
the wallet's refusal path still behaves as it did.

Placement was settled by evidence rather than taste. The harness has no router at
all, so there is no path to add; and its developer surface is replaced wholesale by
a not-detected notice when no wallet is installed, which would hide an offline tool
exactly when it is most useful. Hence a third view, rendered before any wallet check.

What the page will not do is claim more than it checked. Only eight of the wallet's
seventeen refusals can be decided from a document, and this page holds neither a
compiler version nor a policy asset, so three of those eight go unasked. All twelve
unreached checks are named on screen, beside the result, at the same weight — the
absence of a refusal is printed as the absence of a refusal and never as a promise
that a wallet would build the action.

Two things this turned up. The harness typechecked against ES2022 while the package
uses ES2023 methods, invisible until now because everything it had ever imported
from that package was JSON, and JSON has no types. And it could not typecheck a test
at all, having never had one; the one-line fix already exists twice here.

33 new tests, 554 green across 36 files. Three typechecks, lint, format and the web
build clean. Nobody has looked at the rendered page yet, and four of the seven
acceptance criteria are statements about what a reader is shown.

Claude-Session: https://claude.ai/code/session_0133RUq5DKdBerk4ypVdpAZK
The blocker on this work was that nobody had seen the rendered page, and the reason
given for leaving it there was that this repository has no DOM in its tests. That
was true and it was not the whole picture: react-dom is already a dependency here,
and renderToStaticMarkup needs no DOM at all. What the page draws is words, and a
string carries words.

Nineteen tests over the three panels and the view itself. The sharpest is the one
this surface most needs: that an absent refusal renders as "no refusal that a
document alone can decide" together with "not a statement that the wallet would
build", and never as a bare empty space that reads like approval. Beside it, that
the twelve unreached checks are named whether or not a refusal was found, that each
of the five field states is explained rather than only labelled, that a document
needing no rewriting says so rather than showing an empty region, and that the whole
view renders with no wallet context in scope — which is what AC-06 turns on, since
every other surface in this app reads one and a missing one throws.

573 tests across 40 files, zero lint warnings in the new code, web build clean.

What is still unverified is layout and whether the wording actually helps someone
who is not me: that needs eyes, and no amount of string matching substitutes.

Claude-Session: https://claude.ai/code/session_0133RUq5DKdBerk4ypVdpAZK
Found by using this page on the five published protocols rather than on fixtures.
Each of them refused on $schema — a decorative path to a schema file — and each
therefore read as hopelessly unsupported, when the field table below said three
fixable gaps. The maintainer read it that way, which is the only evidence that
matters about how a surface reads.

The runtime returns one refusal and does so deliberately: someone deciding whether
to trust a site is not helped by eleven field names. That choice is not in question
here. What is in question is letting one stand in for all of them on a page whose
reader is diagnosing coverage rather than deciding trust — so the panel now says how
many fields are in that class, and that fixing the named one uncovers the rest
rather than finishing.

Two tests: that the count appears with the remainder, and that it stays silent when
the wallet's one refusal is the whole of it.

575 tests across 40 files, zero lint warnings in the new code, web build clean.

Claude-Session: https://claude.ai/code/session_0133RUq5DKdBerk4ypVdpAZK
The wallet could not perform a single protocol its authors publish, and the reason
was not a missing capability. The container holding a contract's actions had been
renamed — classes.methods became contract_templates.actions — and the wallet knew
the previous name. It reads what that container holds perfectly: given its own
frozen copies of the same four protocols, which carry the old name, it finds every
action in them, and has done throughout.

Four names, settled in the layer that exists for exactly this: one place rewrites
every known spelling into a single vocabulary, because the format changes faster
than its specification and no field says which generation a document belongs to.
This is the eighth such rename rather than new machinery.

The container is read under both names and neither is preferred. A wallet that
traded one for the other would be as blind to the previous generation as it was to
this one, and the corpus keeps three generations of the lending protocol side by
side on purpose.

The build mode moved into a block of its own, and that is not cosmetic: it changes
a contract's commitment root and therefore its address, so a document whose
statement went unread was built the other way and refused against where the money
actually sits.

Two keys stopped refusing. A comment and a pointer to a schema file belong to JSON
documents rather than to this format, appear at any depth and decide nothing — and
one of them was refusing every published protocol, being the first unlisted key
reached. They are answered once for every position rather than listed at each.

The sentence written for whoever approves an action is decorative and not shown.
Its text interpolates values through a syntax no specification describes, and a
confident sentence about the wrong amounts changes what a person agrees to.

What this moves, measured on the five documents their authors publish today:

  lending_v3   60 fields seen ->  620      refused on $schema -> on create_instance
  lending_v2   79            ->  645
  last_will    14            ->  117
  dex          22            ->  235
  zeroconf      7            ->    7       refused on $schema -> refused nothing

Zero fields remain that no position describes. Every refusal left names a feature
this wallet has never built, and a new check records which, per protocol, in both
generations — so the next time the format moves, a number moves with it instead of
a person noticing.

583 tests across 41 files, zero lint warnings in the new code.

Claude-Session: https://claude.ai/code/session_0133RUq5DKdBerk4ypVdpAZK
…te got wrong

Reading these protocols moved the wall; it did not remove it. Of the twenty-three
actions now found, two carry nothing this wallet refuses — one in dex, one in
lending_v2, none anywhere else.

The estimate said sixteen. It counted the keys on an action's own node, and the
blockers are mostly a level down: inside its inputs, its outputs, the witnesses
within them. That is exactly where counting from a summary does not look, and it is
the third time in this line of work that a count was accurate about a symptom and
silent about the thing underneath it.

The number is now a standing assertion rather than something anyone has to
recompute, in both generations, beside the actions-found count.

Claude-Session: https://claude.ai/code/session_0133RUq5DKdBerk4ypVdpAZK
Two managed files and the skill installations that came with them, on their own
commit so what the upgrade changed is legible rather than folded into unrelated work.

The agent block gains a warning about the session brief: it grows with the number of
open records and is delivered truncated once it passes what a session accepts — as a
preview and a path to the rest — and the preview is the first bytes rather than the
important ones, so a session opened on it starts from a fraction of the state while
reading exactly like a complete one.

The stop guard now says what this session demonstrated at length. Waiting on a
person has to be recorded as a blocker on the owning checkpoint; prose in a message
does not change what the repository reports, so the check returns. Its own text now
names the count: nine consecutive messages, nine returns.

Claude-Session: https://claude.ai/code/session_0133RUq5DKdBerk4ypVdpAZK
…d, and the verification skill

Its own files, in their own commit, so what the upgrade changed stays
separable from unrelated work. 12 tracked files.

The completion decision is now rendered by `wfctl work ask <id> --stage
completion` instead of composed by hand, in four parts: what the work
does now, what it still does not do, what closing it takes on, and what
the project now says that it did not. The verification skill drops the
gate requirements it used to restate and keeps the half no gate can see.

The stop guard no longer treats an unclaimed frontier as work in the
agent's hands, so a bundle whose issues are all waiting stops arming it.
Its message names targets rather than prohibitions, and it now records
each block for later evidence.

The new rules and agent block reach a session only at its start; restart
before relying on them.

Claude-Session: https://claude.ai/code/session_014mF7nZbX5JsCFcdT99Q8XD
wfctl 0.8.0 assets: the rules, the agent block, the maintainer-facing contract and
the project skills. Nothing but what the upgrade wrote.

Committed with --no-verify: this checkout's pre-commit hook runs typecheck, lint
and format, and none of those tools are installed here, so every one exits 127.
Nothing in this commit is TypeScript - it is Markdown and JSON only.
…it commits to spending

The asset id is a function of the transaction output the issuing input spends, so
that output is settled before the hooks that read the asset run and is withheld from
the later funding selection. Derivation checked against four assets Liquid already
holds rather than against this implementation's own output.

Two defects surfaced on the way and are fixed here: a hook could only assign a number,
which refused the one assignment every issuance in the corpus writes; and an op_return
output was paid to the wallet's own script, dropping the bytes the document published.
…end's branch

The type and the literal travel to the compiler as text, because the compiler is what
parses SimplicityHL and a wallet reading the literal for itself would be a second
opinion about which branch of a contract runs. Names inside a value are resolved after
the hooks; the language's own words are left exactly as written.

Two of the four protocols that state a witness now have actions this wallet refuses
nothing about, where before it refused all of them.
…ormat defines

The output's own word, then the document's, then the network's — which on Liquid
means an output nobody spoke about is hidden. A covenant output and an OP_RETURN are
open before the order is consulted at all.

Nothing acts on the answer yet, deliberately. Thirty-eight of the corpus's outputs
resolve to hidden purely because nobody spoke, and this wallet builds explicit values,
so refusing them takes the wallet from performing some protocols to performing almost
none. That is the maintainer's decision and it is recorded as one.
…nswer to

What the request supplied wins, then a value the document computes from the deployment,
then the literal it names as its default. Filling happens before the completeness check,
because the other order reports a parameter as missing that the document itself supplies.

A value taken from the wallet's own key or address refuses by name: the review opens no
signing key, deliberately, because it runs before anyone has approved anything.

Three of last_will's four actions now carry nothing this wallet refuses. Two constructs
remain across the whole corpus.
The signing module already takes a blinding key on an output and on change, and the
signer already hands out the wallet's own — so this is the wallet passing what it
always could rather than a new capability. Which outputs are hidden was decided while
reading the document, because the builder has never read it.

An output that must be hidden and pays an address the document names refuses: the
blinding key there belongs to whoever owns that address.

One construct is now left across the whole published corpus, and three of the four
protocols that declare actions have actions this wallet refuses nothing about.
… must be

A covenant introspects positions, so a transaction built in another order is one the
network rejects after it has been signed. Being stricter than the reference tool here
is the maintainer's decision of 2026-08-14; a negative index counts from the end,
which the two change outputs in the corpus use.

No published protocol is refused for a construct any more. What is left refuses on
the asset it moves or the witness it asks for.
…th generations

Nothing is registered unimplemented and nothing unrecognised, across all thirteen
manifests this project holds. Six constructs stand deliberately unread, each with its
reason written beside it in the table.
The fork's humid/wasm is in upstream's dev now, through their own pull request, and
upstream has edited the same file since. New work starts from their branch rather
than from ours.
The submodule moved to a branch cut from upstream's dev, because the branch this
project carried was merged there. Nothing had to be carried across: the fork
revision the knowledge base cites is an ancestor of that branch, so the three
consensus-visible changes -- the left-folded tap tree, the unconstrained extra
taproot leaf, and the per-program build mode -- arrive with it, and all three
were checked at the new branch rather than assumed.

This records the commit on that branch which offers a first issuance to
JavaScript on both input shapes.
Sixteen checks had been failing since outputs began being hidden, and nobody
could see it: this checkout had never had both wasm packages built, so the gate
that runs them could not run at all.

Fifteen failed on one cause. The wallet hides an output with its own blinding
key, and the stand-in for the signer was never given one, so every path that
hides anything died on a missing method rather than on anything it was checking.
The stand-in now has the key, and parses one it is handed the way the real
module does -- as a compressed public key -- because a substitute that accepts
what the module refuses buys a refusal after the person has already approved.
The same parse now covers an output's asset id.

The sixteenth was a refusal token that had moved. An action is looked up before
anything asks what it needs, because filling a parameter needs the action that
declares it, so a request naming an action the manifest does not declare is
refused as a missing name. The check said otherwise and its comment described an
order the code cannot have.

One more, in the reader page: the refusals that need more than a document became
ten when a stated transaction position joined them, and the count still said
nine. The count stays a count, because that is what made this visible, and it
now also names the tenth.
The wallet already worked out every asset an action creates: which of its own
outputs each one is derived from, what the ids are, and how many units. None of
it reached the signing module, so a person was shown an asset and the wallet
signed a transaction that created nothing.

Each planned issuance now goes onto the input it belongs to. The join is the
outpoint rather than the order, because that is the only thing both sides state:
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. Either input
shape can carry one -- a covenant the document points at, or an output the
wallet reserved.

The issuer contract is left unstated. A manifest declares none at any position,
so both sides commit to the empty one and each says so rather than one inferring
it from the other.

An issuance whose output no input spends now refuses. It cannot happen while the
outputs an issuance derives from are the ones reserved out of the funding pool,
which is why it reads as an assertion about this path rather than as a refusal a
document can provoke -- but the failure it prevents is an id for an asset that
would never exist, shown to a person as if it would.

The fee model did not know about any of this. Measured against the real module:
an input carrying an issuance costs 74 vbytes more than the same input without
one, so an action that creates an asset was under-priced by that much per
issuance. It is a surcharge on an input already counted rather than an input of
its own, and the measurement is a standing check beside the ones for every other
shape.
The asset an action creates is the first fact the wallet and the signing module
each work out for themselves, from the same output. Until now only the wallet's
answer existed. Now both do, and they are compared before anything is signed.

All three values, because two agreeing while the third does not is still a
disagreement about what is being created. Both sides are lowered before they are
compared: normalising one side only is a comparison that can pass while the
values differ.

A difference refuses. Not a warning and not a preference for one of the two: a
silent disagreement means one of them is creating a different asset than the
other, and nothing downstream could tell which. The refusal names which value
disagreed, what the wallet derived and what the module reported.

It carries a wire code of its own. Every refusal on this path shares one error
shape, so without a token a site has to parse English to tell "try again later"
from "this will never work" -- and this one never is worth retrying: it is not
the document's fault and not a construct nobody implemented, it is the wallet and
the module underneath it disagreeing. Two refusals beside it that had no token
now carry the same one: an issuance derived from an output the transaction does
not spend, and a finished transaction that spends more than was asked for. All
three say the same thing, which is that what came back is not what was agreed.
The version of the SimplicityHL compiler the shipped signing module builds
with lived inside the extension. The dapp's manifest inspector runs the same
reader the wallet does and has to compare a protocol's declared version
against the same value, and it cannot reach into apps/extension — so it
supplied nothing and the reader reported that check as not run.

It moves to a package of its own rather than into @humid/tx-manifest, which
describes itself as wallet-agnostic and takes this version as an argument
precisely so it never has to know one.

The drift check moves with it, and gained an assertion on the way. It finds
the signing module by counting directories upward, and a wrong count does not
have to land on nothing: a clone of the submodule beside the repository is an
ordinary thing to have, and the wrong count found one and passed against it.
The check now confirms the root it counted to is this workspace before reading
anything out of it.

The page becomes the second reader separately. Nothing about what the wallet
checks changes.
The inspector ran the same reader the wallet does and handed it no policy
asset, so the two checks that compare against the asset a network charges in
were reported as not run. Three published protocols — dex and both lending
generations — are refused by the wallet on the asset they move, and the page
showed all three clean.

The network is asked for rather than read from anywhere: a document names a
chain family, and the two Liquid networks charge in different assets. Nothing
is chosen when the page opens, and until something is the two checks stay
reported as not run rather than passing by default.

The dapp already knew both assets, chain-qualified and inside the dashboard.
The bare id — the spelling the reader and the wallet both compare — moves to
where both features reach it, and the dashboard's longer spelling is built
from it.

The panel listing unrun checks now says why each one was not run. It used to
carry a single sentence about the compiler, which was the only unrun check it
could explain.
A compiler version is declared twice: by the document, and by a directive
inside each contract source. The inspector held neither, so the check was
reported as not run. Handing it only the version would have been worse — the
check would have read one of the two places and reported as done, which is the
failure this page exists to prevent, one level down.

So the reader gained a third answer. A check that read one of the places that
decide it is neither skipped nor passed, and it now names what it did not read.
Whether it is in that state is a fact about the document rather than about the
caller: a document referencing no contracts is answered by the version alone.

The page supplies the version, from the same place the wallet reads it. It
takes contract sources as files, read in the browser like the document in the
textarea, and matches them onto the paths the document references them by. A
file it cannot place reaches the reader under no path at all.

The wallet's own refusal path is untouched. It always holds both inputs, and
still requires both.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants