-
Morphit v1.18.0 Stable
released this
2026-09-25 22:28:09 +00:00 | 17 commits to main since this releaseMorphit v1.18.0
A major release. Chat between instances no longer waits for the blockchain,
and before release every part of Morphit was reviewed again from the ground
up: that closed a way for one hostile chain node to take over a node during
setup, several ways a Tor-only node could still reveal its home address, and
free fake "verified" listings. The rest of this page gives the details, fix by
fix.Chat between two privacy-only instances now lands in about two seconds instead
of eight, plus fixes for people using Morphit over Tor or I2P, an orderbook
comparison that accused honest instances of hiding orders, and a page that
blurred itself on load. It also closes two ways a Tor-only instance was still
reaching the open internet, one of them through its relay. The upgrade fixes
instances that are already installed, not only new ones. No protocol or
consensus change.Added
-
Chat between two privacy-only instances is now fast enough to hold a
conversation in. If you and the person you are talking to are on different
instances, your message used to travel to them through the blockchain — and
the blockchain's own timings made that hopeless. Sending waits for the message
to be written into a block, which takes three seconds; the other instance then
waits up to two more for its next check of the chain before it even looks. That
is five seconds of waiting before a single Tor or I2P round trip is counted,
and a delivered message pays for three of those. No amount of faster or extra
chain nodes helps, because none of that time is spent talking to them.So instances now hand each other chat messages directly, at the same moment the
message goes to the chain rather than after it. The chain still receives every
message and is still the permanent record — nothing about what Morphit
publishes has changed, and the saved copy of your conversation still comes from
it. What has changed is that the other person no longer waits for it to get
there.The connections between instances are kept open in the background, refreshed
every few minutes. This matters more than it sounds: opening a fresh Tor
circuit or I2P tunnel takes half a minute or more, and if the first message of
a conversation had to pay for that, the one message where the other person has
no reason to be watching their screen would be the slowest one. Keeping the
route ready means the first message is as quick as the rest.An instance accepts a handed-over message only if it carries a valid signature
from the sender's own posting key, checked against the key it holds for that
account — so a message cannot be forged or put in someone else's name, and
this is true no matter who sends it or where they connect from. If the key no
longer matches, it is re-read from the blockchain once (rate-limited), and that
correction is held in memory rather than written to your database. Blocks are
honoured exactly as before, and a first message from a stranger still shows up
in an open conversation without ringing anyone's phone. No message handed over
this way is written to the database; the permanent record still comes from the
chain alone. There is one write, and an earlier draft of these notes said there
was none: when a handed-over message may notify someone who has push turned on,
one row goes into the push queue, keyed on the transaction so it is queued once
however many routes deliver it. That row is a notification, not the message,
and the queue's own janitor retires it.Measured the whole way — one person's browser to the other person's screen,
across two instances, every leg over a slow privacy hop — delivery went from
7.3 seconds, which was the old route's best possible case, to about 1.4.To be precise about what that is: the instances, routes, signatures and
sockets are real, and the per-hop latency is a stated model (900ms for a
privacy hop). It is a measurement of the software under an assumption about
the network, not a measurement of Tor. Real timings depend on your own
circuits, andops/fastchat-latency-probe.shmeasures them on your box and
tells you where you stand. -
Two people on the same instance now get fast chat too — and they were the
slowest case of all. The fast path hands messages to OTHER instances, and an
instance is not its own peer, so when both people were on the same server
neither of them got any of this: the message went to the blockchain and came
back a block later, then waited for the next check of the chain, then a read
to fetch it. Up to 6.8 seconds on a privacy-only instance for two people on
one machine, which is very likely the most common conversation there is. An
instance now delivers to its own users the moment it relays their message,
with the same block-list and notification checks it applies to anything a
peer hands it. To the recipient's inbox: 1,334ms privacy-only, 527ms
clearnet. -
A reply to someone who just messaged you now notifies them. Fast
notification requires evidence the conversation is wanted — the sender is
answering your own live order, or you have written to them before. Both were
read from the permanent record, which runs 45 to 63 seconds behind. That broke
the ordinary marketplace exchange in the half that matters: a buyer messages a
seller about the seller's order and the seller is told at once, but when the
seller REPLIES the order is not the buyer's, and the buyer's own opening
message is not in the permanent record yet. So the person who started the
conversation thirty seconds ago got no notification at all until the chain
caught up. Measured before the fix: the check returned "no".An instance now remembers, briefly, that it just relayed one of its own users'
messages to someone — first-hand knowledge it had all along, minutes before
the permanent record admits it. This widens nothing: it only ever says yes to
a pair where the recipient demonstrably wrote first, which is the same thing
the old check meant, established sooner and from a better source. A message is
only remembered after the network accepts it, so a forged one records nothing.
Someone who has never written to you is as much a stranger as before. -
An instance hands over what it has queued before it shuts down. A restart
used to drop chat deliveries that were waiting to go out to other instances.
They still arrived by the blockchain, so nothing was lost — they were just
slow, for whoever happened to be mid-conversation when an operator restarted.
Shutdown now passes them on first, with a two-second limit so one unreachable
instance cannot hold up a restart. -
The browser's half of the inbox is now tested too. The part of the app
that turns a "new message" ping into a lit badge and a card for a conversation
you have never had before had no test of any kind. It does now, and so does
the agreement between the two halves: the server sends four specific fields,
and the browser lights nothing without the three that name the thread. Rename
one of those on either side and chat badges stop working for everyone —
silently, with no error anywhere,
which is the worst way for something to break. Both sides are now pinned, so
neither can drift without a test failing.The same is now true of the broadcast contract — the thing this release
actually changed — which had no test of any kind either. See "an older browser
tab" below for what that omission was hiding. -
Every combination measured, both directions, to the inbox. Same instance,
clearnet to clearnet, clearnet to privacy, privacy to clearnet, privacy to
privacy — a buyer's first contact and the seller's reply, each timed to the
moment it reaches the other person's inbox rather than an already-open
chatroom. Slowest of all twelve: 1,367ms, against the six-second bar.Each of those timings is now held to a floor as well as the six-second
ceiling. A result faster than the hops that case has to cross means the
message skipped a leg, which is what a test passing for the wrong reason looks
like — so the test now fails on it. Adding that floor found two defects in the
test itself, described under Fixed. -
Sending no longer waits for the blockchain either. Getting the message to
the other person quickly is only half of it: the sender was still watching
their own message sit there. Every send waited for the message to be written
into a block before the page was told it had gone — up to three seconds of
pure waiting, on top of the round trip to their own instance. Chat messages now
return as soon as the network has accepted the message rather than when it has
been sealed into a block. Nothing is given up: the message is still checked and
still rejected if there is anything wrong with it, and it still goes to the
chain exactly as before. Everything that genuinely needs a block number —
orders, transfers, account creation — is unchanged and still waits.On a fast connection this was never the problem; on a slow one it was decisive.
A send that waits for a block crosses six seconds once a round trip passes
about 2.6 seconds, which privacy networks reach regularly. Measured on a
three-second hop, the old behaviour took 6.4 seconds and the new one 3.4.An older browser tab is safe. The page asks for the faster answer rather
than the server deciding on its own, because a tab you left open can be older
than the instance serving it — and a tab from before this release would have
read the new reply as a failure, showing you a red message that had in fact
been delivered, with a retry button that would send it twice. An older tab
simply never asks, gets the old reply, and still benefits from the fast
delivery on the other side. Both directions of that mismatch are now tested. -
A sick blockchain node no longer stops conversations. Because the message
is handed to the other instance before it is sent to the chain, a Blurt node
that has wedged or gone unreachable no longer takes chat down with it. Measured
with a node deliberately stalled for twenty seconds, the message still reached
the recipient in under one. -
A hidden instance no longer throttles its own federation. Requests are
rate-limited per address, and over Tor or I2P every instance in the federation
arrives at the same one — the local privacy daemon — so the whole federation
shared a single allowance meant for one caller. On a privacy-only node so does
every human visitor, since all traffic arrives that way. Measured: the 241st
hand-off in a minute was refused no matter how many different instances sent
them, which would have crippled fast chat on precisely the nodes it was built
for. The allowance is now sized for a real federation, and the actual
protection moved to something that works regardless of address: a fixed-size
queue of pending work. -
A busy instance no longer goes deaf. Checking a message's signature costs
real processor time — about four milliseconds each, measured — and a delivery
can carry dozens. Done the obvious way that work would run in one solid block,
during which the instance answers nobody: no chat updates, no page loads,
nothing. Checking now happens after the sending instance has been answered,
and pauses between messages so everything else keeps running. Measured: the
instance never went quiet for more than 17 milliseconds while checking 64
messages, and answered the sending instance in 2 milliseconds rather than the
330 the checking itself takes.This also removes a subtle leak. An instance that took longer to answer for a
message it cared about than for one it did not would be telling anyone with a
stopwatch whether a particular person reads their messages there. Answering
before doing the work means the reply reveals nothing either way. -
Under real overload, fast chat steps aside instead of falling over. There
is a firm limit on how much pending work an instance will hold. Past it, extra
hand-offs are dropped rather than queued — and dropping is the right answer,
because every one of those messages is already on its way through the
blockchain, which delivers it regardless. An overloaded instance quietly
returns to ordinary blockchain timing instead of accumulating work it cannot
finish in time. Theshedcounter in the health output is the thing to watch. -
Fast chat holds up as the federation grows. Every message is offered to
every instance, so each instance has to keep up with the whole network's chat,
not just its own users'. One message per delivery could not have done that: a
privacy connection carries one exchange at a time, so a single instance would
have topped out at well under one message a second no matter how many users
were waiting. Messages that arrive while a delivery to an instance is already
in progress now travel together in the next one. Nothing is ever held back
waiting for company — a message to an idle instance leaves immediately, which
is the ordinary case — so this costs nothing when things are quiet and does the
work when they are not. Measured: forty messages that would have needed forty
round trips took two, about eighteen times the throughput per instance, with a
lone message still leaving in four milliseconds.
Fixed
Found by an independent review of this release
Everything in this section was written after four reviewers — none of whom had
seen the code being written — went over the new delivery path. It is listed
first because some of it is serious, and because a release that only tells you
what went right is not telling you much.-
Ten requests a second could have stopped an instance accepting anything.
The new peer endpoint shared a rate-limit allowance with the ordinary write
path, because the limiter groups requests by category rather than by limit. Six
hundred peer messages in a minute — no key needed, nothing valid needed — and
every user on that instance would have been refused: no chat, no orders, no
transfers. On a privacy-only instance, where every visitor and every peer
arrives through the same local daemon and therefore looks like one caller, that
is one allowance for the entire world. Federation traffic now has its own. -
Batching was switched off by the size limit it ran into. Grouping messages
is what makes a busy federation affordable, and a full group of long messages
is a quarter of a megabyte against a limit of four kilobytes. Groups form only
when an instance is already busy — so the fast path worked while idle and
refused its own traffic under exactly the load it exists to carry, silently
falling back to blockchain timing. The limit now matches what the endpoint
carries, the sending side keeps itself under it, and an instance that refuses
the size anyway is sent the messages one at a time instead of losing them. -
One database hiccup could have turned the fast path off until the next
message arrived. A single failed lookup abandoned every message waiting to be
checked, with nothing scheduled to come back for them — and they went on
occupying the queue until it was permanently full and refusing everything. One
failure now costs one message. -
An older browser tab would have shown delivered messages as failed. A tab
left open from before this release reads the new, faster reply as malformed:
the message reaches the other person, and the sender sees it in red with a
retry button that sends a second copy. The faster reply is now something the
page asks for, so an older tab never gets it. -
A message could be shown as sent forever without ever reaching the chain.
Waiting for a block used to be the proof it had landed; without that wait,
nothing replaced it, and an accepted-but-dropped message looked delivered
indefinitely to both people. A send that is not confirmed within two and a half
minutes is now marked so and can be resent — and if the real copy turns up
late, the mark clears itself. (The window was two minutes until the last round
of review showed that the slowest legitimate path takes up to 123 seconds.) -
A lost reply could turn a delivered message red. Your instance shows your
own message to you before it answers the send, so a connection that dropped at
the wrong moment stamped "failed" over a message already visible in the
transcript. Retrying sent it twice. -
The endpoint had no ceiling on several things anyone could inflate. How
many signatures one message carries (each costs real work to check), how far in
the future a message may claim to expire (which decides how long a captured one
can be replayed), and how large a group may be. All bounded now, and each bound
has a test that was watched to fail without it. -
A free, untraceable way to light someone's inbox badge. Every other use of
a fast message checked whether the sender was allowed to notify that person;
the live badge did not. That mattered little when every message had to go
through the blockchain — which costs resource credits and leaves a record — and
a great deal once an instance could hand one over directly. The badge now obeys
the same check as everything else, and first contact from the peer route is
rate-limited per sender, per recipient — so a flooder exhausts only their own
channel to that person, and the direct path is not a cheaper way to reach
someone than the chain it runs alongside. Established conversations are not
affected at all, and an open chatroom still receives everything unconditionally. -
A stolen key kept working after its owner replaced it. Posting keys were
recorded once and never updated, which no previous feature depended on. This
one does: the key is the only thing standing between a handed-over message
and your screen. So a key the owner had replaced after it leaked still
verified, which the blockchain itself would refuse. And the owner's own
messages, signed with the new key, stopped verifying.Your instance now records a key change from the same blocks it already reads
account creations from. It also forgets any copy of the old key it was holding
in memory. That second step is not a detail. Without it, the leaked key would
have kept working for up to half an hour, in exactly the case a key change is
for. Tested against real key-change operations and a real database; removing
only the forgetting step fails the leaked-key case.Key changes made before you upgrade are covered too. Recording changes
from now on does nothing for an account whose owner replaced a leaked key
last month: your database still holds the leaked key. So every key recorded
before this release starts out unconfirmed. On its first start, your instance
checks each one against the blockchain and corrects any that changed. Until
an account's key is confirmed, a message handed over for it is checked
against the blockchain first. If the blockchain can't be asked right then,
the message travels by the blockchain as it always did. -
Two defects in this release's own tests. Adding a lower bound to the timing
checks — a result faster than the hops it had to cross is a bug, not good
news — immediately exposed them. The link between instances was being modelled
in one direction only, so every reply figure quoted was short by half a hop;
and the stand-in blockchain gave every message the same identifier, so the
duplicate-suppression correctly discarded the second message in each case while
the test, which only checked that notifications arrived, never noticed. Both
fixed, and the figures in this document are the corrected ones.Worse, the four cross-instance cases were not crossing instances at all: both
stood-up instances shared one in-process event channel, so the test passed with
the entire federation deleted. It now fails 24 of 36 checks with the send side
removed and 24 with the receiving side removed, and both of those deletions are
permanent parts of the test suite. -
The test of the notification check was answering the wrong question. Its
stand-in database returned an order in a shape the real query never produces,
so the check said "no" in every scenario — meaning the headline promise of this
release, that a stranger's first message reaches a seller whose browser is
shut, was never actually tested. It is now, in both directions.
Found by checking the release against what it was for
The feature is meant to be fast between instances of every type, across every
network Morphit runs on. Read strictly, that is a claim about three networks —
Tor, I2P and Lokinet — and it had been tested against one.-
Federated chat did not work at all on an instance without a Tor daemon.
Which is most of them: a fresh install has no Tor, and the setting that points
at one is filled in by default whether or not anything is behind it. So your
instance picked the.onionof every peer that had published one, threw away
the ordinary web address sitting right beside it, and failed to deliver a
single message to those peers — instantly, silently, for as long as that
configuration stood. Nothing errored, your peer count looked healthy, and the
only symptom anyone could report was that chat was "sometimes slow", because
every message quietly fell back to the blockchain's own timing.An instance now keeps every address a peer has published and picks one it
can actually reach, preferring the private ones exactly as before. If a
private route turns out to be unusable, the message goes by another road
within the same send — a few milliseconds later, not a timeout later —
and your instance remembers which network is down so the next message does not
pay to find out again. When the daemon comes back, the next background check
notices and the private route resumes on its own.Two instances that are both private-only, with no ordinary web address between
them, fail over to each other's second private network instead. That is the
case with nothing underneath it, and it is now the case with a test. -
Your instance could mark healthy peers as unreachable when the fault was
yours. If your Tor daemon stopped, your instance went through the federation
directory recording every.onionpeer as unreachable — peers that were up the
whole time. The protection against exactly this was written, and documented,
and could never run, because of a detail of how failures are reported inside
the browser-style request library. On I2P and Lokinet the protection had never
existed at all. All three now work, and the test for it uses real failures from
a real dead connection rather than ones written by hand, because a hand-written
one would have agreed with the bug. -
A connection setting could have quietly undone the whole point of keeping
routes warm. Your instance holds open a connection to each private peer and
refreshes it every three minutes, so a message never waits for a new tunnel to
be built — which on Tor or I2P takes half a minute or more. How long an unused
connection is kept is a separate setting, in a different file, and nothing
checked that it was longer than three minutes.Had it ever been set shorter, every single refresh would have found the
connection already closed and built a new one: the loop that exists to remove
that cost would have been paying it instead, on a timer, forever. Nothing
would have looked wrong — messages still arrive and the refresh still reports
success. Only the speed would have changed, on exactly the networks where it
hurts most.Both settings are now checked against each other, and against the point where
Tor discards an idle circuit at the other end. Separately, the I2P side now
proves it reuses one tunnel across many messages rather than opening a new one
each time — Tor has been checked that way since the start, I2P never had been,
and it is the same setting that keeps the six-second promise on both. -
If your I2P router is set to refuse tunnels, your instance now says so
instead of blaming your peers. Reaching another instance over I2P means
asking your own I2P proxy to open a tunnel. The Java I2P router has a setting
that refuses those outright —i2ptunnel.httpclient.allowInternalSSL=false,
which despite its name blocks ordinary in-network destinations on every port.
i2pd has no such setting and cannot be configured to refuse.When that refusal happened, your proxy was running and answering, so it did
not look like anything being down. It was recorded as the other instance
refusing your message. The result was a climbing failure count pointed at
peers that were perfectly healthy, no mention anywhere of your own router,
and federated chat over I2P simply not working.Your instance now recognises the refusal for what it is and reports it in
/v1/health. It still waits for two different addresses to fail before
blaming your router rather than the destination — a router refusing by policy
refuses every address, so that happens immediately — and the operator guide
explains which field to read when you have only one I2P peer. -
One peer's out-of-date Lokinet address could push your other Lokinet
traffic onto the ordinary web. When a private route fails, your instance
decides whether the fault was its own — and if it was, stops offering that
network to every peer for a minute. On Tor and I2P it can tell: the failure
names your own daemon. On Lokinet it cannot, because the failure is a name
that would not resolve, and a peer whose published.lokiaddress is stale or
mistyped fails in exactly the way your own router being switched off fails.So one peer with an out-of-date address was read as your Lokinet being down.
Every other.lokipeer then lost that route for a minute, and any of them
that also publish an ordinary web address had your messages sent that way
instead — on an instance whose operator chose a private network precisely to
avoid it. Nothing failed, so nothing was reported.Your instance now waits for two different addresses to fail before blaming
its own router, which a router that really is off supplies immediately, since
it fails every address it is given. A route that is proven to work clears the
suspicion, and old evidence expires rather than accumulating. Tor and I2P are
unchanged and still decide on the first failure, because there the failure
says whose it is. -
A censored instance is now found over whichever private network it
published. Your instance retries an unreachable peer over its hidden address
— but it only ever tried.onion. An instance behind a national firewall that
had published only an I2P destination, which is what an operator does where Tor
itself is blocked, was recorded as unreachable and dropped out of the
directory. All four published address types are tried now. -
And
morphit-ops healthnow shows it, which is where you would actually
look. All of the above went onto the health endpoint, and the tool you use
to read that endpoint displayed none of it — it showed the blockchain-tailing
half and stopped. The operator guide, meanwhile, told you to read these fields
with the ops CLI.Running
morphit-ops healthnow gives you a line for federated chat, with the
reason underneath when there is one:Fed. chat: degraded — tor unusable from this box (12 delivered, 40 failed) ↳ your tor is not answering — start it; the next warm-up clears thisForty failures that are not your peers' fault, and the fix on the next line.
An instance older than this release has nothing to report here and the line is
left out rather than shown as zeros, because during an upgrade "0 delivered"
would read as a fault instead of a version difference. -
When chat is slow,
/v1/healthnow tells you whose fault it is. A failure
count sends you looking at the federation when the answer is a daemon on your
own machine. Your instance now reports which of its own networks are down, by
name, and marks each recent failure with whether the message ever left your
box. The reasons were always recorded; until now only the tests could read
them. -
A growing federation would have spent its message fan-out on dead
instances. Every chat message goes to every other instance, so that list is
capped — and which instances made the cut was decided by how recently your
node had last checked on them, which says nothing about whether they are
answering. An instance known to be down, checked a minute ago, outranked a
healthy one checked an hour ago. Worse, a brand-new instance sorted below all
of them, because it had never been checked at all — so the one place whose
users have no established conversations to fall back on was the first to be
left out.Instances are now ranked by health, with censored ones kept among the healthy
(unreachable over the ordinary internet but demonstrably alive on the chain is
the case Morphit is built for, not a sick node). Your node also now says how
many instances the cap left out, so "healthy, reachable, and still slow" has
an answer instead of being a mystery.Nothing is mis-served today — the federation is far smaller than the cap. It
is fixed now because the failure it produces is invisible from the affected
instance, and "we are far from the limit" is how a cliff goes unlit. -
Message ordering does not depend on anybody's clock, and now cannot start
to. A message delivered directly carries no timestamp of its own; the
receiving instance works one out from the transaction. If that had come from
the sender's clock, two operators whose servers disagreed about the time would
have shuffled each other's messages — and every operator would have needed to
keep their clock in sync without ever being told so.It comes from the blockchain's own clock instead, which every instance shares,
so this was never a problem. But the one number the calculation depends on was
written down separately in the browser and in the server with nothing tying
them together, and changing either alone would have quietly misdated every
directly-delivered message — visible only as messages appearing in the wrong
order next to ones that arrived the slow way. There is now a check that fails
if the two ever drift apart. -
A missing database index could switch off every push notification on your
instance, and nothing would have told you. A chat message is notified twice
on purpose — once when your instance sees it, once when the chain makes it
final — and exactly one of those is allowed to reach the phone. The mechanism
is a uniqueness rule in the database that the second one collides with
harmlessly.If that rule is absent, PostgreSQL does not quietly skip the check: it refuses
the insert outright. Your instance catches the refusal so a notification
failure can never take a chat message down with it — which is the right
behaviour, and which means a database missing that one index delivers no
chat and no feedback notifications at all, with no error page, no failing
health check, and one log line per message. Outbid notifications carry on
working, which makes it worse rather than better: push is evidently alive, so
nobody would connect "my phone stopped buzzing for messages" to a schema
problem.This is not hypothetical bookkeeping: the index is created by a migration, and
a database restored from a dump taken with the wrong flags, or hand-repaired,
or migrated by an interrupted run, arrives without it and looks completely
healthy.morphit-ops doctoralready compared your database's tables and columns
against the shipped schema and would tell you about anything missing. It had
never looked at indexes. It does now, and it names the ones you are missing.
Getting that right needed one subtlety — the schema creates a handful of
indexes and later replaces them, so counting those as missing would tell every
healthy operator their database was broken, which is how a real warning gets
trained into background noise. Verified against three live databases: a
healthy one reports clean, one with the index dropped names it, and restoring
it goes clean again. -
On a slower server, your instance now knows how many messages it can take
and still be fast. Every message another instance hands you has its signature
checked before it is shown, and those checks happen one at a time. So anything
waiting in that line is waiting behind all the checks in front of it — which
means the length of the line is really a length of time, and how much time
depends entirely on how quick your hardware is.Your instance used to allow a fixed 500 messages to queue up, whatever kind of
box it was running on. On a fast machine that is about three seconds and fine.
On a modest VPS with the database in a container it can be ten — so your
instance would accept a message, tell the sending instance it had it, and then
deliver it well outside the six seconds this release is built around. That is
worse than politely declining it, because a declined message travels by
blockchain and arrives late in a way nobody was misled about.Your instance now times its own checks and allows exactly as many to queue as
it can still clear in time. A fast box carries more than before; a slow one
carries less and says so.morphit-ops healthshows the figure when it is
below the maximum, along with what a check is costing you:Fed. chat: 3 peer(s) (120 delivered, 8 failed) ↳ received 97 from peers, 61 shed ↳ intake queue held to 252 (18.2ms per check) so pushes still land inside 6s — extra pushes go by chainNothing to configure. If you see that line, the overflow is going by blockchain
exactly as it always did, and a faster disk or a less busy machine is what
would raise the number. -
And the line above it was broken since the last release.
morphit-ops healthwas written to read a field the indexer does not send, so "received N
from peers" never appeared on anybody's terminal. It does now. The two sides
are checked against each other from this release on, so a rename cannot quietly
disconnect them again. -
A captured message could have been re-delivered by flooding your instance.
Your instance remembers every message another instance hands it, so the same
one cannot be shown to you twice. That memory is finite, and it used to make
room by forgetting its oldest entry — which meant anybody could decide when it
forgot, by sending enough of their own messages to push the rest out.The result would have been a real message from a real person appearing in a
conversation a second time, minutes later. Not a forged message: nobody can
write in someone else's name, and the blockchain copy remains the record of
what was actually said. But a duplicate nobody sent, which is unsettling and
looks like something worse than it is.Measured rather than assumed, and the surprising part is which instances were
exposed: the memory holds fifty thousand messages, and the limit on filling it
is how fast your server checks signatures — so a fast server was more
exposed than a slow one, because it could be emptied sooner. On the hardware
this was measured on it took under five minutes.Your instance now refuses new hand-offs rather than forgetting something it
still needs to remember. Those refused messages arrive by blockchain as they
always did. If it happens,morphit-ops healthsays so plainly, because at
any volume it means somebody is trying:↳ 41 push(es) refused to protect replay memory — a flood, or this box is busier than that table is sized forNothing to configure, and the memory recovers by itself.
-
A just-restarted instance is now cautious about how much it takes on. The
limit described above — how many hand-offs your instance queues before
declining — is worked out from how long its own signature checks take. Until
it has done a few, it has to assume something, and it used to assume the speed
of a fast machine with a local database.On anything slower that assumption is optimistic in the dangerous direction:
for the first minute or so after a restart your instance would accept more
than it could deliver in time. Which is precisely when a backlog is waiting —
the messages that arrived while it was down.It now assumes the slow end to begin with and speeds up as it measures itself.
A fast server reaches its true capacity within a few dozen messages; a slow one
never over-promises in the first place. The only visible difference is that a
busy instance may decline a few more hand-offs in the minute after a restart,
and those arrive by blockchain as always.
Found by following each fix to its edges
The last rounds of review took every open question left from earlier rounds and
read the code around it, not just the lines it named. The first two items are
the most serious in this release, and both are about a promise Morphit makes to
operators who run hidden-only.-
A hidden-only instance contacted other instances over the open internet.
An instance set up to use no clearnet at all still looked up and connected
directly to every peer that has a clearnet address, every time it checked the
directory. That hands this box's real IP address to every one of those
peers, which is the exposure hidden-only mode exists to prevent. The safety
net that is meant to refuse any stray clearnet request was being bypassed by
the one piece of code that made its own connections. It now refuses before it
even looks the name up, since the lookup alone reveals which peer you are
asking about. A peer your instance declines to contact is listed as "not
checked", never as unreachable. Demonstrated with a real listener standing in
for the peer: before the fix it was contacted; after, it is not. -
The relay on a Tor-only install used clearnet blockchain nodes. The relay
is the part of Morphit that sends signups and transfers to the blockchain. It
has its own list of blockchain nodes, and the Tor-only install never set it,
so it used the public clearnet list from the box's own address. It also could
not use a Tor or I2P node at all, and would not start without a clearnet
one. The instance still said "Zero use of clearnet internet", because that
claim only asked the indexer.The relay now uses the same Tor and I2P routing and the same fail-closed
safety net as the indexer. A Tor-only install gives it only hidden nodes, and
upgrading does the same for a node that is already installed. The
"zero clearnet" claim now asks the relay as well, and is not made unless the
relay confirms it. A hidden-only relay does not send phone or browser push
notifications, because every push service in existence is a clearnet server.
See Notes for what to configure. -
…and turning push off there had two side effects, both fixed. On a node
where people had subscribed to push before upgrading, the queue of pushes
waiting to go out would have grown for as long as the node ran, because only
the push sender cleared it. It is now cleared by the same rules whether or not
anything is sending. And those people's browsers kept saying "subscribed", or
told them the operator had not turned push on "yet". Now the settings page
says plainly why push is off on this instance, in all ten languages, and stops
claiming a subscription that nothing will deliver to. -
Prices on a hidden-only instance never came from the federation. A
hidden-only instance sets its prices from what its peers report. It had never
received a single report: every request to a peer was sent through a function
that refuses Tor and I2P addresses, and the price checker was off by default.
So it priced everything from its own trades or the fixed minimum, while its
public status said "federated". It now asks each peer at every hidden address
that peer publishes, over I2P, Tor and Lokinet, and always runs on a
hidden-only instance. Clearnet instances are unchanged. -
A message the blockchain never recorded looked delivered. The check that
marks an unconfirmed message almost never ran. Your own instance
shows you your message the moment it accepts it, and that moved the message
past the state the check was looking at. It now covers that case. A resend
inside an order conversation also dropped the order it belonged to, so it was
charged as a message to a stranger and filed as a direct message; resends now
keep it.On the other person's screen, a message that never reaches the blockchain is
no longer shown as if it had. After two and a half minutes it is shown dashed
and dimmed, with a note saying it was never recorded. The PDF export says the
same instead of "pending". If the real copy arrives late, the note clears
itself. A resend replaces the unrecorded copy only when the words are
identical, so a resend cannot swap new words in behind old ones. The two
error messages that were English-only now appear in all ten languages. -
A review and a chat message in one transaction could cost a notification.
Both used the transaction's id to recognise their own duplicates, so the second
one looked like a copy of the first and was silently skipped. Reviews now use
their own key. Chat's key is unchanged, so the protection against being
notified twice for one message still works exactly as before. -
An I2P key handed over as text was saved in a form I2P cannot use. The
key import accepted a key written out as base64 text, checked it, said it was
valid and named the address it hosts. Then it stored the text, not the key
itself. Restored to i2pd, that hosts nothing and logs nothing about why. It now
stores the key itself. Tested against a real i2pd: the restored key hosts the
address the import promised. A file that holds only the public address still
gets refused with nothing saved, as before.If you imported an I2P key as base64 text on 1.17.15, it was stored that
way. Exporting it now writes the binary form i2pd can use, and prints a
note saying so. To store it in that form too, import the exported file again. -
Your instance now checks its own Tor, I2P and Lokinet directly. It used to
find out that one of them was down only when a message failed to go through
it. It now checks at startup and every minute. For Lokinet it asks the name
that always means "this machine", which proves Lokinet itself is working. That
settled a question the instance could not answer before: when a Lokinet peer
cannot be reached, is it that peer or our own Lokinet? Because it could not
tell, a peer whose Lokinet address had died kept its place in the list of
peers each message is sent to, indefinitely. Now it is recorded as failing
and makes way for peers that work.
Found by a final review, after the release was cut
1.18.0 was fully tested and ready to publish when five more reviewers read it,
each taking one part: the receiving side, the sending side and its transports,
the database and key handling, the browser and relay, and the upgrade and
operator scripts. None of them had seen the code written, and each was asked
to look for what the fifteen earlier rounds had missed. They found forty-four
things. Each finding was reproduced or traced before it was fixed. Each code
fix has a test, and each test was first run against the unfixed code and seen
to fail. What they found, most serious first:-
Any chat message ever sent could be replayed as new. Every chat
transaction, signature included, is public on the chain. An instance checks
a handed-over message's expiry time, but only when the time was written the
usual way. The same time wrapped in a list skipped the check, and the
signature still verified. Anyone, with no key, could take an old message
between two people and hand it to an instance again, as often as every
twelve minutes. It arrived stamped as new: at the bottom of the open
chatroom, with the unread badge lit and possibly a phone notification. An
old "pay to this address" message could become the newest message in a
trade. An instance now accepts only a transaction in exactly the shape the
chain writes. It rebuilds a clean copy from the checked fields and uses that
copy for everything after, not the one it was sent. -
A posting key the owner had disowned could keep working. Your instance
checks handed-over messages against the posting key it holds for the
sender. Two kinds of account change did not update that key. One emptied the
posting keys, which is a natural way to stop a leaked key. The other needed
two keys to sign together. Either way the old single key stayed trusted for
fast chat after the chain stopped accepting it. A key is now trusted only
when it can sign alone, and any change to an account's posting authority
updates the stored key, clearing it if no single key qualifies. -
Keys were confirmed against a single chain node. This release confirms
every posting key recorded before it, and that confirmation believed the
first node that answered. A node that was out of date could confirm a key
the owner had already replaced, and it would stay confirmed. Keys are now
confirmed only when two nodes agree. Four related gaps are closed with it:- a key missing from a node's answer is treated as no answer, not as "no
key"; - a failed confirmation is retried in the background, starting after a
minute and backing off to half an hour, instead of waiting for the next
restart; - a database restored from a snapshot has every key marked unconfirmed, so
this node checks them itself instead of trusting the publisher's marks; - while this node's indexer is more than 200 blocks behind the chain, fast
chat asks the chain instead of trusting the stored key.
- a key missing from a node's answer is treated as no answer, not as "no
-
One instance could stop your instance checking its peers. When a peer
on Tor, I2P or Lokinet answered with an error and a large page, the
connection was never closed and the probe waited for it forever. From then
on no peer was checked again until a restart, so the directory and fast-chat
rankings froze. An error answer's body is now discarded, and closing the
connection has a time limit. -
One registration could switch I2P off for fast chat, everywhere. An
instance decides its own I2P is down when two different addresses fail in a
way that points at its own router. Both addresses could belong to one peer,
which can publish two. So a single registration with two dead I2P names made
every instance that tried them drop I2P for a minute at a time, moving other
peers' messages to Tor or the clearnet. It now takes two different
instances. The same review found that peers listed without a check (a
clearnet peer seen from a hidden-only node, for example) were ranked with
the best-checked peers for fast chat. They now rank below every peer that
was actually checked. -
Tor-only servers were asking their internet provider about Lokinet.
Lokinet has no proxy, so a.lokiname is looked up with the system's DNS.
On a server without Lokinet, which is every server Morphit installs, that
DNS is the internet provider's. The indexer asked it forlocalhost.loki
once a minute, and for every peer's.lokiaddress on each warm-up and
send. That tells the provider the server runs Morphit. Lokinet is now off
unless you publish a Lokinet address or setMORPHIT_INDEXER_LOKINET=on.
The same review closed three more lookups a tor-only server should never
make:- with its Tor or I2P proxy switched off, the indexer handed
.onionand
.i2pnames to the ordinary connection, whose first step is a DNS lookup;
they are now refused; - a
.onionname of the wrong length was not treated as hidden, and went to
DNS; it is now refused; morphit-ops doctorfetched the node's own.onionaddress directly
before trying the local port, which sent the node's own onion name to the
provider's DNS; it now goes straight to the local port for a hidden
address.
- with its Tor or I2P proxy switched off, the indexer handed
-
A trading partner could put words on your screen next to a blockchain
proof they did not match. Your browser merges two copies of the same
message: the one delivered fast, and the one read from the chain. It matched
them on a tag chosen by the sender, and never compared what they said. A
sender could have one message delivered fast and never recorded, then record
a different message under the same tag. Your screen, and the PDF export,
then showed the first message's words with the second message's "Blockchain
proof". Two copies are now merged only when their encrypted contents are
identical. -
Leaving a chat lost the "not sent — tap to retry" state. A message the
network accepted but that never reached the chain is marked failed after a
while and offered for resending. That state lived only while the
conversation stayed open. Leave and come back, and the message either
restarted its wait or quietly disappeared. While the tab stays open, it is
now kept until the message is confirmed or resent. (In the setting that keeps
no copy of your own messages there is nothing to restore, so nothing
changes there.) And before marking anything failed, the page now
asks the chain for the transaction. A slow indexer used to make a message
already on the chain look lost, and resending it put it there twice. -
On a Tor-only relay you could not remove your push subscription. Such a
relay sends no web push, and it refused every push request, including
"unsubscribe". The stored link between your account and your device's push
address stayed, with nothing ever pruning it. Unsubscribing now works
whether or not the relay sends push, and the settings page does it for you
when it learns push is off there. -
A message the chain had already accepted could be reported as rejected.
If a chain node took a transaction but the answer timed out, the next node
answered "duplicate", and that was reported as a rejection, which invites a
second copy. For chat, "duplicate" now counts as sent. -
One slow chain lookup could hold up every incoming message. When a
sender's key needed re-reading from the chain, the single worker that checks
incoming messages waited for it, for tens of seconds if the chain nodes were
slow, while more messages were accepted behind it. Those checks now run
beside the queue, at most sixteen at once and fifteen seconds each. A
message that is not checked in time goes by the chain as usual. -
Smaller things on the receiving side. One malformed entry turned a whole
batch into an error and dropped the good entries after it; it now costs only
that entry. Queued messages kept whatever extra data the sender attached, up
to about 128 MB for a full queue; only the checked copy is kept now. Two
deliveries of the same message arriving together could both announce it;
the second is now suppressed before either waits on anything. -
Smaller things on the sending side. A warm-up read a peer's reply with
no size limit; it now stops at 64 KB. A connection to a peer was closed by
the peer's web server after 75 seconds idle, but renewed every three minutes,
so every renewal paid for a new tunnel. The frontend now keeps idle
connections for five minutes and says so in its replies. Shutdown now has a
time limit even with a warm-up in progress. The Tor connection code could
report one failure twice. A failure while greeting your own proxy was blamed
on the peer. A proxy that closed early was waited on for the full twenty
seconds. A proxy reply of an unexpected length could leave stray bytes in
the connection. And bytes that arrived with the proxy's reply could be lost. -
morphit-ops doctorpassed a broken push index. If an attempt to create
the push queue's unique index failed, Postgres left an index with the right
name that it does not use. Doctor checked only the name, so it reported a
healthy schema while every chat and feedback push failed to queue. It now
checks that the index is valid and unique, and the repair steps in
OPERATIONS.md drop a broken index before rebuilding it. -
A very old database could not upgrade. Migration 38 builds an index on
a column that is only added after all migrations have run. A database old
enough not to have that column stopped at migration 38 on every boot. The
migration now adds the column first if it is missing. -
The latency probe could report
PASSfor a peer it never reached. The
script you run after this release to measure real Tor and I2P timings
counted an error page from your own I2P proxy as a fast answer. It also
measured I2P on a different path from the indexer's, could run its three
"cold" Tor samples on one existing circuit, read settings the indexer does
not use, and, given a clearnet address, connected to it directly from your
server. All fixed; see "Measuring real fast-chat latency to a peer" in
OPERATIONS.md for what it does now. -
The relay fix for Tor-only nodes now proves itself. On a node that uses
no clearnet, the upgrade moves the relay onto hidden services only. It used
to announce "The relay now reaches the chain over hidden services only" as
soon as it had edited the file, before any restart and without checking. It
now restarts the relay and reads the relay's own health report. If the relay
does not come back, it restores the file and restarts the relay on its old
settings. It also runs first among the upgrade's self-repairs, each of which
can now fail without stopping the others. -
Automatic IPFS release pinning pinned nothing. The pinner, the IPNS
rebroadcast, their setup script and the desktop upgrade notice all asked for
the release on port 8088, where nothing listens. The indexer is on 8081.
Every run failed quietly and the timer looked healthy. They now use 8081,
and the pinner and rebroadcast also try the indexer's Docker bridge
addresses. -
The release monitor could never report a release. Its service blocked
memory permissions Node needs to run. It looked for its tools from the
wrong directory and went to the npm registry for them. When it did report,
the version fields came out empty. And on a hidden-only node the check
downloaded the whole release over Tor to learn one version number. All
fixed: it uses the install's own tools, and a hidden-only node reads the
version from its own indexer. UPGRADING.md also named an Ansible role for
it that does not exist; it now gives the real steps. -
Two old repair scripts could undo newer work.
ops/apply-relay-fix.sh
andops/apply-federation-fix.shpatched the indexer's code on a live
server until a fix shipped. Both fixes shipped long ago, but the scripts
still ship with every release. Run today, the first would replace a function
with its old version and drop the rule that keeps a hidden-only node from
looking up its relay's public name. Both now see that their fix is already
installed and stop, changing nothing and restarting nothing.
Three smaller findings were looked at and deliberately left for later. They are
recorded in the audit with the reasoning:- the chat hand-off to a clearnet peer does not use the probe's check against
private addresses. Certificate checking stops anything being delivered to
one, so the exposure is a connection attempt; - an
https://hidden-service address is dialled as plain HTTP on port 80; - a cached key correction is dropped a few milliseconds before the database
change that replaces it is committed.
Found by the v1.18.0 deep-deep
This release was renumbered from 1.17.16 to 1.18.0 because of how much it
changes. Before publishing it, six more reviewers went over the whole system,
not only this release's changes. Each took one area:- the fast-chat path;
- keys, the database and the upgrade path;
- every outbound connection and the transports;
- the browser and the relay;
- installing, upgrading and the release pipeline;
- the marketplace itself, attacked as a whole.
They worked through each area threat by threat (spoofing, tampering,
repudiation, information disclosure, denial of service, elevation of
privilege), then attacked it the way a hostile peer, operator, counterparty or
RPC node would. About sixty findings came out. All are fixed except the three
named at the end of this section. Each fix has a test that was run against the
unfixed code and seen to fail first. The most serious, first:-
A single hostile chain node could take over a new node during fast
sync. A new node can start from a published database snapshot instead of
replaying the chain. It found the snapshot by asking ONE chain node for
@morphit's history, checked no signature, and fed the downloaded file to
the database tool, which also runs shell commands written into the file. One
dishonest node among the twenty public ones could therefore run commands as
root on any node being set up. Now:- the snapshot record must be agreed by two independent node operators;
- it must be signed by @morphit's pinned key;
- the file is refused if it contains any database command outside its data;
- the restore runs as one transaction, so a failed restore leaves the
existing database untouched; - any database code the snapshot adds that Morphit itself does not create
is removed.
-
Published snapshots contained people's push subscriptions. The relay
shares the indexer's database, and the export copied all of it: every
browser push subscription, which links an account to a device's push
address, went out in a file published on IPFS. So did the relay's own
payout queue. Snapshots now carry only what the chain can rebuild, and a
restore wipes those tables if an older snapshot still carries them. -
Registering a crafted address found out where Tor-only nodes are. A node
that uses no clearnet decided whether an address was "local" by how its
name started. So an instance registered ashttps://10.something.attacker
was looked up in the internet provider's DNS and connected to from the
node's home address, every time a user sent a chat message. Only real IP
addresses count as local now. Registration refuses names made to look like
one, and a hidden-only node never gives a peer a clearnet address at all. -
Tor-only nodes still used the clearnet in several places:
- the
morphit-opsmenu checked for new versions and the relay's balance
over the clearnet; registerand payment-method changes were broadcast through clearnet
chain nodes, putting the node's onion address and home IP in the same
request;- the first-online helper tested clearnet nodes every five minutes;
- the IPFS daemon joined the public IPFS network from the home address and
announced itself as a host of Morphit releases, which anyone could list; - each upgrade's seeding step downloaded a file from git.agorise.net.
All of these now go through the node's own indexer or its hidden
transports. IPFS runs with no public network at all, sending no telemetry
and making no DNS lookups, and it still serves releases over the node's
onion and I2P addresses. Existing nodes are switched over by the upgrade,
which then checks that IPFS came back. - the
-
"Two nodes agree" could mean one operator. Keys and other trusted
answers must be agreed by two chain nodes. Every privacy-network node is
listed twice, once as an onion and once as I2P, so one operator answering
on both counted as two. Agreement now counts operators, not addresses. The
chat fast path's key re-check, which asked a single node, now asks for
agreement too. -
A local program could decide what an upgrade installs.
morphit-ops upgraderuns as root and took the release's fingerprint and download
location from whatever answered first on the indexer's port. Any program on
the machine that got there first chose what root would install.- The upgrade now reads the node's settings from its own root-owned
configuration. - It asks only the indexer address configured there, and checks that the
process answering really is the Morphit indexer service. - A node with a non-standard setup can set
MORPHIT_UPGRADE_TRUST_LOCAL_INDEXER=1to skip only that last check.
- The upgrade now reads the node's settings from its own root-owned
-
A mirror could downgrade a node. Any older signed release was accepted
as an "upgrade". Now:- an upgrade must be strictly newer (use
--allow-downgradeto go back on
purpose); - the unpacked release must say it is the version that was asked for;
- a known release fingerprint must match even when a signature checks out;
- a signature that is present but wrong refuses the upgrade.
The release pipeline also pins its one remaining unpinned build step to an
exact commit, and checks the tarball, fingerprint and signature again before
publishing. - an upgrade must be strictly newer (use
-
The relay's per-address signup limits could be skipped with one header.
Each signup costs the relay about 102 BLURT. The relay believed whatever
address a visitor wrote in a request header, so one person could look like
a new visitor on every request. That got around every per-address limit, up
to the daily ceiling of about 5,100 BLURT. The public firewall trusted the
same header from anyone. The relay and the AI-agent endpoint now use only
the address the proxy itself saw, and the proxies no longer pass the
visitor's claim along. Existing BunkerWeb installs are corrected by the
upgrade, which checks the running firewall afterwards. -
Anyone could get free "verified" Bitcoin-fee listings. When the block
explorers reported a fee transaction as not existing, the order waited as
"pending" instead of "unpaid". Two accounts, one of them the poster, could
then vouch for it and make it verified, as often as they liked. Now:- "not found" by the explorers means unpaid;
- the poster cannot vouch for their own order;
- vouchers flagged as linked to the poster don't count;
- the indexer re-checks pending and vouched-for orders with the explorers,
whose answer always wins.
-
Someone else's Bitcoin fee payment could be claimed first. The fee
address is public, so a bot watching Bitcoin's pending transactions can
claim a payment before the person who made it posts their order. Before,
the real payer's order then silently disappeared. Now it appears, marked as
a fee someone else already claimed. Tying each payment to the person who
made it needs a change to how Bitcoin fees are paid, and it is being
designed for a later release. Until then, post the order right after paying,
or pay the fee in BLURT. -
Privacy-only nodes could be told wrong prices. A privacy-only node takes
its price from the median of its peers' prices, and a peer is free to set
up. Enough fake peers could move the median anywhere: users would be quoted
a listing fee a hundred times too small (and lose it as "underpaid") or ten
times too large. Now:- each operator counts once;
- the result must stay within 15% of the fee price pinned on chain;
- the page never quotes outside that range.
-
Fast chat could be jammed or misused:
- the send endpoint forwarded messages the chain would reject, however
large, to every peer before the chain had seen them; - a quick-notification shortcut skipped the check that a message's order
tag is real; - junk signatures could use up the key re-check allowance for everyone;
- one sender could fill the replay memory and lock everyone else out for
minutes; - messages that arrived while a key was being re-checked were dropped;
- a sender-chosen time decided whether an order still counted as live.
Each is fixed: only what a peer would accept is forwarded, one signer can
only use its own share of the memory and the queue, and gates use the time
a message actually arrived. - the send endpoint forwarded messages the chain would reject, however
-
In the browser:
- a profile picture could cover the whole page with its own drawing. That
could be a fake "payment verified" banner in the middle of a trade.
Pictures are now shown as images, which cannot escape their frame; - signing out left notifications for that account arriving on that
browser; - the idle lock left an open conversation readable;
- a message that reached the chain but that indexers refused looked
delivered forever. It now says so; - the chain lookup could wait forever.
- a profile picture could cover the whole page with its own drawing. That
-
Reputation and the directory:
- completed trades counted even when no listing fee was paid;
- the free first-purchase listing could be cited in a review and trigger
the relay's welcome bonus; - whoever registered a web address first owned it in the directory, so a
squatter could get the real operator marked as an impostor. Ownership now
follows the account the address itself names; - look-alike operator tags such as
m0rphitormorphit-iowere accepted.
The network, themorphit-ops registercommand and the registration page
now refuse them.
-
Operator tools:
- the snapshot check always reported a false "quarantined";
- a restore under a different database user failed after dropping the
database; - chain nodes could redirect the indexer and relay, and send replies of any
size; - the relay fix for Tor-only nodes could undo itself on a slow Tor start;
- a rollback did not restore the relay's settings;
- exporting a privacy key could write into a file another user had
prepared; - release-mirror file names were not checked;
- the I2P proxy closing mid-handshake could jam I2P delivery until a
restart; - the zero-clearnet claim ignored the Matrix alert bot;
- the AI-agent order search always came back empty;
- the release monitor's failure hint pointed at the wrong place on
privacy-only nodes.
Left for a later release, on purpose:
- tying Bitcoin fee payments to the payer (above);
- a fee or rate limit on operator registrations;
- a reputation requirement for price-reporting peers.
The first upgrade to 1.18.0 is still run by the previous version's upgrade
program, so a few of these protections take effect from the next upgrade on.
OPERATIONS.md §25b lists which.Found by the release's first CI run
- A snapshot export could leave out rows and still report success. The
export writes one table in two steps: the database dump, then that table's
shared rows added by a second tool. If the second tool failed, its failure was
swallowed and the snapshot was published without those rows. Every step now
has to succeed, or the export stops and says so. A new test runs the export
with each database tool broken in turn and expects it to refuse. - "Your psql is too old" was the answer to three different problems.
Restoring a snapshot needs a recentpsql(August 2025 or later). When psql
was not installed at all, or could not reach the database, the restore still
said it was too old. It now says which of the three it is, so the fix it
suggests is the right one. It still changes nothing in all three cases, and it
never prints the database address (which can hold the password). - The test machine itself had an old psql. Its restore tests died at that
check before they read the snapshot. Some of them looked like passes, because
"refused, and nothing changed" is also what those tests expect from a hostile
snapshot. The test machine now installs the current PostgreSQL 16 client tools
from PostgreSQL's own package repository. Their signing key is checked
against its published fingerprint before anything is installed. Before any
test runs, the machine also proves that psql accepts the safety mode the
restore relies on. A check on the test setup keeps all of that in place, and a
new test fails outright on any machine whose psql lacks it.
Also fixed
-
Starting a chat now works on a privacy-only instance. Opening a
conversation with someone for the first time checks their chat key against the
blockchain before trusting anything your instance says about it. Your browser
gave that check fifteen seconds. Your instance is allowed a full minute for the
same lookup, because reaching the chain over Tor or I2P means building a
circuit or a tunnel first — so the browser kept giving up while the server it
was waiting on was still perfectly on schedule. Worse, "no answer yet" was
treated as "the blockchain says this person has no key", which is what Morphit
reports when a key looks tampered with. So a slow connection told you your own
instance might be fabricating data, and advised you to go and use a different
one — the least useful advice possible if you are using a privacy-only instance
because the others are blocked where you are. The wait now allows for the
slower route, and a connection that does not complete is reported as exactly
that: try again in a moment. -
The rest of the site works there too. That timeout was not the only one
sized for an ordinary connection. The chain lookup every message send waits
on, the account-name check, the profile fetch behind display names and
avatars, the fee figures, account creation, the release check and the version
poll were all capped at between five and thirty seconds — often less than a
fresh Tor or I2P connection spends simply getting established. Each then
reported the same underlying timeout as its own unrelated problem: a key that
couldn't be verified, an instance that looked unreachable, avatars replaced by
patterns, fees quietly falling back to defaults. Rather than adjust them one
at a time, requests that cross a privacy network are now given the time that
route needs, everywhere, automatically. Ordinary instances are unchanged and
no slower. -
A stalled connection now fails instead of hanging forever. Timeouts
covered only the moment of connecting — once a reply began arriving, the rest
of it had no time limit at all. A connection that died halfway through, which
is an everyday occurrence on Tor and I2P, left the page waiting with no error
and nothing to retry: a spinner that never stopped, or a message stuck as
sending. The limit now covers the whole exchange. -
Comparing two instances no longer invents differences. The comparison page
fetches each instance's orders and shows what one has and the other does not,
so you can tell whether orders are being hidden from you. But each instance
returns at most a hundred orders, newest first — and a busy instance's hundred
therefore reaches less far back in time. A few genuinely new orders at the top
push an equal number off the bottom, and those pushed-out orders, sitting on
both instances quite happily, were listed as missing from one of them. They
always looked old, because the bottom of the list is where old orders are. The
comparison is now limited to the range both instances could fully report on,
matched the same way the orders themselves are ordered so that nothing inside
that range is skipped, says plainly when it had to narrow the range, and
states its conclusion outright instead of leaving you to read three numbers.
Orders genuinely missing from one instance are still reported — including one
sitting right at the edge of the range, which is where a hidden order would be
easiest to miss. -
The Settings page no longer loads blurred. The language filter opened its
menu whenever the field received focus, and an open menu dims and blurs the
page behind it. Firefox restores focus to wherever it was when you reload — so
once you had used that field in a tab, every later visit re-opened the menu and
blurred the page before you had touched anything. Clicking anywhere cleared it,
which made it look like a rendering fault rather than a real one. Menus now
open when you press, type, or use the arrow keys, and never on focus alone. Two
other filters had the same fault and are fixed with it. -
Privacy-network nodes are no longer offered to browsers that cannot use
them. A secure page is not permitted to fetch anything over plain HTTP, and
the Tor addresses Morphit tried first are plain HTTP — so on a normal instance
every visitor's browser started by making two requests it was always going to
refuse, and logged an error for each. They are no longer offered where they
cannot work. Visitors on Tor are still pointed at the instance's own.onion
address, where the privacy path applies properly and no ordinary connection is
made at all.
Notes
-
No protocol/consensus change. Everything here is operator-facing or in the
browser; nothing about what Morphit publishes to the chain has changed. The
direct instance-to-instance chat delivery is an addition alongside the chain,
not a replacement for it. -
One thing to configure — and
morphit-ops doctornow checks it for you.
/v1/federationcarries a batch of chat messages from a peer and needs a
larger body limit than the read default:MORPHIT_INDEXER_MAX_FEDERATION_BODY_BYTES
(256 KB, documented inops/env/indexer.env.example) and a matching
client_max_body_sizeon that location in your reverse proxy, or the proxy
rejects the batch before the indexer ever sees it.This matters most if you are upgrading, because your existing proxy config
is by definition the old one. The shipped configs inops/nginx/have the new
block; a config you copied and edited months ago does not. And the failure is
invisible in every way that matters — nothing errors, single messages keep
working, and chat only goes slow when your instance is BUSY, which is when you
are least likely to be reading logs.So
morphit-ops doctornow sends an 8 KB batch-shaped body to your own public
origin — above the 4 KB read default, far below the federation cap — and tells
you whether it got through. The body is deliberately not a real transaction, so
nothing is delivered and nothing is stored; the indexer refusing it on its
contents is the PASS, because that proves the bytes arrived. It goes through the public origin
deliberately: probing localhost would skip the proxy and report all-clear on
exactly the box that has the problem. On a privacy-only instance whose own
address the host cannot resolve, it says it could not check and gives you the
command to run by hand, rather than guessing. -
Database migrations v60 and v61 run by themselves on upgrade:
- v60 only corrects the description stored on one column
(push_pending.source_trx_id), which said something untrue; - v61 adds one column with a default (
accounts.posting_key_reconciled).
No existing data is rewritten and no table is rebuilt.
- v60 only corrects the description stored on one column
-
After upgrading, the indexer confirms every stored posting key against the
blockchain once, in the background. It reads 100 accounts per request.
When it finishes, the startup log showsposting_key_backfill_donewith a
reconciledcount. Until then, chat from an account whose key hasn't been
confirmed yet may take the slower blockchain route. -
The relay has three new settings, all with working defaults:
MORPHIT_RELAY_HIDDEN_RPC_ENDPOINTS(the public Tor and I2P blockchain nodes,
the same list the indexer uses),MORPHIT_RELAY_TOR_SOCKS(127.0.0.1:9050)
andMORPHIT_RELAY_I2P_HTTP_PROXY(127.0.0.1:4444). Documented in
ops/env/relay.env.example.- If your indexer already uses no clearnet,
morphit-ops upgradefixes the
relay for you. If your relay had no endpoint list of its own (which is
what every Tor-only install had), the upgrade gives it none on clearnet.
It also gives it the indexer's hidden endpoints and proxies, and says so.
It writes a marked block at the end of/etc/morphit/relay.env(or
/opt/morphit/morphit.envif there is norelay.env), which you can
remove to undo it. - If you set the relay's list yourself, the upgrade leaves it alone. It
warns you instead and names the line to change:MORPHIT_RELAY_BLURT_RPC=
(empty). - A hidden-only relay sends no push notifications, because every push
service is a clearnet server. It logspush_disabled_hidden_onlyat startup
so that is never a surprise. Subscriptions your users took earlier are kept,
in case push comes back. Meanwhile their queued pushes are expired and
cleared on the usual schedule (push_queue_janitorin the relay log). - The relay now reports
hidden_onlyon its/v1/health. The instance's
"Zero use of clearnet internet" claim requires that to be true. If the relay
has never answered, the claim is not made. If it answered and then went
down, the last answer stands, because it describes configuration. morphit-ops edit→ RPC now updates the relay's list along with the
indexer's. It used to change only the indexer's.
- If your indexer already uses no clearnet,
-
A hidden-only indexer now always runs the peer price checker, whatever
its setting says. Without it, a hidden-only instance has no federated prices. -
Whether your own Tor, I2P and Lokinet are up is in the local health output
underfastpath→federationDiagnostics→localTransports.nullmeans
you do not run that one.falsemeans it did not answer, and that network
shows innetworksDown. -
For anyone working on the code:
npm run lintat the top of the
repository is now a real check, and it runs with the tests. It fails on any
new or changed file that is not formatted. Files that were unformatted before
are listed inscripts/prettier-ratchet-baseline.txt; that list only ever
shrinks. Running prettier anywhere in the repository now uses the project's
own style. Before, it used prettier's defaults outsideapps/. -
Nothing else to configure for fast chat. Instances discover each other through
the directory they already share, and use whatever Tor or I2P proxy the
instance is already configured with. An instance running an older version simply does not
receive hand-offs, and its users fall back to chain delivery as before. -
To see whether it is working on your own node:
curl -s localhost:3000/v1/health -H 'X-Morphit-Local-Health: 1'and look
underfastpath→federationfor how many peers are known, how many routes
are warm, how many hand-offs have been delivered or failed, and how many
travelled together; and underfastpath→federationIntakefor what is
waiting to be checked and whether anything is being dropped. -
Notifications to a phone or a closed browser were already checked and are
unaffected: that queue is drained every two seconds, well inside the target. -
A message is no longer delivered twice. It could previously arrive once from
the fast path and again when the instance read that block from the chain a few
seconds later. Nobody saw double, because the app already collapsed the pair —
but it was wasted work and double the traffic on the connections where that
costs most. Failures are
logged with the reason, not just a count. -
Both users having a fast VPN does not change the arithmetic above, and it is
worth knowing why: an instance with no clearnet address has no clearnet address
for anybody. Their browsers still reach it over Tor or I2P. A good connection
makes those circuits healthier and faster — it does not skip them — so the
three privacy hops a message crosses are the thing to measure, which is what
the probe script does. -
Nothing needs reconfiguring. The longer waits apply automatically, and only to
requests that actually travel over Tor or I2P. -
If you saw a red "chat key looks tampered with" warning on a
.onionor.i2p
instance, it was almost certainly this bug rather than a real tamper signal.
That warning now means what it says. -
This release was reviewed by fifteen independent passes before it shipped —
none of which had seen the code being written — and a good deal of what is
under "Fixed" came out of them, including two defects in this release's own
tests. The full record, including what those reviews checked and found sound,
is indocs/AUDIT-v1.18.0-FASTCHAT-DEEP-DEEP.md. The design decisions are in
docs/adr/0052-federated-fast-chat-delivery.md. -
The fixes were reviewed too. The first review's remediation added a lot of
new code, and nobody but its author had seen it — which is the same condition
that produced the first round. So it got the same treatment, and that second
pass found two more serious bugs, both introduced BY the fixes: a self-exclusion
that would have cut the canonical instance out of every community instance's
peer list, and an anti-spam cap keyed on the victim rather than the sender, so
one hostile account could have denied a real buyer's first contact. Both are in
docs/AUDIT-v1.18.0-FASTCHAT-DEEP-DEEP.mdunder "Round two", along with one
fix that was made and then deliberately reverted. -
The safety rule this whole feature rests on is now checked against a real
database, not by reading the code. A chat message pushed between instances
is shown to you, never stored — the blockchain remains the only record of
what was said. That is what makes it acceptable to display a message before
the chain has confirmed it: the worst a forged push can do is show something
that quietly fails to appear a minute later.Until now that rule was verified by scanning the source for anything that
writes. Useful, but not the same thing: it proves no write was typed in those
files, where the rule says nothing anywhere should change. Those came apart
once already during this release, when a fix corrected a stale key and saved
it — spotted by reading, not by a test.There is now a test that photographs every table, pushes a real message
through the real code against a real database, and checks that the message
arrived and that not one row anywhere is different. It does the same for a
forged push, because a stranger who cannot prove who they are should not be
able to cause the smallest write. Both were confirmed to catch a deliberately
planted one. -
Review notifications are now held to the same rule, and were not being
checked at all. A review notifies you twice by design for exactly the same
reason a chat message does — once when your instance sees it, once when the
chain makes it final — and the same mechanism collapses the pair. That
mechanism had never been run against a database. It works, and now there is a
test saying so, including the interleaving that broke the chat version in
v1.5.5.The reason it went unchecked is worth saying plainly: the test written for the
chat half carried a note claiming review notifications were single-path and
needed no such protection. That was simply wrong, and a wrong note beside a
passing test is what stops the next person looking. -
And so is the one-message-one-notification rule, which has been broken in
production before. In v1.5.5 a notification was deleted the moment it was
sent, so when the slower of the two paths arrived a minute later there was
nothing left for it to collide with and your phone buzzed twice. The fix was
never to the uniqueness rule — that was always right — but to how long the
record survives, and that is a property no amount of reading the code can
check. It is now driven against a real database, including the exact
interleaving that broke it: notify, deliver, then notify again.The first version of that test passed against the old bug, which is worth
saying out loud. It counted rows, and the count is one either way — one
surviving record when it works, one freshly-created record when it does not.
It now counts what is still waiting to be delivered, which is what the phone
actually reacts to, and replaying the v1.5.5 behaviour fails it. -
Validation. All 15 workspaces typecheck clean, and svelte-check reports
861 files with no errors and no warnings. 2,700 unit tests pass, plus
237 integration tests against a real PostgreSQL. That suite was once
recorded as runnable only on release hardware; it turns out to run here. The
smoke battery (704 runners, 22,011 scenarios) was run three times
end to end with identical results. It now includes a lint and formatting gate
that actually runs.All 18 harnesses pass, 358 checks between them. Eight are MUTATION
harnesses, and the number worth quoting is theirs: 216 deliberate bugs
introduced into the source, 216 caught. Two of the execution harnesses also
plant bugs of their own (8 more, all caught), so the total is 224. The
seventeenth harness is the first to need a real database, because what it
guards is SQL and a migration. The eighteenth, added in the final review,
runs the latency probe against stub proxies and a stub peer.(At the cut these figures were 2,466 unit and 159 integration tests, 17
harnesses, 325 checks and 197 mutations. The two reviews that followed added
the rest. The mutation count stayed at 216 through the deep-deep: its fixes
moved 18 mutations' targets, and each was re-aimed at where its property now
lives and seen caught again, rather than dropped.)Two earlier drafts of this note got that number wrong, and both corrections
are left visible rather than tidied away, because a number nobody can
reproduce is worse than no number. The first said "167 deliberate bugs" —
that was the total check count, which includes the nine execution harnesses
that verify behaviour without mutating anything. The second said 102, which
was not the count of anything: the harnesses held 96 mutations at that point.
The figure above was taken by running all eighteen and counting the
mutations each one reported, not by searching the harness sources. Counting
the sources has been wrong every time it was tried.Every mutation added in this release was watched to fail against correct code
first, because a test never seen to fail is not a test — and five of them
SURVIVED on their first attempt, which is exactly what they are for. Two were
real gaps in the second round's tests. Two more were gaps in the smoke that
covered the transport work, where unit tests had the property and the smoke
did not — and a unit test elsewhere is not an answer, because a harness's job
is to prove the SMOKE is a test. The fifth was the new transport harness
reporting five mutations as unguarded when in truth they had never been
applied; that one is written up in the audit, because a mutation harness that
can be wrong about whether its mutations reached the code is a harness whose
green means nothing.The last rounds added two more of each kind:
- A survivor. Flipping the new migration's default passed every test,
because the test database is built from the full schema and the migration
never ran there. An upgrading node gets the column only from the migration,
so a case now runs the real migration on a database taken back to before
it, and the flipped default is caught. - A mutation that became equivalent. The I2P import mutant stopped
meaning anything once export learned to repair keys stored the old way. The
exported bytes were right either way, so that check now keys on the repair
notice instead.
- A survivor. Flipping the new migration's default passed every test,
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-