Documentation
Bitcoin meets Forth. Henceforth is a native iOS app that combines a Forth-2012 compliant interpreter with an integrated Bitcoin SV wallet — one stack-based language for arithmetic, scripting, and payments.
About
Henceforth is built and maintained by Henry Hudson at Henceforth Bitcoin Limited.
Source code
github.com/henryhudson/FORTHapp
Running the project requires Xcode on any iPhone or iPad. Optimisation for all Apple platforms is in progress. The repository is private at the time of writing — get in touch if you would like access.
Support
Follow @henceforth_app on Twitter
Goals
Achieved
- Bitcoin (BSV) integration — full wallet with HD key derivation (BIP-44 and Type42), transaction building, ARC broadcasting with failover, SPV verification, Paymail support, and 140+ Bitcoin Script opcodes including the Chronicle upgrade.
- Block headers — locally chain-linked header store anchored to a hardcoded checkpoint, auto-synced from chain tip on every launch via a WebSocket history stream. SPV Merkle proof verification is bounded to the chain-validated range — no silent network fallback for missing headers.
- SwiftBSV — fully integrated for transactions, Script building, BIP-32/39 key management, and ECDSA signing.
- OPCodes — all standard and Chronicle-restored opcodes available as FORTH words. Script Recording mode assembles Bitcoin Scripts from the terminal.
- Files — full CRUD for
.fsand.forthfiles with iCloud backup. Script files identified bySCRIPT-BEGIN/SCRIPT-ENDmarkers. - Colour customisation — foreground and background colour pickers, keyboard selection, and Dynamic Type support throughout.
- Forth-2012 compliance — 133 / 133 CORE words implemented, verified by the Hayes test suite.
Get people coding
Provide a simple interface for the FORTH programming language, making it easy for people to learn to code.
Future
- CRDT (Conflict-free Replicated Data Type) system using the timestamp server for user-defined words.
- Trigonometry and calculus extensions.
Reference
Everything in FORTH is a word separated by spaces. That's all there is to the syntax. It's so simple.
Compile Words
One of the great things about FORTH is its ability to extend the compiler during run time.
:;recurseexitimmediatepostponevalue(x --)42 value myval.to(x --)99 to myval.does>( -- ): array create cells allot does> swap cells + ;.include( -- )include maths.fs.Meta Programming
'( -- xt)[']( -- xt)' (tick). Compiles the execution token of a word into the current definition.execute(i*x xt -- j*x)i*x/j*x notation acknowledges that the executed word may consume and produce arbitrary values; only the xt itself is consumed by EXECUTE.literalCompile: (x -- ) Run-time: ( -- x)state( -- addr)[(--)](--)evaluate(c-addr u --)find(c-addr -- c-addr 0 | xt 1 | xt -1)>body(xt -- a-addr)>in( -- a-addr)>number(ud1 c-addr1 u1 -- ud2 c-addr2 u2)Arithmetic
Arithmetic is done using reverse Polish notation, so the numbers are required on the stack before the arithmetic operator can be used.
+(n1 n2 -- sum)-(n1 n2 -- difference)*(n1 n2 -- product)/(n1 n2 -- quotient)*/(n1 n2 n3 -- result)result = (n1*n2/n3). Multiplies n1 by n2, then divides by n3.mod(n1 n2 -- modulus)/mod(n1 n2 -- modulus quotient)abs(n -- |n|)negate(n -- -n)max(n1 n2 -- max)min(n1 n2 -- min)1+(n -- n+1)1-(n -- n-1)2+(n -- n+2)2 +.2-(n -- n-2)2*(n -- n*2)2/(n -- n/2)sumStack(n1 ... n -- sum)*/mod(n1 n2 n3 -- remainder quotient)Double-Cell Arithmetic
Double-cell words operate on 64-bit values represented as two stack items (high cell on top). These are required by the Forth-2012 CORE standard for intermediate precision in calculations.
s>d(n -- d)m*(n1 n2 -- d)um*(u1 u2 -- ud)fm/mod(d n1 -- n2 n3)sm/rem(d n1 -- n2 n3)um/mod(ud u1 -- u2 u3)Numeric Output
<#(--)#(ud1 -- ud2)#s(ud -- 0 0)#>(xd -- c-addr u)hold(char --)sign(n --).r(n1 n2 --)u.(u --)u.r(u n --)Comparison
=(n1 n2 -- f)<>(n1 n2 -- f)<(n1 n2 -- f)>(n1 n2 -- f)&&(n1 n2 -- f)||(n1 n2 -- f)0=(n -- f)0<(n -- f)0>(n -- f)u<(u1 u2 -- f)Bitwise
and(x1 x2 -- x3)or(x1 x2 -- x3)xor(x1 x2 -- x3)invert(n -- ~n)rshift(n1 n2 -- n3)lshift(n1 n2 -- n3)Stack Operators
.s( -- ).(n -- )dup(n -- n n)?dup(n -- 0 | n n)nip(n1 n2 -- n2)swap(n1 n2 -- n2 n1)drop(n -- )2dup(n1 n2 -- n1 n2 n1 n2)over(n1 n2 -- n1 n2 n1)pick(x_u ... x_1 x_0 u -- x_u ... x_1 x_0 x_u)0 PICK is DUP, 1 PICK is OVER).rot(n1 n2 n3 -- n2 n3 n1)-rot(n1 n2 n3 -- n3 n1 n2)tuck(n1 n2 -- n2 n1 n2)2drop(n1 n2 -- )2swap(n1 n2 n3 n4 -- n3 n4 n1 n2)2over(n1 n2 n3 n4 -- n1 n2 n3 n4 n1 n2)reverseStack(n1 n2 ... nlast -- nlast ... n2 n1)depth( -- n)roll(xu xu-1 ... x0 u -- xu-1 ... x0 xu)stackCount( -- n)depth (Forth-2012 6.1.1200).Return Stack
>r(x -- ) ( R: -- x)r>( -- x) ( R: x -- )r@( -- x) ( R: x -- x)i( -- x)i'( -- x)i' and j return the same value.j( -- x)nth(u -- x)Decision
if(x -- )else( -- )then( -- )not(x -- f)0= (returns -1 if x is 0, 0 otherwise). Retained for FIG-FORTH compatibility. Forth-2012 dropped NOT because pre-2012 implementations disagreed on whether it meant 0= (logical) or INVERT (bitwise). Henceforth's NOT is the logical version. Prefer 0= or INVERT in new code.within(n1 n2 n3 -- f)true( -- -1)false( -- 0)case( -- )of(x1 x2 -- | x1)endof( -- )endcase(x -- )catch(i*x xt -- j*x 0 | i*x n)throw(k*x n -- k*x | i*x n)Loop
do(n1 n2 --)loop( -- )+loop(n1 -- )every(n1 -- )stop( -- )begin( -- )while(x -- )repeat( -- )until(x -- )leave( -- )unloop( -- )?do(n1 n2 --)again( -- )Base
Henceforth accepts integers as binary, octal, decimal and hexadecimal. Just change the base.
base( -- addr)BASE @ to read and n BASE ! to write. Supports bases 2 through 36.decimal(--)binary(--)2 BASE !.octal(--)8 BASE !.hex(--)Pop-up
lineChart(--)barChart(--)pieChart(--)Constants & Variables
constant(n --)variable(--)!(n address --)@(address -- n)?(address --)c!(char addr --)c@(addr -- char)create(--),(n --)allot(n --)here( -- addr)cells(n1 -- n2)cell+(addr1 -- addr2)+!(n addr --)count(c-addr1 -- c-addr2 u)bl( -- char)2@(addr -- x1 x2)2!(x1 x2 addr --)fill(addr u char --)move(addr1 addr2 u --)erase(addr u --)allocate(u -- a-addr ior)free(a-addr -- ior)resize(a-addr u -- a-addr2 ior)c,(char --)char+(c-addr1 -- c-addr2)chars(n1 -- n2)align( -- )aligned(addr -- a-addr)Terminal
cr(--)space(--)spaces(n --)emit(n --)((--)( and ) will be ignored. Can appear anywhere on a line.)(--)\\(--)\ to the end of the line is ignored. Supported in .fs files..rs(--).s for the data stack.clear(i*x --)page; for stacks alone use abort.bye(--)."(--)"(--)s"( -- addr u)type(addr u --)page(--)ms(u --)char( -- char)[char]( -- n)word(char -- c-addr)parse(char -- c-addr u)key( -- n)?terminal( -- flag)ekey? (FACILITY 10.6.2.1306); both spellings dispatch to the same handler.accept(c-addr n1 -- n2)sessionInput(--)dictionary(--)words or vlist.vlist(--)words (TOOLS 15.6.1.2465); both spellings dispatch to the same handler.see( "<spaces>name" --)see pay-rent.help( "<spaces>name" --)help opens the vocabulary browser. Henceforth extension.forth(--)c"( -- c-addr)abort(--)abort"(flag --)0= abort" value must not be zero".quit(--)source( -- c-addr u)environment?(c-addr u -- false | i*x true)Other Words
date&time(-- s m h d M y)pay(--)receive(--).fs.fs extension will load that file's contents into Henceforth.startup.fsData Conversion
Henceforth has three stacks: the integer stack (main, inspected via .s), the data stack (raw bytes for Bitcoin Script operations, inspected via .ds), and the return stack (Forth-2012 >R / R> / R@, inspected via .rs). These words bridge the integer and data stacks.
hex>data( c-addr u -- )(c-addr u) to bytes on the data stack. Compose with S": S" deadbeef " hex>data. Legacy spelling hex2data still parses as a silent alias for backwards compatibility with pre-2026-05-19 scripts.chars>data( c-addr u -- )(c-addr u) to bytes on the data stack. Compose with S": S" hello " chars>data. No auto-detect between hex and UTF-8 — silent hex interpretation of a UTF-8-intended OP_RETURN payload would broadcast wrong bytes to chain. Use hex>data for hex decoding, chars>data for raw UTF-8 bytes.data>hex( -- )data2hex still parses as a silent alias for backwards compatibility..ds( -- )>data( -- )>addr(n -- )>scriptnum( n -- )5 >scriptnum.scriptnum>( -- n)String Operations
cmove(c-addr1 c-addr2 u -- )cmove>(c-addr1 c-addr2 u -- )/string(c-addr1 u1 n -- c-addr2 u2)search(c-addr1 u1 c-addr2 u2 -- c-addr3 u3 flag)blank(c-addr u -- )Networking
wocbitcoinprice( -- n)price dispatches to the same handler.woccirculatingsupply(--)wocblockchaininfo(--)wocaddressinfo(--)wocgetbalance(c-addr u -- )s") and displays that address's confirmed balance to the terminal. Usage: s" 1A1zP1..." wocgetbalance. For the active wallet's balance as a stack value, use balance.wocgethistory(--)wocgetblockbyheight(n--)The three words below are awaitable: each suspends the line while its network query runs and resumes with the value pushed, so chain state lands on the stack instead of being prose in the transcript — : pay-rent balance 1000 > if 1000 s" 1A1zP1..." send then ; is both a Forth word and a payment policy. One query may be in flight at a time; while it runs the terminal shows awaiting balance… (esc cancels) and Escape cancels it. A query that cannot answer halts the line with an error — never a sentinel value that could silently flip an if.
balance( -- sats)tx-confirmations(c-addr u -- n)s") and pushes the transaction's confirmation count. Pushes 0 when the transaction is known but still unconfirmed in the mempool. An unknown txid halts the line with an error.spv-verified?(c-addr u -- flag)s") and pushes -1 when the transaction's Merkle proof verifies against the locally-validated header chain, 0 when it does not. A header-chain gap prints SPV PENDING alongside the 0; a proof-fetch failure halts the line with an error.Transaction Builder
The transaction builder words allow programmatic construction of Bitcoin transactions directly from the FORTH terminal. Build a transaction step by step, then broadcast it.
tx-new( -- )tx-set-fee(n -- )500 tx-set-fee.tx-add-output(n c-addr u -- )s") — the same convention as send. One address per call. Usage: 1000 s" 1A1zP1..." tx-add-output.tx-add-data( -- )tx-preview-toggle( -- )tx-show( -- )tx-clear( -- )tx-broadcast( -- )send( amount c-addr u -- )S") and the amount from the integer stack. The string holds one address (single recipient) or N space-separated addresses (equal-split). Validates the inputs, then presents PayView with the amount + address pre-filled. The user long-presses to confirm; Face ID gates the actual broadcast via ARC. Terminal output on success: sent TXID: <id>. Usage: 1000 s" 1A1zP1..." send.send-many( amount1 ... amountN c-addr u -- )1000 5000 250 s" 1A 1B 1C" send-many.Standard Transaction Script
Every installation includes a standard-transaction.fs file in the Files tab. This is a Bitcoin Script file that defines the OP_RETURN output attached to your payments. It is automatically loaded each time you open the Pay view.
( Standard Transaction Script )
SCRIPT-BEGIN
OP_FALSE OP_RETURN
." Hello, you are welcome." >data data>script
SCRIPT-ENDBitcoin Cryptography
sign-msg( -- )sign-msg <message>.verify-msg( -- flag)verify-msg <address> <signature> <message>.encrypt-msg( -- )@bsv/sdk EncryptedMessage. Usage: encrypt-msg <recipientPubKeyHex> <message>.decrypt-msg( -- )decrypt-msg <ciphertextHex>.BAP Identity
BAP (Bitcoin Attestation Protocol) provides a decentralised identity system built on Bitcoin. Identity keys are derived using Type42 (BRC-42) key derivation from the wallet's master key.
bap-id( -- )bap-sign( -- )bap-sign <message>.bap-verify( -- flag)bap-verify <address> <signature> <message>.bap-register( -- )Script Recording
Script recording is a macro assembler for Bitcoin Script. When recording is active, op_* words emit their opcodes into a script buffer rather than executing. Standard FORTH control flow still runs normally.
script-begin( -- )op_* words emit opcodes to the script buffer. FORTH control flow still runs normally.script-end( -- )script-show( -- )script-clear( -- )script-use( -- )script-save( -- )script-save <name>.script-load( -- )script-load <name>.script-list( -- )script-step( -- )script-load, script-begin, script-clear, and clear.Script Bridge
The bridge words connect FORTH's stacks to the script recording buffer, enabling dynamic script construction with computed values, strings, and file data.
>script(n -- )data>script( -- )text>script( -- )text>script image/jpeg.file>script( -- )file>script photo.jpg.Script Helpers
Pre-built script templates for common Bitcoin transaction patterns.
make-p2pkh-script( -- )make-opreturn-script( -- )make-2of3-multisig( -- )script-pushdata( -- )Script Execution Context
OP_CHECKSIG and OP_CHECKMULTISIG require a transaction context to verify against. These two words arm that context so signature-checking opcodes work in the terminal.
checksig-fixture( -- sig pubkey)script-tx-context( satoshis n -- )0x<rawtx> 0x<lockscript> <satoshis> <input-index> script-tx-context.OPCodes
All 140+ Bitcoin Script opcodes are exposed as FORTH words. Below are the categories — every opcode behaves like its Script consensus definition.
Push Data
op_0 … op_16( - x)op_false( - x)≈ falseop_pushdata1( - )op_pushdata2( - )op_pushdata4( - )op_1negate( - -1)-1.)Control Flow
op_nop( - )op_ver( - version)op_if( x - )≈ ifop_notif( x - )0= if.op_verif( threshold - bool)op_vernotif( threshold - bool)op_else( - )≈ elseop_endif( - )≈ thenop_verify( x - )op_return( - )Stack
op_toaltstack( x - )≈ >rop_fromaltstack( - x)≈ r>op_2drop( x1 x2 - )≈ 2dropop_2dup( x1 x2 - x1 x2 x1 x2)≈ 2dupop_3dup( x1 x2 x3 - x1 x2 x3 x1 x2 x3)2dup over.op_2over( x1 x2 x3 x4 - x1 x2 x3 x4 x1 x2)≈ 2overop_2rot( x1 x2 x3 x4 x5 x6 - x3 x4 x5 x6 x1 x2)rots.op_2swap( x1 x2 x3 x4 - x3 x4 x1 x2)≈ 2swapop_ifdup( x - 0 | x x)≈ ?dupop_depth( - n)≈ depthop_drop( x - )≈ dropop_dup( x - x x)≈ dupop_nip( x1 x2 - x2)≈ nipop_over( x1 x2 - x1 x2 x1)≈ overop_pick( ... n - ... xn)≈ pickop_roll( xn ... x0 n - xn-1 ... x0 xn)≈ rollop_rot( x1 x2 x3 - x2 x3 x1)≈ rotop_swap( x1 x2 - x2 x1)≈ swapop_tuck( x1 x2 - x2 x1 x2)≈ tuckSize & Separator
op_size( x -- x n)op_codeseparator( -- )op_true( -- 1)≈ trueSplice (Chronicle restored)
op_cat( x1 x2 - x1x2)op_split( x n - x1 x2)op_num2bin( x n - bytes)op_bin2num( x - num)op_substr( x start len - substring)op_left( x n - prefix)op_right( x n - suffix)Bitwise
op_invert( x - ~x)≈ invertop_and( x1 x2 - and)≈ andop_or( x1 x2 - or)≈ orop_xor( x1 x2 - xor)≈ xorop_equal( x1 x2 - bool)≈ =op_equalverify( x1 x2 - )op_reserved1, op_reserved2( - )Arithmetic
op_1add( x - sum)≈ 1+op_1sub( x - difference)≈ 1-op_2mul( x - product)≈ 2*op_2div( x - quotient)≈ 2/op_negate( x - -x)≈ negateop_abs( x - |x|)≈ absop_not( x - bool)≈ 0=op_0notequal( x - bool)≈ 0<>op_add( x1 x2 - sum)≈ +op_sub( x1 x2 - difference)≈ -op_mul( x1 x2 - product)≈ *op_div( x1 x2 - quotient)≈ /op_mod( x1 x2 - modulus)≈ modop_lshift( x n - shifted)≈ lshiftop_rshift( x n - shifted)≈ rshiftop_lshiftnum( x shift - result)op_lshift which is bitwise on bytes. Chronicle upgrade (0xb6).op_rshiftnum( x shift - result)op_booland( x1 x2 - bool)≈ &&op_boolor( x1 x2 - bool)≈ ||op_numequal( x1 x2 - bool)≈ =op_numequalverify( x1 x2 - )op_numnotequal( x1 x2 - bool)≈ <>op_lessthan( x1 x2 - bool)≈ <op_greaterthan( x1 x2 - bool)≈ >op_lessthanorequal( x1 x2 - bool)≈ <=op_greaterthanorequal( x1 x2 - bool)≈ >=op_min( x1 x2 - min)≈ minop_max( x1 x2 - max)≈ maxop_within( x min max - bool)≈ withinCrypto
op_ripemd160( x - hash)op_sha1( x - hash)op_sha256( x - hash)op_hash160( x - hash)op_hash256( x - hash)op_checksig( sig pubkey - bool)checksig-fixture (a signed demo pair) or script-tx-context (a real transaction).op_checksigverify( sig pubkey - )op_checkmultisig( x sig1...sigM M pub1...pubN N - bool)op_checkmultisigverify( x sig1...sigM M pub1...pubN N - )Lock Time / Reserved
op_nop2, op_nop3, op_pubkeyhash, op_pubkey, op_invalidopcode( - )op_nop1, op_nop4..6, op_nop9, op_nop10( - )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, thetxVersionactivation 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.blockHeadersandvalidatedTipHeightareprivate(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 orforceResync).WalletState.lastPaymentApprovalisprivate(set). The only setter issetLastPaymentApproval, called by the biometric gate after a successful Face ID prompt or byrecordPaymentApprovalfor upstream-stamped approvals.BlockchainDataProvideris anactorrather than a@MainActor class. The cross-source verifier'sasync let primary / secondaryfor 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) and0-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
derivationTypeis 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:
- UTXO Selection — avoiding ordinal-protected UTXOs.
- Fee Calculation — current rate from GorillaPool or TAAL via ARC policy endpoints.
- Change Output — returned to a fresh change address.
- Signing — each input signed with the correct private key.
- Anti-Fee-Sniping —
nLockTimeset to current block height. - 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
/unspentcorroborates 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:
- Non-empty merkleroot — pre-Phase-1 placeholder rows are dropped on load.
- Individual PoW validates — double-SHA256 of the serialized header must be below the target encoded in
bits. - Chain-linked to parent —
previousblockhashmust equal the hash of the header atheight - 1. - 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 blockHeaderNotFound — without 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 tocommitValidatedHeaderRunso 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 m/0/0 BIP32 identity key with DER ECDSA.
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.
persistTransactionsToDiskrefuses 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
byOwnershipfiltering 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:
- Transaction builder — 1-satoshi UTXOs are excluded from UTXO selection.
- Inscription scanner — for display, 1-sat UTXOs are scanned by fetching the raw transaction and detecting the
OP_FALSE OP_IF ... OP_ENDIFinscription pattern. Results are cached per outpoint. - 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
| Capability | WhatsOnChain | GorillaPool | TAAL | JungleBus |
|---|---|---|---|---|
| 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
.whenUnlockedThisDeviceOnlyaccessibility. - 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 verification —
verifyProofchecks 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.
Credits
Starting Forth — Leo Brodie
Leo Brodie, author of Starting Forth — a great textbook, suitable for both novices and professionals.
BITSRFR
Fellow surfer of the binary ocean who has made FORTH tutorials and built a Swift command line FORTH application.
Bitcoin
The greatest monetary system ever created. Invented by Satoshi Nakamoto — please read the white paper.
SwiftBSV
The Swift SDK that powers every cryptographic operation in the Henceforth wallet. Originally written by Will Townsend at wtsnz/SwiftBSV; the fork at henryhudson/SwiftBSV continues that foundation with Swift 6 strict concurrency, modern Apple platform targets, and a 41-page LaTeX book of its own.