A name whose certificate sits in the registry covenant has an owner and an
expiry that are hashed into its address, not written on the chain. Until
now a resolver saw only "held by some script address", forever.
The transaction that moves the certificate now says the state (`c` in the
payload) and the resolver checks it against the address, using the
covenant's code from the network table. Nobody is trusted about who owns
such a name or until when.
Entering is the one step script cannot police - a certificate can be sent
to a covenant address with any expiry - so resolvers set the price: the
entering transaction pays the fund for whole years and may claim no more
than it paid for. Once a name is inside, only a verified state speaks for
it; otherwise an owner could announce their way back to a name that never
expires.
A name past its grace period stays taken but resolves to nothing, judged
at the chain tip (the indexer now answers what that is) or, failing that,
at the newest block in the beacon history, so a stale index lapses a name
late and never early.
Same rules in the gateway, the Ariadne resolver and the Android resolver;
the builders announce every move, and there is one to put an existing
name into the covenant. Chipnet's fund and steward are the operator key.
Records the findings against each contract, the one-message rule in the
protocol, the chipnet run of the fixed migrate path, and which earlier
chipnet transactions used contracts that have since been superseded.
Resolvers took the last message in a transaction. Wherever one party fixes
a message and another assembles the transaction - the delivery escrow pins
the registration at output 1 - a later output could replace it and every
resolver would read that one. Counting instead of choosing removes the
question: one message, or the transaction says nothing.
Same rule in all three resolvers. The Android parser now recognises the
same set of operations as the JavaScript one, since what counts as a
message decides which transactions are void. No transaction on chipnet
carried more than one.
Three older suites built registrations that never created their
certificate's category; their fixtures now do, as the sole-certificate
rule requires.
migrate let the owner supply the new state, so an owner could put a script
of their own in front of the approved code and take the certificate out of
the covenant, or rewrite owner and expiry on the way. The contract now
carries its own state over and the owner supplies only the code. The
steward's approval covers source and destination code, so it cannot be
replayed in another tier or network.
The delivery escrow could be collected with a second protocol message after
the agreed one; it now allows at most four outputs, the fourth plain change.
A subscription draw could pay its fee allowance to a third output; it now
has one or two outputs and pays the host exactly the price.
Smaller: the commit salt is exactly 32 bytes (one box could serve several
names), expiry stays a block height, and relative-lock terms are range
checked in script. Escrow quotes are refused for co-sign TLDs, where a
delivery would satisfy the contract and register nothing.
Ownership of a name is proven by holding a token of its category: an update
is accepted when some output carries it. Nothing required that the
certificate be the only such token. Whoever registered a name could mint a
second one in the same transaction, a minting token for instance, sell the
certificate on the market, and later publish an update the resolvers would
accept. The buyer would hold the certificate and lose the name.
A registration now counts only if its category is created by that very
transaction, exactly one output carries it, and that output is an NFT with
no capability and no fungible amount. Nothing meeting this can ever have a
sibling. The same holds for TLD certificates, whose category proves the
right to set a TLD's policy and to co-sign registrations under it.
One definition, in resolver-web.js; bns.js and the TLD snapshot builder call
it. Every valid registration on chipnet already satisfied the rule: the live
registry is the same 71 names and 21 TLDs before and after.
Found while writing the delivery escrow, which had to forbid exactly this.
The roadmap asks for the covenant capability to be built once and used three
times. covenant.js is the shared part: artifact to bytecode, redeem script and
lock, signing a covenant input, an unlocking script for any function, and
assembling a transaction around covenant inputs. The registry library now
sits on it, and so do the two new contracts.
Subscription: the buyer funds a balance. Anyone may send one period's price
to the host once per period, the rest going back to the same script; the
buyer may take the balance back at any time with only their signature. A
draw spends its input alone, because with two inputs one payment and one
return could satisfy both and the host would keep a balance.
Delivery escrow: a payment that can be collected only by the transaction
that mints the agreed certificate to the buyer and publishes the agreed
registration, or refunded by the buyer after a wait. It forbids any other
token in the transaction: resolvers accept an update from any token of a
name's category, so a deliverer who minted a second one alongside the
certificate could later rewrite the buyer's name. It also sits at a fixed
input, so two payments cannot be collected against one certificate.
Neither can see the registry: an escrow cannot know the name was taken
between payment and delivery. The contract says so.
Drafts, not reviewed. The tests run every path in the VM under the 2025 and
2026 rules and count that the VM itself refused the tampered ones.
The blocklist consumer existed in the resolver library and the gateway, but
the browser opened a flagged name without comment. Now the indexer process
reads the subscribed lists from the chain every ten minutes and hands the
flags to main. A flagged name loads a warning page naming the reason, the
list and the report; the user may continue, and that is remembered per name.
Two gates, because content is reached two ways. loadBns shows the real
interstitial. serveBns refuses with an inline page on every path that skips
it: reload, back and forward, bns:// links, web app windows. The inline page
has no button, since a page at the site's own origin must not be able to
approve itself; only blocked.html may ask to continue, checked by file URL.
Settings › Naming has the policy: warn (default), never open, or ignore the
lists. The csam reason is never offered a way through. A list that cannot be
read keeps the last known flags and never stops a name from resolving.
The gateway put its own warning in front of flagged sites, which this
browser could not get past: it fetches files itself, with no cookie jar. It
now sends x-bns-policy: client and the gateway stays out of the way for a
client that says it decides for itself.
dev/blocklist-selftest.js runs the real protocol handler and decision
functions against a scratch profile.
Expiry was always going to be the chain refusing, not resolvers agreeing. This
is that contract: a certificate sits in a covenant whose address encodes its
owner and expiry, and five paths move it. The owner renews, transfers,
releases or migrates; anyone takes over an expired name at a price that
glides from a premium to a floor, paying the previous owner a share while the
grace period lasts and committing to the name a block ahead.
The two changing values are the last constructor arguments, so they sit first
in the redeem script and the contract re-creates itself by swapping 26 bytes.
A resolver that knows the code can check any claimed owner and expiry against
the address.
Every number comes from registry-lifecycle.js, which was written as the
fixture for this. The tests run each path in libauth's VM under the 2025 and
2026 rules, compare price and split to that model along the glide, and then
break one rule at a time with valid signatures; the suite counts that the VM
itself refused them.
Draft, not reviewed, and not wired into any resolver. REGISTRY-COVENANT-v1.md
says where it departs from the design: script cannot bound a height from
above, so reclaim has no deadline and a commit cannot expire, which leaves
commit hoarding open. That is the first thing a reviewer should read.
The project's indexers answered 'no history' for any address but the two
name beacons, so a client on a network that only lets port 443 out could
not read a blocklist at all. The indexer already tracks a set of scripthashes
and follows spends as well as payments; the table's list addresses join that
set.
buildBlocklists still prefers full servers, because a rotated or private
list is unknown to the indexers. It falls back to them only when no full
server can be reached and every subscribed list sits at its table address.
A flagged name and its subdomains are served behind a warning that names the
reason, the list and the report transaction. The visitor may continue, per
name, for a day. The one refusal is the csam reason.
/api/blocklist returns the subscribed lists and current flags, and
/api/name/<name> carries the flag fields, for clients that cannot reach a
full electrum server. It is a convenience copy: every entry names its report
and can be re-derived from the list address.
Flags are read once a minute from full servers and shared across requests;
names are resolved without the extra round trip. BNS_BLOCKLISTS adds lists.
The mobile constants now carry the default subscriptions for the consumer to
come, and the docs explain flagged names.
blocklist-tx.js builds the three payloads and a transaction that takes its
inputs from the list address and returns its change there, which is both the
authority proof and the funding of the next entry. blocklist-admin.mjs
publishes a report and chains the flag on its change, so the pair needs no
waiting; it refuses free-text reasons and declines 'stolen' in this phase.
bns.js recognises the opcodes and reduces a list through the shared reducer
rather than a second copy of the rules.
The tests cover the validity rules, the authority rule against a stranger who
only pays the address, ordering inside one block, and the contract that
matters most: no subscription, or a subscription to an empty list, changes
no answer.
A resolver that learns about blocklists only after the first scam needs a
release before it can warn anyone. The consumer ships now, pointed at a list
with no entries, so the first real report costs a transaction instead.
A list is an address, named by a BNS name whose bl record holds it. Entries
are RPT (the report), BLK and UBK. A flag counts only when it names a valid
report in the same list for the same name and reason, and only when its
transaction is spent FROM the list address: anyone can pay an address, so
being in its history proves nothing. That spend rule is what lets the
authority change hands later without the resolver learning the difference.
resolveName attaches blocked, blockReason, blockReportTxid and blockedBy to a
flagged name and leaves every other answer untouched. With no subscription it
is the old resolver exactly. A list that cannot be read never stops a name
from resolving. Lists are read from full electrum servers: the project's
beacon-only indexers answer 'no history' for any other address, which would
look like an empty list.
The default list is sirius-blocklist.x, a two-part name because a three-part
one collapses to its parent. Address conversion is dependency-free so this
file stays a single-file drop-in.
The Transfer tab gains a Release card. It asks for the name to be typed,
refuses while the name is listed for sale, and says plainly what ends: the
certificate, the records, the ownership, with nothing refunded.
/api/name/<name> returns released_at for a name that left the index through
a release, so a client can tell 'never registered' from 'given back' and
whether the one-block gap has passed. The register flow uses the same signal
to say why a just-released name is not open yet instead of calling it taken.
Docs gain a Releasing a name section; the roadmap marks its first two steps
done.
buildReleaseTx spends the certificate and writes no token output: the
omission is the burn. The certificate's own dust pays the beacon and the fee,
so a release needs no other coin.
releaseWithBuiltInWallet checks whether the certificate sits in a beacon
transaction. One that arrived by a plain transfer cannot be proven burned
from the beacon history, so the flow publishes an UPD first and releases its
output. Both paths ran on chipnet, and a released name was registered again
after its release confirmed.
checkAvailability now reports a pending release as not available: a REG
broadcast while the release is unconfirmed could be mined beside it and be
void, and the buyer would have paid for nothing.
The CLI resolver reduces the beacon history on its own, so a rule that lives
only in resolver-web.js would be a consensus split between the tools that
register names and the clients that resolve them.
release.test.mjs feeds the same synthetic histories to both reducers and
requires identical results: valid and invalid burns, the unanchored
certificate, same-block ordering, the one-block gap in and out of the
mempool, and that nothing of a released registration carries over.
A burned certificate used to leave a name in the index forever: listed,
resolvable with its last records, and impossible to update or re-register.
REL is the declared burn. A transaction that spends the certificate from a
beacon transaction, recreates no output of its category and carries the REL
payload removes the name from the index.
The proof is read from an input because a burn leaves no output to read it
from. Resolvers hold only the beacon history, so the spent output must belong
to a beacon transaction; a certificate moved by a plain transfer is anchored
with an UPD first. That keeps the rule a pure function of the snapshot every
client already has.
A released name can be registered again only in a later block than the
confirmed release. Same-block re-registration would be decided by txid order
between a release and a snipe sitting in the mempool together.
This is the first step of ROADMAP-MAINNET.md §9: it puts burn detection in
the resolvers on its own, before any covenant depends on it. Specified in
PROTOCOL-ADDENDUM-release.md; shared resolver and its Ariadne copy here.
Migration step 3 (DESIGN-bns-indexer-service.md). Ariadne's Thread now owns
BNS indexing on the machine, so Theseus no longer runs a second electrum
indexer beside it.
bns-indexer.js keeps its process and its messages to main.js, but inside
it is now an index host on the shared source chain:
- Ariadne's indexer over its pipe, trusted only after ariadne-helper.exe
has checked the server process on that connection (found through
Ariadne's uninstall key), then pushes;
- the local copies: Ariadne's files for both scopes, Theseus's own raw
copy, the bundled one. The richest wins.
- Theseus's own index copy, written from the pipe data.
The shared core runs as Theseus's own indexer only while Ariadne is
unhealthy. That means: no pipe 4 s after launch, a pipe that fails the
check, a pipe that went silent, or an index not confirmed for 10 min while
Ariadne is not paused. The own indexer warm-starts from Ariadne's
snapshot, so there is no download and no cold sync. It hands back after
90 s of health, so a flapping service does not start and stop it. Economy
is not a failure and never triggers a takeover. With no checkable Ariadne
(portable, not installed, older than the pipe) the own indexer starts at
once, as in 0.3.70. The one thing Theseus does on Ariadne's side is run
the indexer's task at launch when "Launch at start" is off.
main.js passes the shared module paths (packaged as .mjs, which is why the
shared modules no longer import each other) and keeps the host's status.
The Ariadne panel takes its state from the indexer task when one exists,
and the sub-page says where Theseus's names come from.
Design step 1 (TheseusNavigator/DESIGN-bns-indexer-service.md). Theseus's
bns-indexer.js and Ariadne's daemon each had their own index logic, and
Ariadne's warm-started from the operator's pre-resolved list, which has no
owners. Ariadne's Thread is to own indexing for the whole machine, with
Theseus reading from it, so both need the same code and the same results.
- bns-index-core.js: the 0.3.70 indexer logic without the Electron port:
local copies -> published raw snapshot (dl.silentmode.st, Sia mirror)
-> electrum delta every 30 s, index rebuilt from the beacon txs with
owner, TLDs from tlds.bch. Writes two local copies atomically (current
+ previous), emits change/fresh with the names that changed, and can
pause its network work (Economy) without stopping.
- bns-source-chain.js: how readers get names without depending on the
indexer: memory -> live indexer over the pipe -> local copies -> public
snapshots -> public BNS indexer for single names, marked provisional
and replaced when verified data arrives.
- bns-pipe.js: newline-JSON hello/snapshot/get/subscribe/poll over
\.\pipe\ariadne-bns[-<SID>]. Node cannot reach the pipe handle, so the
client trusts a connection only through a helper that checks the server
process (GetNamedPipeServerProcessId) and then relays that same
connection. A pipe name can be squatted while the indexer is down;
a 127.0.0.1 port cannot be checked at all.
Theseus keeps its own bns-indexer.js unchanged until migration step 3.
The only published raw snapshot sat on the Sia S3 endpoint, which needs
SigV4 for every read: every Theseus launch got a 403, and the copy had not
been refreshed since 2026-08-31 (it was written by hand).
cdn-name-snapshot.mjs runs on the VPS every 10 min: one electrum history
call plus only the new transactions, then writes the same raw format as two
public, read-only copies on two storage backends:
https://dl.silentmode.st/bns-name-snapshot.jsonhttps://navigate.st/bns/silentmode.bch/bns-name-snapshot.json (Sia)
No wallet or credentials are needed to read either; clients still rebuild
the index from the transactions. A failed upload of the Sia copy retries on
the next run. nameListUrl (+ nameListMirrorUrl) point at them.
The "BNS Resolver Daemon" and "BNS Sia Bridge" scheduled tasks were
registered with New-ScheduledTaskSettingsSet's defaults, which include
DisallowStartIfOnBatteries and StopIfGoingOnBatteries. On a laptop booted
on battery the system-wide resolver and gateway never started, and an
already-running daemon was killed the moment the charger was unplugged,
so BNS names silently stopped resolving.
install.ps1 (which the Inno installer runs on every install and upgrade)
now registers both tasks with -AllowStartIfOnBatteries
-DontStopIfGoingOnBatteries. The Argus copy of install.ps1 gets the same
change.
Existing installs keep the old task definitions until reinstalled, so the
daemon (running as SYSTEM) now checks both tasks 30 s after start and
flips just those two flags in place via Set-ScheduledTask. Every other
setting and the running instance are left untouched. The fix lands on the
first boot on mains power after the upgrade; battery boots work from then
on.
The public repos carried no license, so nobody could legally copy or build
on the code, and the whitepaper's "free software" had nothing behind it.
Theseus and its companions take the Mozilla Public License 2.0, the
file-level copyleft Firefox and Brave use, which is compatible with every
component they bundle. The resolver and gateway libraries take Apache-2.0
so that other implementations of the registry can reuse them without
copyleft in the way. The protocol documents and the whitepaper are CC BY 4.0.
A third-party notices file lists what the browser ships and fetches, with
the source offer the GPL sing-box binary the VPN add-on downloads requires;
the matching source archive is now published beside the binaries. The
names and marks are reserved in TRADEMARKS.md, separate from the code
license, so a fork must ship under its own name. Settings › General says
the license and links the three files; the whitepaper says the same.
The app read its TLD set from /api/tlds `advertised` — the legacy nine —
so a name under a newer registry TLD was intercepted only once the local
index already carried it. It now reads `routable` (falling back to
`advertised` for an older gateway) and ships a bootstrap list matching
today's routed set for cold start.
The cold-start snapshot it pulls from dl.silentmode.st was produced by
publish-name-mirror.mjs, which needs the operator wallet and ran by hand:
the CDN copy was sixteen days and seven names behind. cdn-name-index.mjs
now reshapes the gateway's own /api/registry into the snapshot format on
the VPS every ten minutes (systemd timer in VPS/SilentMode), so no wallet
and no hand step are involved.
The gateway checks a name's host rules before it decides what to serve, so a
blocked or redirected subdomain behaves the same whatever record the name
carries. Theseus only inherited that for names it proxies through the
gateway's /bns/ mount. A name with both s3 and ip — the shape that caused
the 2026-08-13 subdomain bug — would have had its blocked subdomain answer
anyway, because Theseus talks straight to the IP.
It now asks the gateway for the host's verified rule before taking either of
the paths it serves itself, and only for those paths, so an ordinary
subdomain navigation gains no round trip. Verification stays in one place:
the client reads a decision, it does not re-derive one.
The spec catches up with what is implemented — it still described v1 and
called hosts a future idea.
The per-name tab was a DNS record table: eight record types, of which the
stack reads exactly one, and then only as a last resort when a name carries
no content record. Everything else was signed, stored and read by nobody —
furniture that looked like configuration. Meanwhile the thing a name owner
actually has, a folder tree that already answers on matching subdomains, was
not visible anywhere and could not be changed.
The tab now leads with what answers today: the apex, every folder in the
name's storage next to the subdomain it serves on, and which of those a rule
overrides. Below it, the rules — point a subdomain at a different folder,
redirect it, or switch it off.
Classic DNS keeps its table, behind a disclosure that says plainly what
those records do today and what they are waiting for. Nobody loses a record
they published; they just stop being the first thing a name owner is asked
to think about. The table gains a Host column, so records can belong to a
subdomain rather than only the apex.
Both halves are one signed manifest and one publish button — they always
were one file, and two buttons for it only ever invited a half-saved state.
Labels and values are separated in the generated rows so the words
translate and the owner's own folder and URL do not.
The signer accepted v1 manifests only, which would have rejected every
publish from this screen.
Feature. The daemon now hot-reloads C:\ProgramData\Ariadne\policy.json every
5 s (fs.stat mtime check, only opens the file when it actually changed) and
flips its DNS collision policy between bcnr-first and icann-first in place.
Every browser on the machine follows within seconds -- no daemon restart,
no reinstall, no UAC. The next Ariadne panel in Theseus will edit this file
directly; a text editor works too.
install.ps1 seeds the file with the current -Policy default on FIRST install
only (an upgrade never clobbers a user's later flip) and grants BUILTIN\Users
Modify on it so an unprivileged app can rewrite it. Invalid values keep the
last good policy (logged). File deleted -> daemon falls back to the CLI
policy the scheduled task was registered with.
Also fixes an intermittent "config unreadable" on daemon startup when
ariadne.config.json had a UTF-8 BOM -- same class of PS-writes-BOM bug 0.1.6
fixed in the TLD-sync path, this time in bnsd.js's own config loader.
Verified live on this box with a second daemon running on port 15353:
bnsd policy: bcnr-first -> icann-first (from C:\ProgramData\Ariadne\policy.json)
bnsd policy: icann-first -> bcnr-first (from C:\ProgramData\Ariadne\policy.json)
bnsd policy: ... has invalid policy 'cash-first' -- keeping bcnr-first
ACL check: BUILTIN\Users has Modify on policy.json, non-elevated write succeeds.
Deployed
- dl.silentmode.st/AriadneResolver-Setup-0.1.7.exe
- s3://bns/silentmode/dl/AriadneResolver-Setup-0.1.7.exe
- silentmode.st manifest advertises 0.1.7
- sha256 742f8005c2dfb47929287c009cddf7738f0d12bd56fb8f8a837df3e9b435f47f
Theseus UI wiring for this file NOT touched -- settings-preload.js has 31
lines of uncommitted work from another session; a Theseus panel selector
belongs in that session's next commit so it doesn't collide.
Until now a subdomain served the folder its labels spelled out, and that was
the only thing it could ever do. There was no way to point one somewhere
else, send one to another site, or stop one answering — the mapping was a
convention with no override.
A verified hosts entry now decides: a different folder, a redirect, or
nothing at all. Block beats redirect beats path, so a host that is both
blocked and redirected stays blocked. The manifest is fetched only when the
request is actually for a subdomain, and the result — including the absence
of one — is cached for a minute, so the apex pays nothing and a name that
never published one pays one lookup per minute rather than one per request.
A manifest that fails to verify is treated exactly like no manifest: the
name keeps serving from its chain records instead of failing shut.
The folder derivation moves next to the picking rule and grows tests,
because getting it wrong does not raise an error — it quietly serves the
wrong bytes. The subtle parts are that labels reverse (DNS reads outward, a
path reads inward) and that a .bch name indexes as <label>.bch even though
normalizeName collapses it to a bare label; matching the bare label would
have served the bucket root for every subdomain of every .bch name.
/api/dns answers for a host too. A host with its own dns block owns its
record set outright, the way classic DNS works; one without inherits the
apex, the way everything else on BNS does.
The signature check was written out twice — once on the read path, once on
the write path — and a third copy was about to be needed for serving. Three
hand-copies of a verification routine is how a hole gets in, and the
dangerous half is subtle: pubkey recovery always succeeds and always yields
some key, so the line that actually proves anything is the comparison of the
derived address to the on-chain owner. That line is now in one place.
The manifest schema gains a hosts block for per-subdomain records and
routing, validated strictly here because whatever we accept we then serve
under the owner's signature: no path traversal, no non-http redirect
targets, no host key that is not a label path. v1 blobs stay valid, so
nothing anyone has published needs republishing.
Also adds the rule for which hosts entry governs a request. The apex is
deliberately never one of them — its content comes from the chain, and
letting an off-chain blob redirect the main site would move a name's trust
boundary without the owner spending a transaction.
Other browsers stopped opening most BNS names because two lists had
drifted. The installer took its TLD set from the gateway's `advertised`
field — the legacy tlds.bch record, nine TLDs — while the registry had
moved to per-TLD NFTs with twenty (including .x, where most Silent Mode
sites live). And the daemon's permitted-TLD list came from the bundled
ariadne.config.json (seven TLDs) rather than the CA the installer had
just minted, so only the overlap — .bch, .p2p, .deviant, .nav — actually
worked; .sia, .nt and the rest got no certificate, .x got no route at
all.
Now: the gateway publishes `routable` (every live TLD on the public
listing plus the legacy advertised set); the installer routes and
certifies that, skipping ICANN-delegated collision TLDs (.cash, .gt,
.sc — checked against the live IANA root zone, bundled snapshot as
fallback) since the local CA must never be constrained to a real
internet TLD; it writes the certified list into ariadne.config.json;
and the daemon reads its permitted list from the installed CA's own
name constraints, so the two can no longer disagree.
Symptom: on any machine that had been through a series of installs, one or
more older "BNS Local Root CA" certs from earlier builds could accumulate in
LocalMachine\Root -- an unconstrained root from before 2026-07, or a rotated
one -- and every install added the current constrained root ALONGSIDE the
stale ones. Two certs with the same Subject-DN defeat browser chain-builders:
Chromium/Edge/Firefox pick one candidate and refuse to try the others, so
every .bch site failed with ERR_CERT_AUTHORITY_INVALID / SEC_ERROR_BAD_SIGNATURE
in every browser EXCEPT Theseus (which trusts the on-chain tls fingerprint
directly, not the machine store).
Reproduced on the dev box: two BNS Local Root CAs present (thumbprints
EB5DBE98... constrained + 23538FD7... unconstrained), DNS answered correctly,
strict Invoke-WebRequest to https://coinspectrum.bch/ failed. Removing the
stale one and re-running the chain-build restored HTTPS 200.
Fix (install.ps1):
- Before Add(imported), enumerate the store and $store.Remove() every
"BNS Local Root CA" whose thumbprint differs from the one we're about
to trust. Post-condition: exactly one BNS root remains after install.
Fix (uninstall.ps1):
- Switched from `Get-ChildItem Cert:\LocalMachine\Root` to X509Store direct.
The Cert: PSDrive is not always loaded in Inno's [UninstallRun] -NoProfile
context, so uninstall could silently leave the CA behind on the store side
even while it removed everything else.
Verified end-to-end on this box: 0.1.4 upgrade over 0.1.3 finished cleanly,
BNS Resolver Daemon Running, 127.0.0.1:53 open, exactly one BNS Local Root
CA in the machine store, strict HTTPS to https://coinspectrum.bch/ returned
200 (506,997 bytes) with X509Chain.Build ok=true leaf -> BNS root.
Deployed
- dl.silentmode.st/AriadneResolver-Setup-0.1.4.exe
- s3://bns/silentmode/dl/AriadneResolver-Setup-0.1.4.exe
- silentmode.st manifest advertises 0.1.4
- sha256 30ed5e3bb6d6da5325f854c97976410b4fc63067ed52c810a55e9d2e1bdc4e17
Every client hard-coded the chipnet beacons, address prefix and electrum
servers on its own: resolver, registrar config, wallets, gateway, indexer,
mirror scripts, the browser bundle and the mobile Java. A mainnet launch would
have meant finding all of them and hoping none was missed.
The table now lives in resolver-web.js, the one file every client already
shares, so it stays a single-file drop-in. BNS_NETWORK selects the record;
unset means chipnet, so nothing changes today: the live index resolves the
same 60 names and 20 TLDs, the 67 offline tests pass, and the dashboard,
market and studio load the same values through BNS.NETWORK.
The mainnet record carries the verified public servers, the prefix and its
own Sia bucket, but its beacons, start height and operator address are
deliberately null: requireBeacons() refuses to scan until they are pinned in
the order ROADMAP-MAINNET.md §6 requires. Bns.java reads a generated
BnsNetwork.java so the phone cannot drift from the desktop clients.
NETWORK-CONFIG.md records what reads the table and what a launch still pins.
Two routes, in the house flat-if style next to the other /api handlers.
GET /api/vpn/servers returns the exit list with Reality material only —
host, port, sni, fp, pbk, sid — and no client UUID. All of that is
already visible to anyone watching the handshake, so the catalogue is
safe to serve and to cache.
POST /api/vpn/session leases one pre-provisioned UUID from that exit's
pool to the caller for BNS_VPN_TTL_MS and returns a built vless:// URL.
Asking again inside the TTL returns the same lease instead of burning a
second UUID, so a client that reconnects is not counted as a new tenant.
Paid tiers require a signature over BNS-VPN1\n<serverId>\n<tier>\n<ts>
and the recovered address must equal the address the caller claims —
recovery on its own decides nothing. Pro and Max then stop at 501,
because the subscription ledger they would check does not exist yet;
better an honest refusal than a free lease wearing a paid label.
This replaces the shared credential the extension was going to ship. It
does not give real revocation: xray keeps honouring a leased UUID until
the inbound is rotated, so the TTL frees a slot for re-issue but does
not cut a live tunnel. What it does give is that every caller holds a
distinct UUID, so one abuser can be dropped from the pool without
touching anyone else, and nothing is ever published. Real expiry needs
the live xray handler API on each box; noted in the handler comment so
the next session does not assume the TTL is enforced.
The pool file is the only place those UUIDs exist off the servers. It is
read from BNS_VPN_POOL_FILE, created by hand on the VPS, and kept out of
the repo the same way oracle.key is.
One deployment snag found while writing this: the angie vhost forwards
only Host, so every request reaches the gateway as 127.0.0.1 and callers
are indistinguishable. Rather than rate-limit the whole internet as one
bucket — which would let the first user lock out the rest — clientKey()
returns null in that case and the handler answers 503 explaining the
proxy needs X-Forwarded-For.
Smoke test covers what matters for a credential issuer: the catalogue
leaks no UUID, two callers get different ones, one caller asking twice
burns only one, exhaustion is a clean 503, tier gating holds, a junk or
stale signature is refused, and an unidentifiable caller is refused
rather than served. It stages the flat VPS layout so it exercises the
real gateway file, not a rewritten copy. 17/17.
After a successful USD listing the panel tried to show the BCH-style offer
hex, which dollar listings do not have, so the seller saw an error and a
bare Cancel button. The panel now shows the contract address instead.
The gateway pruned listings from its store whenever one electrum lookup did
not show the covenant output. A listing a few seconds old can be invisible
to one server while another already relayed it; deleting it then loses a
real on-chain listing. Absence is now trusted only after 20 minutes; before
that the listing is hidden from buyers but kept.
/bns/sirius.x/theseus was resolving to theseus/index.html but served in
place under the old URL. The injected <base> then stayed at the parent
directory (/bns/sirius.x/), so the sub-page's own relative references
went wrong in two visible ways:
* <link rel=icon href="./assets/favicon.svg"> loaded the SIRIUS
favicon, not the theseus one — visible as the theseus subdirectory
"borrowing" its parent's icon.
* href="../market/" inside theseus/index.html resolved to /bns/market/
(dropping the sirius.x name) — reported as "the market link goes
to /bns/market/ with usage /bns/./ as result".
Fix: when the directory-index fallback fires, 302 to add the trailing
slash instead of serving in place. The browser follows the redirect to
/bns/sirius.x/theseus/, my per-directory baseHref computes correctly
there, and every relative reference lands where the source expects.
Same behaviour as nginx `try_files ... /$uri/`. Flat-file rescues
(/tld -> tld.html) and SPA fallbacks keep serving in place — those
don't have the base-mismatch problem.
Verified live: /bns/sirius.x/theseus 302 -> /theseus/; theseus's own
favicon loads; ../market/, ../tld, ../portal all stay inside sirius.x.
Two symmetric changes so a site published as flat .html files (sirius.x
has tld.html, portal.html, market.html, register.html, studio.html)
reads with clean URLs to the visitor.
1. S3 handler fallback order for extensionless requests:
/tld → tld.html (new — first)
/tld → tld/index.html (existing — second)
/... → index.html (existing — SPA last-resort)
Requests with a real file extension (.js, .svg, .json) are never
rescued — those must never be routed by a client-side router and
swallowing them would hide real bugs.
2. transformHtml() gains stripHtmlExt(): rewrites href="foo.html"
(relative) to href="foo", preserving anchor and query. Absolute
URLs (https://silentmode.st/tlds/) are left alone — external
references are none of our business.
Backwards-compatible: existing /bns/<name>/foo.html links still 200.
Verified end-to-end on sirius.x: /tld, /portal, /market, /register,
/studio all serve; assets under js/, assets/ unchanged; direct .html
requests still work.
A BCH-priced offer is a signed transaction with a fixed amount, so a
seller who wants "$500" had to relist whenever the rate moved. The new
USD mode moves the certificate into a small CashScript covenant
(contracts/usd-listing.cash). Anyone may buy it by paying the seller
target_cents / price, where the price comes from a message signed by the
gateway's oracle key — the median of five exchanges the site already
uses. The payout is clamped to a floor/ceiling band the seller sets, and
the seller can reclaim the certificate at any time.
What the chain can and cannot enforce, stated plainly in the contract and
the UI: script can require a transaction to be mined no earlier than a
time, never no later, so a quote cannot expire on-chain. The band is the
guarantee, and quotes are bound to the listing's outpoint so cancelling
and relisting voids every quote ever issued. An offline libauth VM test
covers list, buy, underpayment, foreign-listing quotes, forged quotes,
band clamping, cancel and a non-seller cancel; on chipnet aloevera.test
was listed at $0.30, bought by a second wallet for 119,090 sat at
$251.91, listed again by the new owner and cancelled.
Gateway: GET /api/price/oracle[?txid=] serves the oracle public key and
per-listing signed quotes; the key lives in oracle.key beside the
gateway, never in the repo. POST /api/market accepts kind "usd" after
reproducing the redeem script from the stated terms and finding the
certificate in that covenant; such listings are pruned once the covenant
output is spent; DELETE accepts the listing's seller while the index
shows the covenant as holder.
Dashboard: the Sell tab has "Price is fixed in: US dollars / BCH", a
band control with the resulting BCH range, and cancel through the
covenant. Market: USD listings show the dollar price, today's BCH
equivalent and the band; buying fetches a quote and completes the
covenant spend. Studio no longer sends a needless UPD when the registry
view is stale, and missing files on a name now get a real 404 page.
The market page had just moved to market/index.html and still referenced
market.js and pricing.js under their previous query strings, so browsers
kept the cached satoshi-priced versions. Tags are now in step across
every page. The gateway also sends cache-control max-age=120 for
everything it serves from Sia; those files carry no ETag, so browsers
were applying heuristic caching and holding old scripts for hours.
theseus.x had a hardcoded href="https://sirius.x/" that worked in Theseus
(which resolves BCH names natively) but 404'd for ordinary browsers
served through the relay. Fixing it in every source file each time a
site adds a cross-name link is thankless; fix it once at the gateway.
New transformHtml() = injectBase() + rewriteBnsLinks(). The rewrite
matches href="https?://<label>.<tld>/..." and, when <tld> is a known
BCDN-only TLD, replaces the URL with /bns/<host>/... so the click stays
inside the relay origin.
Allowlisted TLDs: bch, x, p2p, deviant, bit, nav, test, coin, cash.
Intentionally EXCLUDED: st, com, gt, sc, de — those exist in ICANN and
rewriting them would hijack real web references (dl.silentmode.st is
the working case). The rewrite only fires on gateway-served HTML, so
the source sites remain clean when consumed at their native origin
by Theseus or Ariadne.
Verified: https://sirius.x/ on theseus.x home + plug-ins pages now
becomes /bns/sirius.x/; ordinary dl.silentmode.st links unchanged.
Every dollar figure on the site came from a hard-coded 250,000 sats per
dollar, which implies $400 per BCH; the market is near $250, so name
prices, TLD fees and sale listings were shown about 60% too cheap in
dollars. The gateway now serves /api/price: the median of Coinbase,
Kraken, Bitstamp, Binance and CoinGecko public tickers, no keys, cached
60 s, last good answer kept if every source fails. The site reads it
first, queries the same tickers itself if the gateway is unreachable,
remembers the last rate per browser, and only falls back to a constant
for the very first paint. Pages repaint when the rate arrives, and the
rate line says where it came from and how old it is.
Units: satoshi no longer appear anywhere. Sale prices, the seller's
input and the buy dialog are in BCH with the dollar figure beside
them; balances, fees and dust are in bits (1 bit = 100 satoshi).
The path-form handler injected `<base href="/bns/<name>/">` on every
page regardless of the sub-path being viewed. That meant a subdirectory
page's relative-parent links resolved wrong: from /bns/theseus.x/extensions/
the page's `<a href="../plug-ins/">` was resolved against the base as
/bns/plug-ins/ — dropping the name, giving a 404.
Compute baseHref from the directory of the current sub-path instead
(everything before the last "/" in rest, defaulting to "/"). Now:
/bns/theseus.x/ → <base href="/bns/theseus.x/">
/bns/theseus.x/plug-ins/ → <base href="/bns/theseus.x/plug-ins/">
/bns/theseus.x/plug-ins/x.html → <base href="/bns/theseus.x/plug-ins/">
/bns/theseus.x/extensions/ → <base href="/bns/theseus.x/extensions/">
`../plug-ins/` from the extensions page now resolves correctly to
/bns/theseus.x/plug-ins/, and `./` on a subdirectory page resolves to
the subdirectory itself instead of the site root.
Verified end-to-end: clicking Plug-ins from Extensions preserves the
name in the URL. Assets loaded via `../assets/...` from any subdirectory
still resolve correctly (they now go up exactly one level, to the site
root).
Pages such as potidaea.asm put their background on <html> and leave
<body> transparent; the editor's base stylesheet paints <body> white,
so the imported page showed light text on white. The canvas body now
stays transparent, which also matches the exported page.
The gateway's public name route sends Access-Control-Allow-Origin so
Studio on sirius.x can fetch a page's stylesheets and inline them
instead of falling back to links; the route only serves public content.
The language picker had no toolbar to attach to in Studio and floated
over the blocks panel; pages can now mark a slot for it and Studio does.
Anyone who owns a BCDN name can now publish a Theseus extension, and every
Theseus can install it with the publisher's signature verified locally.
Gateway (Argus/src/gateway/public-gateway.mjs):
PUT /api/ext/<name>/<id>/<version> takes the gzipped tar, checks two BCH
message signatures against the name's current NFT owner (one authorises
the upload, one is stored in the channel), inspects the package
(addon.json at the root, id/version/main match, 8 MB cap), enforces
first-publisher ownership of an id and monotonic versions, and writes the
tarball, the extension's updates.json and community/catalog.json to Sia.
GET /api/ext/catalog reads the catalog back with CORS.
Theseus:
lib/publisher-sig.mjs recovers the signer of a channel entry; main.js
compares it with the publisher name's owner from Theseus's own chain
index before installing or updating, so neither the relay nor a tampered
catalog can pass off code under a trusted name. addon-updater.js gains
installCommunity() and accepts publisher-signed entries in the regular
update check (operator Ed25519 entries unchanged). Settings › Extensions
shows the community catalog with Install / Update; Settings › Plug-ins
links to theseus.x/plug-ins.
theseus.x:
/plug-ins/ is a separate page for the first-party plug-ins (Aegis,
Ariadne's Thread) with live versions and hashes; /extensions/ lists the
bundled extensions, the community catalog, and how to build and publish;
/extensions/publish/ signs and uploads a package in the browser with the
wallet that holds the publisher's name (session helper + wallet bundle
copied alongside).
Sirius Studio only looked in its own folder, bns/<name>/. Every site
published the conventional way lives at bns/<label>/ (the s3 record),
or inline in the h record, so the editor opened those names as "new
site" with a template picker instead of the current build.
The gateway's site read gains a read-only ?src=live view that follows
the on-chain record: list and fetch from whatever bucket s3 points at,
or serve the h HTML as index.html. Writes are unchanged and still
confined to bns/<name>/.
The editor now starts from the live page when there is no draft:
relative image and link URLs are resolved against the public site URL,
stylesheets are inlined (cross-origin ones such as font CDNs are kept
as links and re-emitted on export), scripts dropped. When a draft
exists but the name serves something else, or index.html on Sia is
newer than the draft, a notice says so with a one-click import. An
"Import live site" button is always available. Verified with
aloevera.test, which is hosted at bns/aloevera/.
Two gaps the owner panel left open. First, a hidden TLD was only a UI
gate: anyone could still broadcast a REG under it and every indexer
admitted it. Second, there was no way to sell a name without trusting
the other side.
Co-sign rule (consensus, applied in lockstep by bns.js and
resolver-web.js): a REG under a TLD whose records at that height say
policy "cosign" or hidden 1 is indexed only if the transaction carries
the TLD's own certificate. The certificate can only be spent by the
owner's key and is re-issued to them in the same transaction, so it is
a co-signature nobody can forge and nothing is consumed. The TLD map
now keeps the TUPD timeline so policy is evaluated at the REG height.
Owners register under their private TLDs with the certificate added
from their own wallet; third parties under a "cosign" TLD build the
full transaction, sign their inputs and queue it at /api/cosign, where
the owner approves it from the dashboard (signCosignRequest refuses to
sign unless the certificate returns to the same locking script).
Marketplace: a listing is the seller's certificate input plus a price
output signed SIGHASH_SINGLE|ANYONECANPAY, stored by the gateway as a
bulletin board (/api/market, verified against the on-chain owner and
pruned when the certificate moves). The buyer completes it in one
transaction, so the seller is paid exactly when the name moves.
Cancelling also spends the certificate once so the offer is void.
Site: market.html, Sell sub-tab and Pending approvals in the portal,
Market link in nav and footer, six dictionaries extended, cache tags
bumped. Verified on chipnet: cosigned.sc registered by a throwaway
wallet through the queue with the .sc certificate back at the owner;
aloevera.test listed and delisted through the API.
Ariadne's resolver-web.js copy and the mobile Bns.java port still need
the co-sign rule; until then they admit REGs this index rejects.
BuiltInWallet.signMessage read `recovery` from libauth's recoverable
signature, but libauth v3 names it `recoveryId`. The flag byte came out as
0, so verifiers recovered the right key only when the true recovery id
happened to be 1. Signed DNS manifests and Studio uploads failed with
"signature does not match current on-chain NFT owner" and a different
derived address each attempt. Read the right field, fail loudly if it is
missing, rebuild the browser bundle and move every importer to the new
bundle URL. Also lands the registrar's signRecordsManifest that the bundle
already shipped.
Owners had no way to put a real page behind a name without running Sia
tooling themselves. Sirius Studio (studio.html, js/studio.js) embeds
GrapesJS (BSD-3, vendored under vendor/grapesjs so nothing loads from a
third party) with three starter templates; Publish exports one
self-contained index.html plus the editor project, uploads both to the
name's own folder bns/<name>/ on Sia, and sets the s3 record on chain if
the name does not point there yet. Drafts save to the same folder.
Gateway: /api/site/<name>[/<path>] — list, read-through, PUT and DELETE.
Every write carries a wallet signature over (name, path, body hash,
timestamp) that must recover to the current NFT owner, and writes are
confined to bns/<name>/ so no name can touch another name's bucket.
Dashboard menu is now Web-Builder, Domain names, My TLDs, Settings, Sign
out; Overview and Wallet folded into Domain names and Settings, Docs is a
link in Settings.
The 17:23 UTC deploy today pushed the repo copy of the gateway over the
VPS, but that copy predated the signed-records work (/api/records,
/api/dns, TLD_BEACON-sourced /api/tlds, holdings with tlds/wallet/chain),
which had only ever lived on the VPS. Three-way merge of that version with
the SPA-fallback change, plus the resolver's NFT-owner tracking that the
records routes verify against. The repo is now the only deploy source.
Two-tier fallback for extensionless paths that miss in Sia. The
previous change went straight to root index.html, which meant a real
sub-site living at "<name>/about/index.html" got shadowed by the SPA
root — visitors requesting "/about" got served the app instead of
the landing page.
New chain (matches nginx try_files order):
1) request path → /about
hit: bns/potidaea/about → 404
2) directory index attempt → /about/
hit: bns/potidaea/about/index.html → 200 · SERVE
3) if still 404, SPA root → bns/potidaea/index.html → 200
Trailing-slash requests still short-circuit to their own index.html
via the existing `rel` derivation (unchanged), so /about/ has been
working the whole time; only the bare /about was falling through
to the SPA rescue.
Deployed to silentmode 2026-09-16:
scp Argus/src/gateway/public-gateway.mjs
silentmode:/tmp/public-gateway.mjs
sudo cp … /opt/bns-gateway/public-gateway.mjs
sudo systemctl restart bns-gateway
When a request to /bns/<name>/<path> misses in Sia AND the path has
no file extension (looks like a client-side route, not an asset),
serve the domain's own index.html instead. Same shape as nginx's
try_files … /index.html — lets any hosted site use the History API
for pretty URLs.
Extension-having requests (missing .png / .js / .json / …) still
404 as they should — those should never be routed by a client-side
router and swallowing them would hide real bugs.
Deployed to silentmode 2026-09-16:
scp Argus/src/gateway/public-gateway.mjs
silentmode:/tmp/public-gateway.mjs
sudo cp … /opt/bns-gateway/public-gateway.mjs
sudo systemctl restart bns-gateway
Companion Deviant commit ("widescreen swap + URL routing") uses
this to serve /bns/potidaea.asm/katalogos etc. from the same
index.html.
install.ps1 root-cause fix
- Every `& $node ... 2>&1` had been tripping PS 5.1's "$ErrorActionPreference=Stop +
native-command stderr = terminating error" quirk. Trap-and-log added in 0.1.1 EXPOSED
the bug (0.1.0 died at the exact same line -- silently). 0.1.2 routes both node calls
through a new Invoke-NodeCapture helper that uses Start-Process
-RedirectStandardOutput/Error to file, so PS 5.1 never touches the streams.
Multi-source TLD cascade with on-disk cache
- -Indexer now accepts a comma-separated list; default "https://navigate.st,https://silentmode.st".
- Cascade (first hit wins): indexer[s] HTTPS -> local cache
(C:\ProgramData\Ariadne\tlds-cache.json) -> electrum WSS (fetchTldList / legacy
fetchBcnrTlds) -> bundled ariadne.config.json snapshot -> bootstrap [bch].
- Authoritative fetches (indexer or electrum) refresh the cache. Second install on a
machine that has ever succeeded before is instant + always works offline.
- Per-source deadlines kept tight (5s for indexer HTTPS) so a slow source can't stall
the install.
Upgrade over live install
- .iss CurStepChanged(ssInstall) stops "BNS Resolver Daemon" + "BNS Sia Bridge" via
schtasks + 1.5s pause before file replacement. Fixes "DeleteFile failed; code 5.
Access is denied" on runtime\node.exe when upgrading a machine where the daemon is
currently running.
CA import
- Switched Import-Certificate -CertStoreLocation Cert:\... to direct X509Store('Root',
'LocalMachine').Add(). Cert: PSDrive is not always loaded in -NoProfile elevated
shells, which had broken the standalone unblock scripts.
Verified end-to-end on this box: silent install -> exit 0, clean install.log,
BNS Resolver Daemon running, 127.0.0.1:53 open, 9 NRPT rules (all TLDs including .bch),
coinspectrum.bch resolves system-wide, tlds-cache.json populated.
Deployed
- dl.silentmode.st/AriadneResolver-Setup-0.1.2.exe
- s3://bns/silentmode/dl/AriadneResolver-Setup-0.1.2.exe
- silentmode.st + navigate.st/bns/silentmode.bch manifests advertise 0.1.2
- sha256 5a2c9e4f3c5e1a162008641019f5ffc35968751c0440f94d3c5fe67aafb8cc5e
Local Dev <noreply@localhost>