Skip to content

Perform a contract action from a txManifest protocol - #5

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

Perform a contract action from a txManifest protocol#5
lukachi wants to merge 96 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 5 commits August 5, 2026 12:54
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
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);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We generally don't know which inputs go first. They should be added in the same order they are declared in the manifest.

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.
*/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aah, that's not true.

return { ok: false, reason: `Compile parameter ${name}: ${found.reason}` };
}

if (typeof found.value !== "string") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runtime will have to understand the case when a parameter is another covenant scriptHash.

}

/** The names a Liquid protocol's `chain` can carry. */
const LIQUID_CHAINS = new Set(["elements", "elements-regtest", "liquid", "liquid-testnet"]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be BIP-22-like.

}

const needed = targetSats + headroomSats;
const usable = available.filter((utxo) => utxo.spendable && !utxo.confidential);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not confidential?

lukachi added 20 commits August 7, 2026 11:57
Installed workflow assets refreshed by `wfctl upgrade`: agent rules on
review and continuity, the stop guard runtime, the AGENTS.md managed
block, the project-work skill set, and the recorded asset hashes.
The package was one directory holding 30 sources, 23 tests beside them and a
global types.ts. The repository's own file-structure rule names that shape
directly, and the accounting made at framing missed it because it read the
skill's description rather than the skill.

The layout follows the import graph rather than topic. document/ is the format
layer, chain/ is what the chain says, evaluation/ resolves expressions,
covenants/ derives and compares addresses, request/ is what the site sent, and
confirmation/ and review/ are entrypoints owning the files only they use — bytes
inside chain, provenance inside confirmation, coinSelection inside review, each
of which already had exactly one consumer while sitting in public.

The public surface drops from about thirty-five exports to twenty, which is what
anything outside actually imports, ordered by the four steps a wallet takes
rather than alphabetically. The import path does not change.

Found while moving, and worth more than the move: stateless.test.ts walked one
directory. Its nine standing structural checks would have gone on passing over
two files out of thirty, reporting clean because they had stopped looking. The
walk is recursive now, the import check resolves a specifier instead of matching
its shape, and a first assertion fails if the sweep ever narrows again.

508 tests, gate green, extension builds.
The contract identity row put the thrown error's own message on the screen. That
message is written for whoever is debugging the wallet — it can name a module, a
network kind or a derivation path, none of which the reader can act on, and it
changes whenever the code under it changes.

There is exactly one thing a person can do about any failure of this read, so
that is what it now says. The technical detail stays where it was already going,
which is the diagnostic path rather than the screen.

One of seven places in the extension that render error.message directly. The
other six predate this change.
41 warnings sit in files this change touched, 34 of them in files it created,
while every verification receipt it wrote said it introduced none. The linter's
own --fix accounts for 19 of them and leaves the suite at 508 green: they existed
because nobody had run the command that removes them.

The 22 that remain are not mechanical and are itemised in the sweep, to be
settled one at a time. Two of them are a design question rather than a lint
finding — the review path awaits a chain read per covenant inside a loop, and
whether those may run concurrently is about the reader and the fee model.
Ten console.warn calls, each carrying a hand-written [liquid-sync] prefix, which
is what a child logger's scope is for. Two of them this change added, eight
inherited — and converting two would have left two conventions in one file.

The facade is a console wrapper with no transports, so it behaves the same inside
a worker as on a page. Verified rather than assumed before moving anything: it
reaches no storage and no extension API.
…aying why not

Zero left in the files this work created or touched, from 41.

Two in the review path are disabled with the reason rather than obeyed: the loop
returns on the first covenant it refuses, so running the sites concurrently would
compile contracts and send chain reads for covenants after the answer is known,
and would make which refusal a person is shown depend on which request finished
first instead of on the order the manifest declares.

The rest are test-file rules that are wrong about test files. Transaction-byte
fixtures are joined where one field ends and the next begins; a helper that builds
the case it sits in belongs beside its assertion; wasm-bindgen's exported names
cannot be renamed. Each disabled narrowly with its reason. One was a genuine
improvement and is simply fixed.

Six warnings in three files this change did not create are left alone and
recorded, per the sweep's own boundary.
The audit asked for BIP-22-like refusals and it was right. Every refusal the
runtime produced was a sentence and nothing else, and the method collapsed all of
them into one wire code — so a site telling 'this wallet will never build that'
from 'your state file is out of date' had to parse English, and one of those is
worth retrying while the other never is.

Seventeen tokens, derived from the refusal branches that already exist rather
than invented: the vocabulary is ours because the format defines error codes only
for the validation rules a manifest itself declares, and none of these are that.
They are the wallet's own statements about what it will not do. The prose is
untouched at every site; the token travels beside it and reaches the caller in
data.reject.

Two standing checks. Every published manifest is built or refused by a name,
never a sentence with nothing attached. And all five the corpus refuses today
refuse on the same branch — asserted as the fact it is, so the day one refuses
for a different reason that line is what says so.

517 tests, gate green, extension builds.
Five of the audit's comments applied: the Rust-side contract vocabulary with
every js_name untouched, one provider accessor instead of two, new before
from_mnemonic, ChangeOutput in its own file under its own name, ecdsaPublicKey on
the bindings, and the compiler version read at build time instead of remembered.

The wasm package is rebuilt from it. 517 extension tests pass against the new
module, gate green, extension builds.
Derivation-path example, the sequence helper inside the builder it belongs to,
and the binding able to express the nested witness form. The wasm is rebuilt and
the extension driven against it: 517 tests, gate green.
Extension driven against the rebuilt module: 517 tests, gate green, builds.
The fork now carries upstream's dev, with the Program collision resolved once and
stated in that commit. 517 extension tests green against the rebuilt module,
including the pinned taproot addresses that would move if the resolution were
wrong.
…ule's

The reviewer said 'that's not true' of the contract identity's doc, and they are
the module's author. The doc blamed the module for a limit this wallet imposes:
it said the module signs with one key and returns change to that key's own
address, and that the SDK does not yet sign each input at its own path.

It does. FinalTransaction takes a change target and PartialInput takes a
derivation path per input. This method supplies one change script — the signer's
own — and no paths at all, so every wallet input is signed with the default key
because nothing here overrides it.

Corrected in both places that carried the claim, including the account settings
row a person actually reads. Lifting the limit is work in the method.
517 extension tests green against the rebuilt module, including the pinned
taproot addresses that would move if the memoisation were wrong.
…gain

The merge with upstream dev left the code generator calling a Program signature
this fork does not have, so every contract the framework generates failed to
compile — ten errors across five programs. Nothing here noticed because the wallet
consumes crates/wasm and never the generator, and nothing on either side compiles
that output.

The fork moves onto upstream's Program::new signature rather than patching the
generator to call ours, and its own CI now runs on the branch it pushes to.

bun run check green against a wasm rebuilt from this revision: typecheck across all
three projects, oxlint, oxfmt over 630 files, 517 tests across 32 files, 0 fail.

Claude-Session: https://claude.ai/code/session_0133RUq5DKdBerk4ypVdpAZK
The reconciliation had added this branch's name to two of upstream's workflow
files so their gate would run on our pushes. Maintainer, on reading it: our
branch has no business in upstream's CI, and they are right — on a pull request
into dev it reads as noise in somebody else's repository.

It also never worked. The push landed and nothing ran, because GitHub leaves a
forked repository's workflows unregistered until someone enables them by hand,
which no trigger edit substitutes for.

The generator fix is untouched and no code changed here, so dist/ built at 16:35
from f06c5db is still the current build for testing.

Claude-Session: https://claude.ai/code/session_0133RUq5DKdBerk4ypVdpAZK
…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
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