Skip to content

Every wallet stands behind one facade, and performs every protocol action - #162

Draft
lukachi wants to merge 6 commits into
BlockstreamResearch:mainfrom
lukachi:humid/wallet-protocol-actions
Draft

lukachi wants to merge 6 commits into
BlockstreamResearch:mainfrom
lukachi:humid/wallet-protocol-actions

Conversation

@lukachi

@lukachi lukachi commented Aug 28, 2026 •

Copy link
Copy Markdown

The dapp used to build its own transactions against one wallet. It now hands the protocol's own deployed document to whichever of four wallets is connected, and every screen sees the same value whichever that is.

Before: screens named connectors directly — providers/wallet/ and providers/humid/ were parallel paths to a wallet, and adding one meant adding another path. Transaction building lived here, written against the protocol rather than reading it. The chain session (cached wollet, IndexedDB, cross-tab lock, periodic reread) sat inside the wallet provider. Only the extension could perform a protocol action.

Now: providers/walletFacade/ is the only thing a screen talks to, with four adapters behind it — the HUMID extension, a Jade device, a recovery phrase held in the page for demos, and SideSwap over its relay. For each action the dapp hands over the deployed manifest, the .simf sources of every covenant involved, the factory instance, the indexer's state and the person's parameters. The extension compiles those and builds the transaction itself; a wallet that signs in the page follows the document's own input rule and calls the shared builder. All eight actions work on every wallet. The chain session moved behind the facade, and adding a wallet is now writing one adapter.

web/src/test/theSeam.test.ts walks the import graph from the entry point and fails if anything above the facade names a wallet directly, or if the application can reach a superseded module. Nothing was deleted; the pre-facade wiring stays on disk and unreachable.


Trying it out

1. Install the HUMID extension

Download humid-extension-1.1.0-rc.0.zip from humid 1.1.0-rc.0 and unzip it. In Chrome: chrome://extensions → enable Developer mode → Load unpacked → select the unzipped folder. Create or import a wallet in the extension and switch it to Liquid testnet.

2. Start the indexer API

From the repo root — the web app reads offers from it and will not list anything without it:

cp .env.example .env
docker compose up --build      # api on :8000

3. Configure and run the web app

cd web
cp .env.liquidtestnet .env
pnpm install
pnpm dev                        # http://localhost:5173

Worth setting in web/.env:

Variable Why
VITE_REOWN_PROJECT_ID Initialises AppKit for the extension connect option. Register your own at dashboard.reown.com — free, takes a minute. Leaving it unset falls back to a shared HUMID test id, which is fine for local testing.
VITE_DEMO_MODE=true Unlocks the recovery-phrase wallet card and the 3-minute offer term, so a full borrow/repay cycle finishes while you watch. Recommended for testing.
VITE_SIDESWAP_WS_URL Enables the SideSwap card. Needs a SideSwap dev build to sign. Leave unset to hide it.
VITE_DEBUG_MNEMONIC Auto-connects the in-page wallet from a fixed phrase — skips the connect step on every reload.

4. Fund the account

Send testnet L-BTC to the address the wallet shows. Then send it to itself once, unblinded — a contract action can only be funded from unblinded outputs at the signing address, so a normally-funded account shows a balance the protocol cannot spend. This is a property of the wallet, not of this change.

5. Connect and act

The picker shows four cards, each disabled only for a reason true of your browser or build:

Wallet Needs
HUMID The extension installed, and VITE_NETWORK set to liquid or liquidtestnet (regtest has no chain)
Jade A Chromium browser — the card is disabled where navigator.serial is missing
Recovery phrase VITE_DEMO_MODE=true
SideSwap VITE_SIDESWAP_WS_URL set

Then: create a borrower account, create an offer, and from a second account accept it — followed by repay, or let the term lapse and liquidate.


Verified on chain

Liquid testnet, against the deployed protocol.

Action Transaction
Create offer e0154712…
Cancel offer 2e089324…
Accept offer cddc64df…
Claim principal 114c0bee…
Liquidate 1afe98ad…
Repay b2bbcddd…
Lender settlement 793d73d7…

Creating a borrower account on a wallet that signs in the page is proven by 61d9833f at block 2590033. The other seven on a signing wallet await a recorded manual run — the table above is the extension path.

Gates: tsc -b, eslint, prettier --check, vite build and 143 tests, all clean. Two adversarial reviews over five rounds broke seventeen things with executable tests; every one is fixed here.

Two known limitations

Neither is introduced here; both are properties of the wallet, recorded rather than worked around.

  • A contract action can only be funded from unblinded outputs at the wallet's signing address (see step 4).
  • The principal is paid out confidential by the deployed document's own declaration, so a borrower changes its form before repaying.

The dapp no longer assembles a transaction. For each of the seven actions a
person can take on a lending offer it hands the wallet the deployed manifest,
the .simf sources of every covenant involved, the instance the factory
published, the state the indexer reports, and the parameters the person chose.
The wallet compiles the sources, resolves what the manifest computes for
itself, builds the transaction, shows what it will do and broadcasts it.

Landed here:

- One wallet facade between the application and any provider, connected to the
  humid extension through the injected AppKit adapter.
- Creating a borrower account and creating an offer, from the protocol's own
  deployed document rather than from code written against it.
- Accepting, cancelling, claiming the principal, liquidating, repaying and
  collecting the lender settlement, all through one shared request builder and
  one shared hook, so each modal and each demo page differ only in the action
  they name.
- What a contract action can be funded from is read from the chain, because
  the wallet does not serve it.
- The deployed lending document is carried beside the contracts it describes,
  with the two references that named a namespace unable to hold them corrected.
- The indexer's compose build generates the contract artifacts it depends on.
- Tests over the request builder, the shared action hook and the seam itself.
…action

The dapp acted through one wallet. It acts through four now - the HUMID
extension, a Jade device, a recovery phrase held in the page for demos, and
SideSwap over its relay - and every screen sees the same value whichever is
connected. Adding a wallet is writing one adapter; it is never another path
from a screen to a connector.

Selection. connect takes the wallet the person pressed and whatever that wallet
needs to start - a phrase, an address kind, a resume flag - availability is
reported per wallet rather than as one flag, and signerType is the connected
wallet's own answer. Nothing selected refuses by name rather than answering from
whichever adapter happened to be built first, and a wallet already connected or
coming back from a held approval is selected without anyone pressing anything.

The account. Descriptor derivation stopped assuming a bare descriptor: the chain
library is asked rather than the string guessed at, so a connector's already
confidential descriptor is read as it stands and only a bare one is wrapped.
scriptPubkey is a real script on all four wallets. What an adapter publishes has
the account's own blinding key taken out first - anything holding it could
unblind every output the account owns - and a descriptor whose key cannot be
taken out is refused rather than passed on.

The chain. The cached wollet, its IndexedDB namespace, the cross-tab lock, the
60s reread with its throttle and focus handlers, the reconciliation of a
just-broadcast transaction and the asset-contract stage that lets a device show
asset names rather than hashes all moved out of the old provider and behind the
facade. It gives back the Rust memory it takes. syncWallet rereads the chain
before the balances are asked for, which is what the six builders that call it
before choosing inputs have always meant by it. A reread nobody asked for
reaches the screens rather than sitting in the session.

Performing. Each wallet that signs in the page performs an action by following
the document's own input rule - an input is either one the wallet finds itself,
named by asset and amount, or one located in the deployment's state by its type
- and hands the result to the builder that already implements that action. All
eight actions, creating the borrower account included, which every other one
presupposes. The seven builders moved beside the protocol they build for,
because a wallet and a screen both call them now.

What the screens are told. The confirmed balance is what has confirmed, because
only that can fund an action; the receive address is the wallet's own; a device
waiting for its PIN reads as locked and gets its unlock screen back; a wallet
being approved elsewhere says which request and offers the link and a cancel.
The picker offers four cards, each disabled only for a reason true of this
browser or this build.

The seam test still guards the seam. Modules that became adapters left its
superseded list, each departure stated, and it now notices a screen reaching for
a wallet however the import is spelled.

Two adversarial reviews over five rounds broke seventeen things with executable
tests, every one of them fixed here. Forty-five stubs of this code fail the
suite that used to stay green under them. Typecheck, lint, format, build and 143
tests pass at zero warnings.

Creating a borrower account is proven against Liquid testnet by transaction
61d9833f at block 2590033. The other seven actions on a signing wallet await a
recorded manual run.
The wallet now funds a contract action from every output it holds in the asset,
the blinded ones included: it unblinds its own money and hands the signing
module the secrets. This page was still counting only what the chain shows in
the open at the account's first address, which on a network that hides by
default is a small part of the account and reads to the person as an account
holding almost nothing.

So the count is now two reads that do not overlap. The wallet answers for what
it holds, which is the only way a blinded amount can be counted at all. The
chain read is left with the one output the wallet's own list leaves out, an
unblinded output at the first address, which the chain library treats as
external and omits while the wallet's action funding reads it separately and
spends it. They are added by outpoint, so an output both reads return is
counted once.

getUtxos was already a capability every adapter serves and the facade did not
publish; it does now, and nothing above the facade names a wallet to get it.

The vendored connector is re-copied from humid at 7ef0dab, byte for byte as
VENDOR.md requires. One behavioural line in it: the wallet's answer to an
action is now a stated shape rather than "no dapp-facing shape exists yet".

The manifest correction was re-checked against the same commit and is still
needed, for the reason recorded beside it.

Not verified: no typecheck, lint, suite or end-to-end run has been made against
any of this.
@lukachi
lukachi force-pushed the humid/wallet-protocol-actions branch from 7c5ca63 to 3ef16ac Compare September 18, 2026 11:17
Every action after an offer is created reads the deployment it created, and
six of them declare no create_instance, so the wallet works nothing out for
them: the deployment is exactly what this page sends. It sent the ten values
the indexer records and left the covenant script hashes out, on the reasoning
that the wallet would compute them from the document. It does not, for an
action that creates nothing, and so each of those actions failed when the
wallet came to compile a covenant from a field that was not there.

The deployment now carries every compile-time field. The five vault and
script hashes and the lending covenant's script hash are derived here through
the same derivation the offer was created with, and CURRENT_DEBT through the
same computation that builds the lending covenant's storage leaf. ZERO_HASH is
sent as well: the document gives it a default, but the wallet fills defaults
only for an action's own parameters, and none of the actions that spend a
finalized vault declares it.

The tests hold the derived hashes to the values the in-page path computes for
the same offer, the debt to a multiply-then-floor division, the lending script
hash to the offer's terms, and every action to the fields it reads beyond the
recorded ten. They run the real chain library, handed its wasm bytes.

Run against Liquid testnet through the HUMID wallet on 2026-09-22, alongside
accepting, cancelling and claiming the principal: RepayLoan f1ff2237 at block
2628663, ClaimLenderVault f214edbc at 2628664, LiquidateOffer 906ae9fb at
2628700.

Typecheck, lint, format and 154 tests pass.

This branch has not been deployed

No deployments
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.

1 participant