Files
such.software/apps/web/content/blog/smirk-v0-3-0-nostr-and-self-hosting.mdx
T
jwinterm 037304b1ad fix(blog): render tables, drop typography's backticks, correct the transport claim
TABLES. next-mdx-remote had no remark plugins, so GFM tables rendered as literal
pipe characters in a paragraph. Four published posts were affected, not just the
new one: bauhaus-echo-launch, flutter-3d-physics-dice, vegan-iq-data-pipeline
and smirk-v0-3-0. remark-gfm is now wired into MDXRemote's mdxOptions.

BACKTICKS. @tailwindcss/typography wraps every inline <code> in literal backtick
characters via code::before / code::after. The served HTML was already clean;
the marks were injected by CSS. Monospace already marks code as code, so the
backticks were a redundant second marker that reads as a typo in running prose.
Suppressed across .prose.

TRANSPORT. The previous version's "transports" section covered only Grin slate
channels. Tor is a separate mechanism and was undocumented: the wallet accepts a
.onion backend origin over plain http, alongside private ranges and .local,
because onion services carry their own transport authentication and no CA issues
for them. The onion pattern matches the label immediately before .onion, so an
onion-lookalike public host is refused. On the backend, the admin plane binds to
loopback and TOR_ADMIN_ONION adds exactly one onion host to its Host allowlist
as a DNS-rebinding guard.

There is no bundled Tor daemon and no I2P support anywhere in either repo. The
post now says so rather than leaving it implied.

COPY. Removed the editorial voice throughout: no more "the part worth
explaining", "the instructive one", "worth dwelling on". A technical report
states what a thing does and lets the reader decide what matters. Headings are
now descriptive rather than argumentative.
2026-08-21 05:29:35 -04:00

223 lines
12 KiB
Plaintext

---
title: "Smirk Wallet v0.3.0: Nostr identity, federated backends, and Grin slate transports"
date: "2026-08-20"
excerpt: "v0.3.0 makes Smirk federated end to end: a configurable backend host, a NIP-07 provider with tiered signing, Grin slates over NIP-59 gift-wraps, and smirk-backend-core released under MIT."
draft: false
---
# Smirk Wallet v0.3.0
v0.3.0 removes the remaining assumptions about our infrastructure. The backend host is
per-wallet configuration, Grin is fully non-custodial, and the server is a public repo.
The wallet ships as a Chrome MV3 and Firefox extension plus a Tauri 2.x desktop shell for
Windows, macOS and Linux, built from the same code path. Capacitor mobile is deferred to
v0.4.
## Federation
No component points at `api.smirk.cash`. `homeDomain` is per-wallet configuration naming an
instance of [smirk-backend-core](https://github.com/Such-Software/smirk-backend-core),
released under MIT.
<figure>
<svg viewBox="0 0 720 250" role="img" aria-label="Two wallets pointing at two independent backends, each delegating to its own chain nodes, with a Nostr relay layer connecting the two wallets directly." style={{width:"100%",height:"auto"}}>
<defs>
<marker id="fedArrow" markerWidth="9" markerHeight="9" refX="8" refY="3" orient="auto">
<polygon points="0 0, 9 3, 0 6" fill="currentColor" opacity="0.55" />
</marker>
</defs>
<g fill="none" stroke="currentColor" strokeWidth="1.5" opacity="0.85">
<rect x="20" y="20" width="150" height="44" rx="8" />
<rect x="20" y="150" width="150" height="44" rx="8" />
<rect x="285" y="20" width="160" height="44" rx="8" stroke="#2f9e68" />
<rect x="285" y="150" width="160" height="44" rx="8" stroke="#2f9e68" />
<rect x="560" y="20" width="140" height="44" rx="8" opacity="0.6" />
<rect x="560" y="150" width="140" height="44" rx="8" opacity="0.6" />
</g>
<g fill="currentColor" fontSize="13" textAnchor="middle">
<text x="95" y="47">Wallet A</text>
<text x="95" y="177">Wallet B</text>
<text x="365" y="40" fill="#2f9e68">backend-core</text>
<text x="365" y="57" fill="#2f9e68" fontSize="11" opacity="0.85">homeDomain: a.example</text>
<text x="365" y="170" fill="#2f9e68">backend-core</text>
<text x="365" y="187" fill="#2f9e68" fontSize="11" opacity="0.85">homeDomain: b.example</text>
<text x="630" y="41" opacity="0.7">Electrum,</text>
<text x="630" y="56" opacity="0.7">LWS, grin-lws</text>
<text x="630" y="171" opacity="0.7">Electrum,</text>
<text x="630" y="186" opacity="0.7">LWS, grin-lws</text>
</g>
<g stroke="currentColor" strokeWidth="1.5" opacity="0.55" markerEnd="url(#fedArrow)">
<line x1="172" y1="42" x2="281" y2="42" />
<line x1="172" y1="172" x2="281" y2="172" />
<line x1="447" y1="42" x2="556" y2="42" />
<line x1="447" y1="172" x2="556" y2="172" />
</g>
<g fill="none" stroke="#3d93c4" strokeWidth="1.5" strokeDasharray="5 4">
<path d="M95 66 L95 148" opacity="0.9" />
</g>
<g fill="#3d93c4" fontSize="12">
<text x="112" y="103">Nostr relays: identity, DMs, slates.</text>
<text x="112" y="120">Wallet to wallet, across backends.</text>
</g>
</svg>
</figure>
The two backends share no state. A wallet on `a.example` pays a wallet on `b.example`
because identity and message transport ride Nostr relays rather than a shared database.
## Backend origins: onion and LAN
A backend URL must be `https`, with three exemptions where requiring TLS would make a
legitimate instance unreachable:
| Origin | Accepted over plain http | Reason |
|---|---|---|
| `.onion` (16 or 56 char) | yes | Onion services carry their own transport authentication and encryption, and no CA issues certificates for them |
| Private ranges, `.local` | yes | A self-hoster running a box on their own LAN |
| `localhost` | yes | Local development |
Everything else is rejected. The onion pattern matches the label immediately before
`.onion`, so `abcdefghijklmnop.onion.example.com` is a normal public host and is refused.
The backend's admin plane binds to loopback by default. Exposing it requires an SSH tunnel
or an onion: `TOR_ADMIN_ONION=<hash>.onion` adds that one host to the admin `Host`
allowlist, which otherwise accepts only loopback names. A request arriving with any other
`Host` gets a 403, which is the DNS-rebinding guard. A non-loopback bind additionally
requires `ADMIN_ALLOW_PUBLIC_BIND=true`.
There is no bundled Tor daemon and no I2P support. The wallet accepts onion origins; routing
to them is the operator's Tor instance.
## Nostr identity and tiered signing
The wallet exposes a NIP-07 `window.nostr` provider, so Nostr-native sites request a public
key and have events signed without a second extension. A multi-identity vault holds separate
keypairs with an in-wallet switcher.
A Nostr signature is a bearer credential, so which events a connected origin may sign
without a fresh prompt is a security decision. Kinds are classified into three tiers.
<figure>
<svg viewBox="0 0 720 210" role="img" aria-label="A sign-event request routed by kind into three tiers: money kinds always prompt, session-grantable kinds may use a time-boxed grant, and everything else prompts per event." style={{width:"100%",height:"auto"}}>
<defs>
<marker id="tierArrow" markerWidth="9" markerHeight="9" refX="8" refY="3" orient="auto">
<polygon points="0 0, 9 3, 0 6" fill="currentColor" opacity="0.55" />
</marker>
</defs>
<g fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="16" y="80" width="132" height="46" rx="8" />
<rect x="196" y="80" width="104" height="46" rx="8" />
</g>
<g fill="currentColor" fontSize="13" textAnchor="middle">
<text x="82" y="100">signEvent()</text>
<text x="82" y="117" fontSize="11" opacity="0.7">from a site</text>
<text x="248" y="100">kind tier</text>
<text x="248" y="117" fontSize="11" opacity="0.7">classifier</text>
</g>
<g stroke="currentColor" strokeWidth="1.5" opacity="0.55" markerEnd="url(#tierArrow)">
<line x1="150" y1="103" x2="192" y2="103" />
<path d="M302 96 L340 96 L340 32 L392 32" fill="none" />
<line x1="302" y1="103" x2="392" y2="103" />
<path d="M302 110 L340 110 L340 174 L392 174" fill="none" />
</g>
<g fill="none" strokeWidth="1.5">
<rect x="396" y="10" width="308" height="44" rx="8" stroke="#d1568c" />
<rect x="396" y="81" width="308" height="44" rx="8" stroke="#2f9e68" />
<rect x="396" y="152" width="308" height="44" rx="8" stroke="currentColor" opacity="0.6" />
</g>
<g fontSize="12.5">
<text x="412" y="29" fill="#d1568c" fontSize="13">money: 17, 27235, 30402, 22242</text>
<text x="412" y="46" fill="#d1568c" opacity="0.85">always prompt, never session-grantable</text>
<text x="412" y="100" fill="#2f9e68" fontSize="13">session-grantable: 1, 7, 1059</text>
<text x="412" y="117" fill="#2f9e68" opacity="0.85">time-boxed grant, no re-prompt in window</text>
<text x="412" y="171" fill="currentColor" fontSize="13" opacity="0.75">default: everything else</text>
<text x="412" y="188" fill="currentColor" opacity="0.6">prompt per event, unknown kinds land here</text>
</g>
</svg>
</figure>
The wallet handler is the enforcement point. It computes `sessionCovered=false` for any kind
that is not session-grantable and refuses to persist a money-tier kind into a session, so the
classification holds independently of the approval UI.
Kind 27235 is NIP-98 HTTP auth. The backend mints a full session from a NIP-98 event via
`POST /auth/nostr`, so it is money-tier, with an origin guard on the event's `u` tag.
## Grin slate transports
Grin is interactive: a payment requires slatepack legs to travel between sender and receiver.
The slate is one payload; the channel is a separate choice.
<figure>
<svg viewBox="0 0 720 200" role="img" aria-label="Three Grin slate transports: Nostr gift-wrap as the federated default, manual clipboard as the universal fallback, and the backend relay as a same-instance shortcut." style={{width:"100%",height:"auto"}}>
<g fill="none" strokeWidth="1.5">
<rect x="14" y="14" width="692" height="52" rx="8" stroke="#2f9e68" />
<rect x="14" y="76" width="692" height="52" rx="8" stroke="currentColor" opacity="0.75" />
<rect x="14" y="138" width="692" height="52" rx="8" stroke="currentColor" opacity="0.45" />
</g>
<g fontSize="13">
<text x="30" y="36" fill="#2f9e68" fontSize="14">Nostr gift-wrap (NIP-59 kind 1059)</text>
<text x="30" y="55" fill="#2f9e68" opacity="0.85" fontSize="12">federated default, crosses backends, relays learn neither sender, amount, nor slate</text>
<text x="560" y="36" fill="#2f9e68" fontSize="12">npub / NIP-05</text>
<text x="30" y="98" fill="currentColor" fontSize="14">Manual clipboard</text>
<text x="30" y="117" fill="currentColor" opacity="0.7" fontSize="12">universal fallback, no network dependency, age-encrypted to the recipient address</text>
<text x="560" y="98" fill="currentColor" opacity="0.8" fontSize="12">grin1… address</text>
<text x="30" y="160" fill="currentColor" opacity="0.75" fontSize="14">Backend relay</text>
<text x="30" y="179" fill="currentColor" opacity="0.55" fontSize="12">same-instance only, both parties registered on one backend, never required</text>
<text x="560" y="160" fill="currentColor" opacity="0.6" fontSize="12">backend user_id</text>
</g>
</svg>
</figure>
The address in the send box selects the transport. An npub or NIP-05 handle routes over
Nostr. A bare `grin1…` address defaults to clipboard, and is upgraded to gift-wrap when a
same-instance lookup resolves that address to its owner's npub. The backend relay serves two
users on one host; there is no cross-backend relay federation.
Counterparty keys are pinned on first use, so a later key change for the same NIP-05 handle
is surfaced rather than trusted.
## smirk-backend-core
Rust, Axum, PostgreSQL. It provides chain reads, fee estimation, broadcast, Nostr identity, a
price feed, a Grin slatepack mailbox and the public social-tips subsystem, without holding a
spend key or a seed.
Stored: public addresses, public keys, and for Monero and Wownero the incoming view keys.
View credentials are forwarded per request to the chain backends that scan with them and are
not persisted. Signing and broadcast happen in the wallet.
| Chain | Source |
|---|---|
| Bitcoin, Litecoin | Electrum / Fulcrum |
| Monero, Wownero | light-wallet-server, stateless view-key forwarding |
| Grin | grin-lws, or grin-wallet view-only plus a node |
Chains are independently feature-flagged. An unconfigured chain reports `enabled: false` from
`/capabilities` rather than failing at call time, so a Bitcoin-only deployment is a supported
configuration.
The HTTP contract is generated from the handlers into `openapi.json`, which is also the
source for the wallet's generated client. A CI gate fails the build on drift.
Grin is non-custodial in this release. The backend no longer holds Grin balances, outputs or
transaction records; those endpoints and the server-side wallets table are removed. Grin
outputs use the standard deterministic rewind-nonce scheme, so a wallet restored from a seed
phrase rediscovers its outputs from the chain, matching how Bitcoin and Monero already
restored. Outputs created by earlier builds remain spendable.
Two supporting components are public: [grin-lws](https://github.com/Such-Software/grin-lws),
a standalone Grin light wallet server, and a non-custodial BTC/LTC payment processor exposing
a BTCPay-style invoice surface where funds go to operator-controlled addresses.
## Also in this release
Taproot (`bc1p…`, `ltc1p…`) recipients in Send and Swap. Cross-chain sends blocked, where a
Monero address could previously be used as a Wownero destination. Public tip keys encrypted
at rest. A custom fee rate below the relay minimum now fails rather than being silently
raised at broadcast. Connected sites visible and revocable in Settings.
v0.3.0 is feature-complete and security-reviewed. The schema and API may still change; run
your own chain backends where possible.