Perform a contract action from a txManifest protocol - #5
Open
lukachi wants to merge 96 commits into
Open
Conversation
Adds lukachi/smplx at the repository root, following the convention the lwk submodule already sets: fork to the maintainer's account, pin a named working branch, vendor at the root rather than under packages/. The pinned branch humid/wasm-wallet-integration is local until the slice that finishes this work. Until it is pushed, `git submodule update` on this branch cannot resolve the gitlink for anyone who does not already hold both checkouts.
…t and lint The smplx submodule joins lwk in both ignore lists. A vendored fork must not be reformatted or linted by this repository: doing so rewrites files the fork owns and makes its diff unreadable. The wfctl-managed surface is excluded from formatting for the same reason it already excluded AGENTS.md and CLAUDE.md — those files are generated and synced by wfctl, so any formatting applied to them is undone on the next sync. Without this, twenty-six generated files failed `format:check` and blocked every commit in the repository.
Points the gitlink at humid/wasm-wallet-integration, which carries the provider feature gate, a signer usable without networking, the change target and per-input derivation path a ranged-descriptor wallet needs, and the browser-loadable wasm-bindgen package. The branch is still local, so this gitlink does not resolve for anyone who does not hold both checkouts until it is pushed.
The repository had no way to run a test: no `test` script, no framework in devDependencies, and no test file anywhere under apps/ or packages/. Anything not provable in a browser was not provable at all. Uses Bun's built-in runner rather than adding a framework. Bun is already the pinned package manager, so this costs no new dependency and needs no config: it runs TypeScript directly and finds nothing inside the lwk and smplx submodules, which carry no JavaScript tests. `test` and `test:watch` scripts are added and `check` now ends with the suite, so a failing test fails the same gate as a type error. The first tests cover `formatTimeAgo`, whose expectations come from its own documented contract — sub-minute gaps read as "just now", nothing rounds to "0m" — rather than from reading its body. `@types/bun` and `bun-types` are dev dependencies, and `bun-test-env.d.ts` states the type reference once instead of repeating it at the top of every test file.
Adds `loadSmplxWasm`, deliberately mirroring `loadLwkWasm`: the same streaming-with-fallback instantiation and the same wasm-bindgen start handshake, because both modules are produced the same way and a second shape here would be a difference nobody could explain later. Unlike lwk it needs no network, so it can initialize in any context the extension runs in rather than only where a `window` exists. The package is wired the way lwk already is — a `file:` dependency on the built package inside the submodule — plus a `build:wasm` script, which lwk does not have. Without it, producing that directory is undocumented knowledge that lives only in working copies. The ambient module declaration joins lwk's in `vite-env.d.ts`, since wasm-pack emits no types for the `_bg.js` entry point. Tests instantiate the same bindings the loader consumes, differing only in where the module bytes come from. They assert the module reports its SDK version, compiles the probe contract to 43041b02608dc3ba245a2e3dc7aa5bc991fcf6c097c6a165a18e97a486461729 — the CMR a native build produces for the same source — derives a covenant address, and refuses both an uncompilable source and an unknown network. That the optimised wasm build agrees with a native build is what makes recomputing a covenant address in the wallet meaningful.
…te one The request contract has six parts: the manifest, the sources of the contracts it references, the chosen action and its filled parameters, and the two mutable protocol files the site holds — the instance file and the state file. The fee is deliberately absent; the wallet establishes it. Two separate questions, answered separately. `parseLiquidProcessCtParams` checks the request is well-formed. `resolveActionRequirements` reads the chosen action to work out what that action actually needs, and reports what of it is absent — so a refusal can name the missing part and, where the part is a map, the exact keys. Requiring all six parts of every request would refuse valid ones: a protocol with no covenant parameters has no instance file, and an action that creates rather than spends reads nothing from state. The published p2pk manifest shows both cases in one document — `Pay` needs neither file, `Receive` locates its covenant UTXO through the state file — and it is the fixture the tests run against, unmodified at the corpus commit they name. `resolveActionRequirements` is knowingly narrow: it looks for referenced contract sources, instance references, state lookups and declared parameters, and it is not a general construct registry. The registry is a later slice and should replace this rather than grow out of it. Fixtures are excluded from the formatter, since a fixture that is a copy of a published document stops being that document once it is reformatted.
Taken from simplicityhl 0.6.0's own examples rather than written here, so the tracer's compile-and-derive path is checked against the compiler author's contract instead of against something we invented. Two identifiers renamed to match the published manifest's compile parameter and witness names; nothing else changed.
The wallet proves a contract is genuine by rebuilding it and checking the address matches where the funds sit. That check means nothing unless different compile parameters genuinely produce different addresses, and the same ones reproduce the same address, so both are asserted rather than assumed. Also asserted: a parameterised contract given no parameters is refused rather than compiled against defaults, and malformed argument JSON is refused when the contract is constructed.
…equest
A covenant address is derived from a contract source and the parameters it was
built with. The manifest wires those parameters to references — {"PUB_KEY":
"params.pubkey"} — and the request fills them, so something has to join the two
and hand the compiler its own argument shape.
Everything it cannot resolve refuses rather than resolving to something plausible:
a reference into the instance file, a declared type nobody has mapped, a parameter
with no declared type, a parameter the request left out. That strictness is the
point — these values participate in the address, so a wrong one produces a
well-formed address for the wrong contract instead of an error.
The type map is a closed list holding one entry. The corpus's other declared types
arrive with the slices that need them.
Both reference spellings the corpus carries are accepted: lending uses the
$-prefixed form where lending_v2 uses the bare one.
Two functions, deliberately separate. deriveCovenantAddress rebuilds the contract from the source the request supplied and the parameters the manifest wires into it. The site's contribution changes what the wallet computes; it never changes what that result is checked against. covenantMatchesChain does the checking, and takes the on-chain address as an explicit argument. The state file carries an outpoint and no scriptPubKey, which is the right shape rather than a gap: comparing two values the same site supplied would pass for any pair it chose to make consistent. What sits at an outpoint is read from the network, not told by the requester. A mismatch returns a refusal with both addresses named. There is no shape of this that returns a warning. Compilation is injected, so both are exercised without a wasm module and the module's lifecycle stays where it belongs.
The covenant address check needs a second value that the requester did not supply, and nothing the wallet ships can produce one. The wallet's UTXO snapshot only ever holds outputs it owns, and lwk's Esplora client scans a descriptor and broadcasts — it cannot fetch a transaction. So this reads the output directly from the endpoint the chain record already configures for lwk, headers included, so an authenticated backend does not have to be configured twice. What is read is public chain data: no key, no descriptor, no wallet state. That is what makes a direct read acceptable where a direct write would not be — broadcast stays with lwk. Every way the answer could be unusable fails loudly rather than returning something a comparison would silently match against: a malformed txid or index is refused before the request is made, and an output that comes back without a scriptPubKey is an error rather than an empty string.
…anyone approves it Ties the pieces together. For every covenant the action touches, the contract is rebuilt from the source the request supplied; a covenant being spent is then compared against what the chain says is at its outpoint, and a covenant being created is reported as derived-but-not-yet-on-chain rather than as verified. That distinction is the point. An action that creates a covenant has nothing to compare against, and saying so is more honest than reporting a check that did not happen. Its protection is different in kind: the destination is derived by the wallet rather than supplied by the site. Everything it cannot establish refuses, and the refusal says which thing: a missing request part named by key, a contract that will not compile, a state file listing no such covenant, a chain that cannot be read, an address that does not match. There is no return value meaning 'probably fine'. This runs before the permission gate, where a standing permission cannot skip it, which is why it refuses rather than warns.
…oves it Replaces the stub's four functions. parse accepts the six-part request; review loads the wasm module, rebuilds every covenant the action touches, and for one being spent compares the derived address against what the chain says is at its outpoint; the confirmation carries what the wallet established rather than what the site claimed. review runs before the permission gate, which is what makes this a control rather than a prompt: a standing permission skips the prompt, and would have skipped the verification with it. The stub's ELIP-1 Wallet ABI framing is gone. The flow sends a manifest. execute still refuses, and now says exactly what is missing rather than that the feature does not exist: the wasm module exposes compilation and address derivation, not transaction assembly or signing. The contract is verified by the time anything asks it to build.
…t values Six assertions over the key material now bound in the wasm module: an address on the network it was built for, a different address for the same mnemonic on a different network, the same values twice from one mnemonic, an x-only key shaped for a covenant parameter, a confidential address that differs from the plain one, and a refusal for an unknown network. The mnemonic is the BIP39 all-abandon test vector, not a wallet's.
…fuses what it cannot Six assertions over the assembly binding: an empty builder, a wallet input taken as an outpoint plus the encoded output it spends, an unblinded output, and refusals for a txid that is not one, an output encoding that will not parse, and an asset id that is not one. Each refusal also asserts nothing was added, so a rejected input cannot leave a half-built transaction behind. Amounts are passed as BigInt because they are u64 in the module.
…isation The whole shape a manifest Pay action produces, asserted in one place: a wallet output funds the transaction, an output pays somewhere, and the module returns a consensus-encoded transaction, a txid and a fee greater than zero. Two refusals beside it: inputs that cannot cover the outputs and the fee, and a change script that will not parse — the second because failing is better than finalising a transaction whose change goes nowhere. This is the first evidence in the initiative that smplx's blinding and signing work under wasm rather than only its compilation.
…without keeping it Two pieces the signing path needs, both shaped by what must not happen. The fee rate is read from the same endpoint the covenant check reads, and it fails rather than falling back to a default. The fee is the wallet's business, and a default would quietly turn 'we do not know' into 'we are sure' — which is exactly what refusing is for. When the requested confirmation target is absent it takes the nearest slower one, because being wrong towards a longer wait is the safe direction. withAccountMnemonic runs a callback with the account's mnemonic and takes it away again. It is the whole account secret, so it lives for one call in one place, nothing is cached, nothing is returned, and every wasm object that held it on the way is freed on the way out — including when the callback throws. The derivation is LWK's own, unchanged from how accounts resolve everywhere else, so the account model has one place to drift rather than two. Handing the mnemonic to smplx is the accepted debt this change records, not a shortcut; the conditions that should reopen it are recorded with it.
Turns the action's declared outputs into concrete amounts, so the thinnest real action can be built end to end. Knowingly minimal: it resolves a literal and a `params.` reference and refuses everything else by name. The format's amounts can be arithmetic over other outputs, the fee and chain state, and evaluating those is a dependency graph with a fee re-pass — a later slice's whole subject. This should be deleted when that lands rather than extended one form at a time, which is why it refuses loudly instead of falling through. Amounts are bigint throughout and never become number: a satoshi count above 2^53 is representable in a transaction and not in a double, and the test asserts one survives. Change carries no amount, because change is whatever is left after the fee — and the fee is not known until the transaction has a shape.
The manifest path does not produce a PSET. smplx blinds, signs and finalises internally and hands back a finished transaction, so there was nothing the existing broadcast could take. Adds broadcastTransaction across the same four places the PSET broadcast already lives: the scan core, the client interface, the offscreen client and its protocol, and the offscreen dispatcher. It goes through lwk's Esplora client like every other write, so this does not add a second way to put something on the network — reads were the thing that had to reach the endpoint directly, and writes stay where they were. The dedicated worker rejects it for the same stated reason it rejects the PSET broadcast: LWK's Esplora client needs a window that context does not have.
Largest-first, stopping once the target is covered, which keeps the input count and therefore the fee down. The caller passes headroom for a fee it cannot know exactly yet — the real figure comes from the assembled transaction's weight, and selecting for the outputs alone would leave nothing to pay it with. Selection lives on the wallet's side rather than in the signing module on purpose: the wallet knows which of its outputs it is willing to spend, and a module choosing on its behalf would be making that call somewhere the wallet cannot see it. Amounts are bigint end to end, asserted past 2^53, because a rounded balance is a wrong decision rather than a wrong display.
Completes the path. execute resolves which account acts, works out what the action's outputs pay, establishes a fee rate from the chain, selects coins to cover both, and hands the whole thing to the signing module — which blinds, signs and finalises. Broadcast happens only when the request asked for it; otherwise the signed transaction comes back unsent. Three things are deliberately where they are. The fee comes from the chain and refuses rather than defaulting, because the request carries none and a default would turn 'we do not know' into 'we are sure'. Coin selection stays on the wallet's side, because the wallet knows which of its outputs it is willing to spend. And the account mnemonic exists for the duration of one call inside withAccountMnemonic, which takes it back afterwards — including when the call throws. An output paying a covenant uses the address the wallet derived in review, not one the request supplied. There is no path from a site-supplied address to a transaction output. The resolved account now carries the BIP-85 index it derives at, which was already an input to resolution and simply never came back out. Without it a caller cannot derive the account's own key material without re-deciding which group it is looking at.
…g to execute The seam was recorded this way from the start — review interprets, compiles, verifies and builds; execute signs and optionally broadcasts — and the first implementation put building after the confirmation instead. Moving it back matters for a reason beyond tidiness: what a person is asked to approve should be the transaction that gets signed, not a description of one reassembled afterwards from the same inputs, which might not match. So review now also plans the outputs, establishes the fee rate, and selects the coins, and carries all three into the confirmation. execute rebuilds exactly that and signs it. The wallet's own script comes from its receive address rather than from the signer, so nothing in review touches key material. Deriving a script from an address is public work and should not require a seed. One test narrowed rather than passed: Receive verifies but cannot yet be built, because its output amount references another input and the planner does not evaluate that. It now asserts the refusal is about the amount and not about the covenant, which is what shows verification got past — a weaker claim than before, and the true one.
An action that spends a covenant says what its output pays by referring to the input — `p2pk_in.amount_sat`. That has to resolve against the chain, and it now does: the amount comes from the same read the covenant check already performs, so a request understating what a covenant holds cannot make the wallet pay out less than it should. The planner gains exactly one form for this, `<input_id>.amount_sat`, alongside the literal and the `params.` reference it already had. Everything else is still refused by name. This is what the Receive shape was missing: it verified and was then refused at planning. It now plans, and the test that recorded the limit is replaced by one asserting the amount comes from the chain rather than the requester.
…nant does It does not run. p2pk's program asserts a signature, so executing it before the signature exists fails — not because the binding is wrong, but because a program whose only content is 'this signature is valid' has nothing to check yet. Zero witnesses and pruning help a program with branches the spend does not take; they do not help this shape, and this shape is every covenant that authenticates a spender. The test asserts the observed behaviour rather than a hoped-for one, so a future change that makes it run is visible rather than silent. This is the evidence AC-04 needs to be amended against: it asks for a dry-run before the confirmation surface, and for a signature-bearing covenant there is no run to be had at that moment.
execute added only wallet inputs. An action spending a covenant reviewed it, planned against what it holds, and then built a transaction that did not spend it — a silently different transaction from the one that was approved. review now carries each covenant input out with what it was verified against: the outpoint, the re-encoded output, and the source and arguments the derivation actually used. Rebuilding from those rather than resolving the request a second time means the thing spent is the thing checked, by construction rather than by two paths agreeing. Re-encoding is only valid for an explicit output, which is not a limitation here: a covenant output cannot be confidential, because Simplicity's introspection jets cannot read a confidential commitment. One that comes back confidential is refused rather than guessed at, and a test asserts that. Covenant inputs go in before wallet inputs, since the manifest's own input order is what a covenant introspects and the wallet's funding is an addition to it.
…o load The method could only be exercised by building the extension and running it, which is why nothing exercised it. Its outside reaches are now one named object with the extension's wiring as the default, so a test can substitute them. Six assertions over the seam itself: it builds and signs and returns the transaction unsent by default; it broadcasts only when asked and returns the network's txid; it reads the account mnemonic exactly once; it refuses a missing contract source by name; it refuses a malformed request; it refuses an undeclared action. Three of them also assert the mnemonic was never reached, so a refusal cannot quietly touch the seed on its way out. Writing the test surfaced a real defect: importing the module pulled in webextension-polyfill, which throws outside an extension. The sync-worker client is now imported when a transaction is actually broadcast rather than at module load — nothing else in this method needs a browser, and now neither does loading it.
…eeds A document the wallet did not write arrives as parsed JSON with nothing guaranteed about its interior, so reading it is a sequence of "is this the shape I think it is" questions. Four files had answered them separately.
Every failure the popup showed was "[object Object]". The background
serialises a thrown error structurally — {message, code, data} — so a dapp
can branch on the code rather than parse a sentence, and the popup client
put that object through String(). So the one place the message is written
for a person is the one place it did not arrive, and has not since the
serialisation became structured.
Found on the send screen while funding a contract action: a real refusal,
with a real explanation, rendered as its own type name.
The client now reads the structured shape and keeps the code and data on
the error, since a caller here has as much right to branch as a dapp does.
Anything neither string nor message-shaped is rendered as JSON rather than
as its type name — an unreadable error is worse than an ugly one.
Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
The send adapter attaches what actually went wrong as the cause, with a comment saying it does so "so real errors (broadcast, insufficient funds, address) stay diagnosable instead of collapsing into an opaque WALLET_TRANSFER_FAILED". The serialiser then dropped the cause at the message boundary, so the collapse happened anyway — one layer further out and invisible from the code that took care to prevent it. The cause chain now crosses the boundary, bounded because it can be circular, and the message a person sees carries it: "Could not build, sign, and broadcast the Liquid transfer. — caused by: InsufficientFunds…". Only the message reaches a screen, so a cause kept solely as a field would have been preserved and still unread. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
A contract action can only spend an explicit output, and the wallet could only receive to and pay a confidential address. So nobody could put money where a contract action could reach it — not from a faucet through the wallet, and not from the wallet itself. The contract identity screen exists to show the address to fund, and funding it was impossible. No fork patch needed: the chain library already has an explicit-recipient path and its wasm binding is already exposed. The ordinary path refuses an address with no blinding key, which is what "Address must be confidential" was, so the recipient's own shape picks the path. Draining already took the address as it was. The confidentiality lost is the point of the address, and the review screen has always said so before anyone confirms. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
Wollet::utxos walks the unspent cache and then skips every entry whose amount is explicit, so an unblinded output at one of the wallet's own scripts is never listed — the library says as much elsewhere, that such outputs "are considered external". The output is in the cache; only the listing drops it. That is the money, and the only money, a contract action can spend: the signing module gets an outpoint and its bytes, and unblinding needs secrets it is never given. So the wallet could be funded and still report nothing available, which is what happened an hour after it learned to do the funding. The list is built from the wallet's own transactions — each reports which outputs are its own and which inputs spent its own outputs, so unspent is the difference. No network call, no second source, and nothing counted as the wallet's that its own scan did not already claim. Kept separate from getUtxos rather than folded in. That one answers the dapp-facing getUTXOs and the portfolio snapshot, which describe the wallet as the chain library reports it; widening them would change an existing contract to fix a different problem. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
The signing module is handed an outpoint and its bytes and no derivation path, so it signs every wallet input with one key: the account's first external address. An explicit output anywhere else in the range is real money the wallet owns and cannot spend here. Offering it to coin selection would buy a failure inside the signing module — after the person approved — in place of a shortfall stated plainly beforehand. Today only one such output exists and it happens to sit at the right index, so this costs nothing now and is the difference between a refusal and a crash the first time it does not. Claude-Session: https://claude.ai/code/session_01FwsThjiPHkRvcSs5ThmrBQ
Arvolear
reviewed
Aug 5, 2026
Comment on lines
+168
to
+186
| for (const covenant of review.covenantInputs) { | ||
| builder.addCovenantInput( | ||
| covenant.txid, | ||
| covenant.vout, | ||
| covenant.txOutHex, | ||
| covenant.source, | ||
| covenant.argumentsJson, | ||
| // No witness values: a covenant that authenticates its spender needs a | ||
| // signature over this transaction, which only the signer can make, and | ||
| // naming it is what asks for one. | ||
| undefined, | ||
| covenant.signatureWitness, | ||
| sequenceFor(review, covenant.id), | ||
| ); | ||
| } | ||
|
|
||
| for (const utxo of review.selected) { | ||
| builder.addWalletInput(utxo.txid, utxo.vout, utxo.txOut); | ||
| } |
Member
There was a problem hiding this comment.
We generally don't know which inputs go first. They should be added in the same order they are declared in the manifest.
Arvolear
reviewed
Aug 5, 2026
Comment on lines
+17
to
+25
| * This is not the wallet's own address and is not interchangeable with it. Contract | ||
| * actions are signed inside the smplx module by a single key at | ||
| * `m/84h/{1|1776}h/0h/0/0`, and the module funds and returns change to that key's own | ||
| * unblinded address rather than to a wallet change address — so a covenant action can | ||
| * only spend what sits there, and only what sits there unblinded. | ||
| * | ||
| * Both values are read-only and public: an address anyone can pay, and the x-only form | ||
| * of the same key. Nothing here derives, stores or returns a secret. | ||
| */ |
Arvolear
reviewed
Aug 5, 2026
| return { ok: false, reason: `Compile parameter ${name}: ${found.reason}` }; | ||
| } | ||
|
|
||
| if (typeof found.value !== "string") { |
Member
There was a problem hiding this comment.
This runtime will have to understand the case when a parameter is another covenant scriptHash.
Arvolear
reviewed
Aug 5, 2026
| } | ||
|
|
||
| /** The names a Liquid protocol's `chain` can carry. */ | ||
| const LIQUID_CHAINS = new Set(["elements", "elements-regtest", "liquid", "liquid-testnet"]); |
Arvolear
reviewed
Aug 5, 2026
| } | ||
|
|
||
| const needed = targetSats + headroomSats; | ||
| const usable = available.filter((utxo) => utxo.spendable && !utxo.confidential); |
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Performs one action of a txManifest protocol: a site sends the protocol document, the sources of the contracts it references, the chosen action and its filled parameters, and the wallet returns a signed Liquid transaction.
The point is that the wallet does not have to know the protocol in advance. It reads the document, works out what the step does, rebuilds every contract from the source supplied, and — for a holding being spent — compares the address it derived against where the network says the funds actually are. A mismatch refuses, and there is no way to click through it.
Depends on BlockstreamResearch/smplx#117; the
smplxsubmodule pins that branch.What a person gets
A confirmation screen showing the effect on their own balance, the fee the wallet worked out, which account is acting, and whether each contract was checked against the network. Every value says where it came from — established by the wallet, read from the chain, or claimed by the site. The site's own words are shown, because someone deciding needs to know what the site says it is doing, and labelled, because the wallet checked none of them.
Two of the seven published example protocols can be performed today. The other five are refused, each naming the first construct in its document this runtime does not read. A refusal is a protocol this wallet cannot perform — the coverage is asserted as a standing test so it cannot drift silently.
How it is built
packages/tx-manifest— the runtime, wallet-agnostic: it holds no keys, opens no connection of its own, and remembers nothing between calls. The same request twice produces the same transaction, enforced structurally rather than intended.Five modules the rest reads through, because the format changes faster than its own specification and a runtime built by accumulating special cases would be rewritten at every generation: one normalisation layer producing a canonical document from either declaration shape and every legacy spelling; a construct registry saying what is read and what is load-bearing; references resolved by position rather than by syntax; the covenant sites of an action; and the shape tests a document nobody wrote demands.
apps/extensionowns coin selection, the signing key, the change address, the fee rate, the confirmation surface and broadcast. The signing module owns assembly, blinding, signing and finalisation.Composition of this branch
smplxsubmodule + CI actionapps/webdashboard card.workflow/,.claude/,.agents/,skills-lock.jsonChecks
465 tests across 27 files, including the seven published protocol documents run through the runtime, the deployed
simplicity-lendingcontracts compiled with their commitment merkle roots pinned, and the fee model's transaction weights measured against the real module rather than modelled.This branch also adds the CI gate the repository never had — the only workflows were a manual build and two deploys, so no test had ever run in CI — and closes two holes in the local gate:
apps/webrantsc --noEmitagainst a solution config, which checks nothing and exits 0, andpackages/was outside every project.What is not verified
No transaction from this path has reached a network. The runtime is exercised end to end against a substituted signing module and the real wasm one, but not against the chain.
A contract action is signed by a single key at
m/84h/{1|1776}h/0h/0/0, and the module funds from that key's own unblinded address and returns change there — so an action can only spend what sits at that address, unblinded. The wallet now shows that address and its x-only key (Settings → the account → Contract signing identity) rather than hiding the limit. Removing it needs the SDK to sign each input at its own path, which #117 lays the groundwork for.