Wallet

How the Bitcoin SV wallet inside Henceforth is built — architecture, transactions, SPV, cold mode, and security.

Bitcoin Wallet

Henceforth includes a full Bitcoin SV (BSV) wallet, enabling users to create wallets, receive and send transactions, and interact with the blockchain — all from the same app that runs FORTH programs.

Wallet Architecture

The wallet is built on a hierarchical deterministic (HD) key structure, meaning a single seed phrase generates an unlimited number of addresses.

  • Seed Phrase — a 12-word BIP-39 mnemonic generated when creating a new wallet. The master backup.
  • Key Derivation — two derivation methods are supported. Type42 (BRC-42) is the default for new wallets, using elliptic curve key tweaking for more flexible derivation paths. BIP-44 is the legacy standard, retained for restoring wallets created by other software; path m/44'/236'/0'/0/n.
  • Address Rotation — after each transaction, a new receiving address is derived automatically.

Trust-domain separation (Swift code organisation)

The Swift code that runs the wallet is partitioned into three independent trust domains, each owned by its own @MainActor @Observable type. A façade type, FORTH, holds one instance of each and is the only place that orchestrates across them.

  • ForthInterpreter — the Forth-2012 execution surface: two stacks, the user vocabulary, control flow, terminal text, KEY/ACCEPT, memory blocks, script recording.
  • WalletState — the wallet/payment surface: UTXO and transaction caches, payment-approval cache, PayView / MultiPaySheet / FeeRetry coordination, fee quotes, WoC viewmodels, the ARC service seam.
  • SPVHeaderStore — the SPV header chain: blockHeaders, validatedTipHeight, latestBlockHeight, the txVersion activation gate, the header-fetch viewmodel.

WalletState and SPVHeaderStore each hold a peer-reference to ForthInterpreter for legitimate terminal-IO reach (emitting an error, reading a stack value, writing to the terminal buffer). Deliberately there is no peer-reference between WalletState and SPVHeaderStore — payment code that tries to read blockHeaders directly is refused by the compiler, because the property simply isn't reachable from a WalletState extension. The handful of legitimate cross-domain reads (the wallet's transaction builder needs latestBlockHeight for the OP_VER gate) route through an explicit FORTH.shared?.headers.latestBlockHeight bridge, visible at every call site and reviewable by audit.

Three invariants previously honoured by policy are now enforced by the type system:

  • SPVHeaderStore.blockHeaders and validatedTipHeight are private(set). The four legitimate writers each have a named mutator: commitValidatedRun (chain-validated runs, atomic with tip advance), recordDisplayHeader (display-only WoC fetches, never advances the trust tip), bootstrapFromPersistence (atomic load on launch), resetToCheckpoint (wipe + reseed on checkpoint upgrade or forceResync).
  • WalletState.lastPaymentApproval is private(set). The only setter is setLastPaymentApproval, called by the biometric gate after a successful Face ID prompt or by recordPaymentApproval for upstream-stamped approvals.
  • BlockchainDataProvider is an actor rather than a @MainActor class. The cross-source verifier's async let primary / secondary for WhatsOnChain and JungleBus history fetches now runs in genuine parallel on the cooperative thread pool rather than serialised through the main thread.

The motivation is straightforward: the three domains have very different blast radii on compromise. A bug in interpreter code is a UX glitch. A bug in wallet code is potentially lost funds. A bug in SPV code is silent acceptance of fake "confirmed" status. Partitioning them so the compiler refuses cross-domain reach makes each domain's invariants reviewable in isolation and removes the entire class of "interpreter code accidentally mutating wallet state" failures that the prior single-class shape allowed.

Address Discovery

The wallet discovers addresses with funds using a shared Type42 scanner (scanType42Addresses). The scanner is derivationType-independent — it always tries Type42 first if the master key exists in Keychain, regardless of what derivationType says on the wallet card.

  • Dynamic scan range — scans at least 20 addresses (gap limit) past the last address with activity, with a minimum floor of 50 addresses.
  • Receive and change chains — scans both 1-wallet-{N} (receive) and 0-change-{N} (change) invoice numbers.
  • BIP-44 fallback — after Type42, scans standard BIP-44 paths. A wallet can have funds on both derivation paths.
  • Auto-repair — if Type42 addresses are discovered but derivationType is wrong, the scanner automatically corrects it.

Wallet Cards

Each wallet is represented by a Wallet Card in the app interface. A card stores wallet title, main address, derived addresses, balance, and derivation type. Multiple wallets can be managed simultaneously; biometric authentication is required to access sensitive details.

One wallet can be marked as the default wallet, indicated by a star on the wallet card. The default wallet is used by the terminal send word and other non-UI operations.

Transactions

Receiving

To receive BSV, share your wallet's receiving address or scan its QR code. Monitoring uses JungleBus REST polling every 30 seconds via GET /v1/address/get/{address}, plus a periodic sync as safety net.

Notifications use a UTXO-based deposit detection system tracking known outpoints per wallet — immune to confirmation state changes and mid-sync transient balances. First-launch outpoints are seeded silently.

Sending

Transactions are built locally on the device:

  1. UTXO Selection — avoiding ordinal-protected UTXOs.
  2. Fee Calculation — current rate from GorillaPool or TAAL via ARC policy endpoints.
  3. Change Output — returned to a fresh change address.
  4. Signing — each input signed with the correct private key.
  5. Anti-Fee-SnipingnLockTime set to current block height.
  6. Broadcasting — submitted via ARC with automatic failover.

The pay word opens the transaction builder with three tabs: OP_RETURN, Script, Upload (B:// protocol).

Fragmentation-Aware Selection

UTXO selection switches to a fragmentation-aware policy when the wallet has more than 100 UTXOs OR average UTXO value is below 5000 satoshis with more than 40 UTXOs. Dust-input cap rises from 10 to 50, sort flips to smallest-first, smart-change splitting disabled. A 100 KB ceiling refuses oversize transactions before biometric prompts.

Paymail

Paymail allows sending BSV to human-readable addresses (e.g., [email protected]) instead of raw Bitcoin addresses. Implements BRC-30 receive-transaction callbacks via .well-known/bsvalias discovery, and automatic resolution when a paymail address is entered in the pay view.

UTXO Sync

The wallet's UTXO set is reconstructed from locally-held transactions, not queried from a network endpoint. White paper §8 says SPV clients should derive ownership from chain-validated proofs they hold themselves; treating an external /unspent index as authoritative inverts that model. Henceforth follows the white paper.

  • History authoritative — every confirmed transaction in the local store contributes its outputs to the reconstructed UTXO set. WhatsOnChain /unspent corroborates and surfaces deposits we haven't fetched yet; it is never the source of truth.
  • Canonical address set — the wallet's master address, every derived address, and the currently-rotated receive address are unioned into a single set used by both history sync and UTXO reconstruction.
  • Strict removal policy — a UTXO disappears only on positive evidence of spend: the user broadcast a transaction that consumes it; a confirmed foreign transaction spending it lands in the local history; or the producing transaction fails SPV verification. Absence from an external endpoint is never a removal trigger.
  • Outpoint-consumption index — a derived set of "txid:vout" strings recording every consumed output, rebuilt from the local transaction store on every wallet open and updated incrementally as new transactions arrive.
  • Reconciliation — every full sync partitions outpoints into corroborated, provisional, and wallet-unknown. Wallet-unknown outpoints trigger a background fetch of the producing transaction.

Recovery Tools

  • Add address — paste any BSV address into the wallet's edit sheet; the app searches the wallet's seed across both Type42 and BIP-44 chains for a derivation path that produces it. Pure local computation.
  • Auto-rediscovery — when an incoming transaction pays an address the wallet doesn't yet know about, the app silently attempts the same rederivation in the background.
  • Advanced sync toggle — Settings → Bitcoin → Advanced Sync exposes the active UTXO sync model. Either side is safe; the toggle exists so users can self-recover if either path surfaces an anomaly.

SPV Architecture

The wallet implements Simplified Payment Verification per Section 8 of the Bitcoin white paper: clients trust a locally maintained chain of block headers and verify individual transactions against it using Merkle proofs.

Trust Anchor

The wallet ships with a hardcoded checkpoint in SPVCheckpoint.mainnet(height, hash, previousblockhash, merkleroot, version, time, nonce, bits) for a recent mainnet block roughly 500 confirmations below the then-tip. The checkpoint hash doubles as its own migration version marker: bumping it triggers a wipe of every user's local header store on next launch.

Header Store

FORTH.blockHeaders: [Int: WOCGetHeadersModel]. Every entry must satisfy:

  1. Non-empty merkleroot — pre-Phase-1 placeholder rows are dropped on load.
  2. Individual PoW validates — double-SHA256 of the serialized header must be below the target encoded in bits.
  3. Chain-linked to parentpreviousblockhash must equal the hash of the header at height - 1.
  4. Transitively anchored to the checkpoint.

Validated Tip

FORTH.validatedTipHeight is the highest block height whose header transitively chain-links back to the checkpoint via a contiguous run. SPV verification uses validatedTipHeight as the trust upper bound.

Commit Path

FORTH.commitValidatedHeaderRun(_:) is the single entry point. It requires: (1) the run's first header has a local anchor at firstHeight - 1; (2) the anchor's hash equals the run's first previousblockhash; (3) the full batch passes SPVService.validateBlockHeaderChain.

Auto-Sync

HeaderSyncService opens a WebSocket to wss://socket.whatsonchain.com/websocket/history?from=<validatedTipHeight+1>, buffers decoded headers, flushes runs of 500 via commitValidatedHeaderRun, with idle detection via cancellable Task.sleep(for: 3s) and exponential backoff 1s → 60s cap.

Verification

SPVService.verifyTransaction(...) fetches the TSC (BRC-74) Merkle proof from WhatsOnChain, looks up the containing header via findBoundedBlockHeader (returns the header only when its height falls in [checkpointHeight ... validatedTipHeight]), PoW-validates the local header, and verifies the proof against the local merkleroot. If the bounded lookup returns nil, verification returns blockHeaderNotFoundwithout fetching from WoC.

Checkpoint Maintenance

Bumping the checkpoint is deliberate: pick a target at least 500 confirmations below current tip; fetch from a primary source and cross-verify every field against a second independent source; commit the updated SPVCheckpoint.mainnet. The next launch detects the new hash and wipes/re-syncs.

Cold Mode & Air-Gap Bundles

Cold Mode (WalletCard.coldModeEnabled) defers broadcast: a transaction the user has approved with Face ID is signed and stored locally as a pending action rather than submitted to ARC. The user releases it later from the Pending Broadcasts view.

Three serialisable JSON formats carry data across the air-gap, exchanged via AirDrop and validated on import:

  • .hfheaders — a contiguous run of validated block headers anchored to a checkpoint hash. The importer hands the run to commitValidatedHeaderRun so chain-link, PoW, and checkpoint-anchor invariants self-enforce.
  • .hfreceipt — proof that a specific transaction was included in the blockchain. Carries (txid, raw tx hex, block height, block hash, TSC Merkle proof, containing header). verifyTransaction(fromBundle:) validates inclusion against the importer's own local header with no network call. After SPV passes, the importer re-derives output ownership and refuses receipts that don't pay this wallet.
  • .hfaddrpack — pre-derived receive addresses pushed signer → watcher. Each entry has the address, derivation scheme, and the scheme-specific derivation reference. Signed by the wallet's hardened identity key at m/13'/0' with DER ECDSA — deliberately isolated from the receive/change derivation tree to avoid ECDSA nonce-reuse leakage.

Settings → Bitcoin → Air-Gap Tools exposes three exporters and a single import button (routed by file extension). In v1.0, .hfaddrpack import validates the signature; persistence into a watch-only store is a follow-up in a later release.

Wallet Data Management

UTXO and transaction persistence is the most security-critical layer of the wallet. The rules below are invariants, not preferences.

  • Never overwrite UTXO files with partial data. Saved only when all addresses queried successfully.
  • Conservative merge. A UTXO is removed only when its owning address was queried and the UTXO was not in the response.
  • Never persist a smaller transaction set than what's on disk. persistTransactionsToDisk refuses the write if the in-memory snapshot has fewer entries.
  • Per-wallet file scoping. Transactions, UTXOs, and pending broadcast amounts live in wallet-scoped files. On wallet open, in-memory state is cleared and reloaded from that wallet's files only.
  • Trust the per-wallet file on load. Load-time byOwnership filtering has been removed — it dropped Type42 change and paymail receives, then silently wrote the smaller set back.

UTXOs & Balance

A UTXO represents a discrete amount of Bitcoin that can be spent.

Ordinal Protection

1Sat Ordinals are NFTs inscribed on single-satoshi UTXOs. Protected at three levels:

  1. Transaction builder — 1-satoshi UTXOs are excluded from UTXO selection.
  2. Inscription scanner — for display, 1-sat UTXOs are scanned by fetching the raw transaction and detecting the OP_FALSE OP_IF ... OP_ENDIF inscription pattern. Results are cached per outpoint.
  3. 1Sat indexer fallback — 1-sat UTXOs with no detectable current-output inscription are checked against ordinals.gorillapool.io. Tri-state: is-ordinal, not-ordinal, or fails closed on HTTP 5xx (protect the UTXO).

UTXO Split

From the UTXO detail view, a single UTXO can be split into up to 240 separate outputs across fresh derived addresses.

API Providers

CapabilityWhatsOnChainGorillaPoolTAALJungleBus
UTXOs (balance)
Transaction history
SPV Merkle proofs
BSV price
Fee quotes
Broadcast (ARC)✓*
Real-time monitoring

*TAAL requires a user-configured API key. GorillaPool is primary; TAAL is failover. API keys are stored in the iOS Keychain and configured in Settings → API & Miners.

Security

  • Biometric authentication — Face ID or Touch ID required to view private keys, seed phrases, and wallet details. Sessions expire after 120 seconds of inactivity. The same window is used by the payment approval cache.
  • Keychain storage — all sensitive material stored with .whenUnlockedThisDeviceOnly accessibility.
  • Seed verification — after creating a wallet, users verify their seed phrase via a 3-word quiz.
  • No server dependency — fully self-custodial. No account, no registration, no server holds your keys.
  • Message encryption (BRC-2) — Electrum ECIES (ECDH + AES-CBC + HMAC-SHA256). Wire-compatible with @bsv/sdk. Format: "BIE1" || pubkey || ciphertext || HMAC.
  • Wallet file encryption — AES-256-GCM with a Keychain-backed key before syncing to iCloud.

Payment Approval Cache

A successful biometric approval stamps the cache with (surface, amountSats, outputsDigest, timestamp). A subsequent broadcast within the 120-second window can skip the prompt when all three scoping axes match. The outputsDigest is a SHA-256 over a canonical encoding of the sorted output set: UInt32-LE(recipientKey.utf8.count) || recipientKey.utf8 || Int64-LE(satoshis) per output. Length-prefixing defeats engineered concatenation collisions.

Encrypted Off-Device Backup

.bep files produced by BackupService: a portable, password-encrypted blob restorable on any device.

  • Key derivation — PBKDF2-HMAC-SHA256 with 16-byte salt, 600,000 iterations.
  • Encryption — AES-256-GCM (authenticated; tampering yields .invalidPassword).
  • Wire format — magic prefix "HFBK1", salt (16), iteration count (4 BE), nonce (12), AES-GCM ciphertext + 16-byte tag.
  • Forward compatibility — version byte in the magic prefix allows future format bumps.

QR Code Scanner

Unified QR scanner in Settings, routed by content: hfauth: prefix → BRC-103 auth; Base58Check 0x80 → WIF import; 0x00 → Bitcoin address → pay view.

BRC-103 Authentication

Passwordless authentication: web service displays hfauth: QR with challenge + callback. Wallet displays the requesting domain, asks for approval, signs the challenge with the wallet's identity key, sends the signature to the callback. No passwords, no accounts.

BRC-100 Wallet Interface

henceforth://wallet/{method}?params=... URL scheme exposes key derivation, signing, encryption, and certificate management (BRC-52/53). All sensitive operations require user approval via on-screen confirmation + biometric.

Status: the URL scheme and signing infrastructure are implemented, but the full BRC-100 sign-in workflow is still in development and not yet available for general use.

Certificate Proofs (BRC-52/53)
  • Per-field HMAC keyring, ECDH-bound — each field's HMAC key is encrypted with an ECDH shared secret derived between the prover's and verifier's BRC-42 identity keys. A leaked proof is useless against any other party.
  • Issuer signature verificationverifyProof checks the issuer's BSM signature over the certificate's canonical field set before accepting any field as authentic.
  • Serial number carried in the proof — for correlation with revocation or re-issuance records.