16 KiB
Phase 3a — Posting relay + account creation · Design
Status: Draft for review, pre-implementation Date: 2026-04-18 Depends on: Plan v1.3, ADR-0002 (live keys policy), ADR-0005 (Phase 3 subphase split)
2026-05-11 forward note (Part 120 audit): This is the ORIGINAL Phase 3a design as it stood on 2026-04-18. The shipped relay differs in two important ways and this doc is preserved for historical context only — do NOT cite it for current behavior:
- Account-creation mechanism. This doc describes
account_create(inline-pay: creator broadcasts the creation transaction with afeefield paid in liquid BLURT) — which is exactly what the shipped relay does as of beta.28. (Historically the relay briefly used the Account-Creation-Token model —claim_account/create_claimed_account— but Blurt disabled both ops at hard fork 2, so the relay reverted to direct inline-payaccount_create. See ADR-0010, as amended 2026-06.)- Repo layout. The "morphit/apps/relay/cmd/…/internal/…" Go-style layout shown in §"Repo layout added in 3a" below was the original sketch. The shipped layout is flatter:
apps/relay/src/{main.ts,api/,blurt/,config/, middleware/,policy/}with nocmd/orinternal/directories. See ARCHITECTURE.md for the current topology.Authoritative current behavior is in the code at
apps/relay/src/api/create.ts+apps/relay/src/blurt/client.tsand the operator-facing recipes in OPERATIONS.md + RUN-A-MORPHIT-NODE.md.
Goals
Ship a first-time-user onboarding flow that:
- Lets a user pick and register a Blurt account name without ever running a third-party signup.
- Never lets the user's owner / active / posting / memo private keys leave their browser.
- Uses a small Node.js service on morphit.io's VPS (the
morphit-relayaccount) to pay the Blurt RC cost of theaccount_createoperation. The relay holds only its own active key, never the user's. - On success, stores the confirmed Blurt account name locally so
the Phase-2 display-name-broadcast path can now actually
broadcast (previously a
BroadcastError { code: 'no_account' }). - Enables the first on-chain read of a
morphit_profile_v1op (i.e. if the user had a name saved from a prior session, fetch it back from the chain on fresh device).
Non-goals (explicitly out of Phase 3a)
- Indexer. No chain-streaming database yet; the relay does not index.
- Orderbook. No order posting, no order reading, no order storage.
- Chat, feedback, reputation, payment-watcher, tor/lokinet/i2p hosting. All later phases.
- WhaleVault / Gravity signing path. Design the relay to accept externally-signed transaction blobs so this lands cleanly in Phase 4, but no code.
Repo layout added in 3a
morphit/
apps/
web/ (Phase 1-2, SvelteKit frontend)
relay/ (Phase 3a, NEW — Node.js/TypeScript service)
cmd/
morphit-relay/
src/main.ts
internal/
api/ HTTP handlers
blurt/ RPC client wrapper
config/ env-driven config + validation
policy/ rate limits, name-allowlist rules
queue/ pending-op queue + retry
package.json
package-lock.json
tsconfig.json
Makefile
README.md
(apps/indexer/ comes in Phase 3b)
ops/ NEW — deployment + systemd units
systemd/
morphit-relay.service
nginx/
relay.conf
env/
relay.env.example
Relay HTTP API
One origin: https://relay.morphit.io. Served only over HTTPS.
CSP on the frontend connect-src adds this exact host.
POST /v1/account/availability
Purpose: cheap existence check so the UI can disable the "register" button for names that are already taken.
Request:
{"name":"sally"}
Response: 200
{
"name":"sally",
"available":false,
"reason":"already_registered"
}
reason ∈ "already_registered", "reserved", "invalid_format",
"too_short", "too_long", "leading_trailing_dash", "bad_chars".
If available: true then reason is omitted.
Rate limit: 60/min per IP (rate limits are per-IP memory; no logging of IPs per Plan v1.3 privacy promise).
POST /v1/account/create
Purpose: accept an unsigned account_create op body from a
user's browser and broadcast it to Blurt, signed with the relay's
own active key. The relay pays the chain's account-creation fee
in BLURT (currently 100 BLURT per account; set by witness
consensus and read dynamically by the relay from
condenser_api.get_chain_properties at each signup) from its
own liquid BLURT balance.
Request:
{
"op": {
"new_account_name": "sally",
"owner": { "weight_threshold": 1, "account_auths": [], "key_auths": [["BLT...", 1]] },
"active": { "weight_threshold": 1, "account_auths": [], "key_auths": [["BLT...", 1]] },
"posting": { "weight_threshold": 1, "account_auths": [], "key_auths": [["BLT...", 1]] },
"memo_key": "BLT...",
"json_metadata": ""
}
}
The client POSTs only the op's body — the four sets of authorities for the new account and the chosen name. The relay:
- Validates the op body.
- Checks its own BLURT balance against the current
account_creation_feemedian from witness consensus. - Wraps the body in a Blurt transaction with correct
ref_block_num,ref_block_prefix, andexpiration. - Adds its own
creator: "morphit-relay"field and the requiredfeefield (read fromcondenser_api.get_chain_properties.account_creation_feeat broadcast time — currently"100.000 BLURT"). - Signs the whole transaction with its own active key
(read from
MORPHIT_RELAY_ACTIVE_KEY_FILEat startup). - Broadcasts via
condenser_api.broadcast_transaction_synchronous.
Why this shape — not "user signs, relay forwards"?
Account creation on Blurt is signed by the creator (the
account paying the fee), not the new account. The creator
authorizes "I am paying 100 BLURT to register sally with these
pubkeys." The new account has no active key on-chain yet so it
couldn't sign anything. The relay's active key is the signing
identity here; the user's role is only to choose the name and
contribute the pubkeys that will govern their new account.
This is security-positive for users: their owner private key never touches any signing flow during account creation. Zero opportunity for an XSS / extension bug to exfiltrate it at this moment.
Validation before signing + broadcast:
op.new_account_namepasses the same allowlist as the availability endpoint.- The four pubkey fields are each structurally valid BLT
keys (parseable; no duplicates across roles, each
key_authshas exactly one entry). weight_threshold: 1and eachkey_auths[0][1]: 1(single-key authorities; multi-sig setups via relay are not supported in 3a).json_metadatais empty or a short string; reject >1 KiB to prevent using account creation as a chain-storage abuse vector.- The account name is not already registered (final chain check, belt-and-braces — availability endpoint already did this but another user could have grabbed the name between availability check and create submission).
- The relay has at least
account_creation_fee * 2BLURT liquid balance (if not, return 503relay_out_of_fundsso the client can offer to retry later and the operator gets a clear signal to top up). - Dedupe within the last minute by the 4-pubkey fingerprint (hash of all four pubkeys concatenated) — an accidental double-submit from a flaky client network does not try to register the same account twice.
Response: 200
{
"status":"broadcast",
"block_num":12345678,
"trx_id":"abc..."
}
Or 4xx with a machine-readable error:
{
"status":"rejected",
"code":"already_registered",
"message":"sally is already taken"
}
Error codes: already_registered, malformed_operation,
name_not_allowed, invalid_pubkey, rate_limited,
relay_out_of_funds (temporary — retry later),
broadcast_failed (with inner error from Blurt surfaced).
Rate limit: 5/hour per IP. Real users only register once; this bucket primarily protects the relay's BLURT balance from drain attacks.
GET /v1/health
Purpose: liveness + readiness for monitoring. Returns the relay's current BLURT liquid balance + an estimate of how many more account creations it can fund, node it's currently using, and uptime. Used by the frontend to decide whether to show a "registration is temporarily unavailable" banner.
Response: 200
{
"status":"ok",
"version":"0.3.0-phase3a",
"blurt_balance":"423.000 BLURT",
"account_creation_fee":"100.000 BLURT",
"creations_remaining":4,
"uptime_sec":12345
}
creations_remaining = floor(blurt_balance / account_creation_fee).
At a balance below 10 creations, the frontend warns the user
that registration might be delayed. Below 3, the relay
rejects new create requests with relay_out_of_funds so a
remaining handful of BLURT isn't spent on a single burst that
might include a name-squatter.
What the relay DOES NOT expose
- No catch-all broadcast endpoint. Future ops (orders, feedback) will have their own dedicated endpoints with op-specific validation. A general-purpose "sign this for me" endpoint is a footgun.
- No account-key-rotation endpoint. That path stays in the user's
browser with
useOwnerKey()+ direct-to-chain broadcast. - No key-escrow, no key-recovery, no email-binding, no social-login.
Client-side — account-registration UI
New route: /onboarding/register-name.
Reached after a user completes the existing /onboarding keygen
- seed-confirmation flow and the seed is backed up. Skipped if the user already has a Blurt account name in local storage (returning user path).
Layout (sketch):
- Headline: "Pick your Blurt handle."
- Single input, rules displayed inline (3-16 chars, lowercase, digits, dashes, no leading/trailing dash, no consecutive dashes).
- Debounced availability check (400ms after typing stops) against
/v1/account/availability. - Submit button: disabled until availability is green.
- On submit:
- Prompt for owner-key password (one time). Password unlocks
the keystore,
useOwnerKey()hands the owner key to a callback. - Callback builds the
account_create_with_delegationop, signs with the user's owner key + generated posting/memo/active pubkeys (keys already exist inFullIdentity; we broadcast the pubkeys and retain the privkeys locally). - Owner key is zeroed the instant the callback returns.
- Signed transaction is POSTed to the relay.
- On 200: local storage gets the account name, session flips
to "registered", user is redirected to
/orderbook.
- Prompt for owner-key password (one time). Password unlocks
the keystore,
- Error states: show the relay's error code translated via i18n. Retry is always offered for recoverable errors.
i18n keys added (translated across all 10 locales):
register.titleregister.introregister.name_labelregister.name_placeholderregister.name_rulesregister.check_availabilityregister.availableregister.unavailable_{reason}(×7 reason codes)register.password_promptregister.submitregister.submittingregister.successregister.error_{code}(×7 error codes)register.rc_low_warning
That's ~30 new keys × 10 locales = 300 new string deliveries.
Release-discovery op
In 3a, the morphit account publishes its first
morphit_release_v1 custom_json op. Schema:
{
"v": 1,
"release": "0.3.0-phase3a",
"hashes": {
"index.html": "sha256-..."
},
"endpoints": {
"relay": ["https://relay.morphit.io"],
"indexer": []
},
"ts": 1713456789
}
Clients can optionally verify this op is signed by the expected
posting pubkey (pinned in $net/config.ts) to detect
impersonation. Phase 3a hard-pins the expected key; Phase 5
adds PGP-signed release manifests as a second factor.
Need from you: the morphit account's posting public key
(safe to share publicly). I'll embed it as
MORPHIT_OFFICIAL_POSTING_PUBKEY in $net/config.ts.
Security review for 3a
- Owner key touches JavaScript heap for <100ms (build + sign op).
Zeroed in the
useOwnerKey()finallyblock. - Relay holds only
morphit-relay's active key. Operator keepsmorphit-relay's owner key offline. - No user IP is logged by the relay in any path.
- Rate limits use in-memory sliding windows. No IP-keyed persistence.
- Relay's Blurt node is the same endpoint rotator the frontend uses; community failover applies.
- CORS on the relay allows only
https://morphit.io+https://*.onion+https://*.loki+https://*.i2porigins. - All endpoints return no body on idle connections (no banners
broadcasting software versions) except
/v1/health. - CSP additions on the frontend:
connect-srcnow explicitly allowshttps://relay.morphit.io. Tightened from the Phase 2https:wildcard once the registry of community relays publishes (Phase 3b).
Phase 3a test plan
Relay-side unit tests for:
- Availability check: accepts valid names, rejects every illegal pattern in the allowlist spec.
- Account-create validation: rejects malformed ops, accepts well-formed ones (mocked RPC).
- Rate limits: 5/hour bucket enforces, resets after window.
Relay-side integration test against a testnet Blurt node:
- End-to-end: generate keys, sign op, post to relay, observe account existing on testnet.
Frontend-side Vitest:
- Availability debounce: typing fast only fires one check.
- Success path: relay 200 → local storage updated → redirect.
- Failure paths: each error code maps to its i18n key.
Phase-2 carry-forwards P2-10 / P2-11 / P2-12 land in this subphase's test suite.
Rollout
- Relay deployed to staging subdomain (
relay-staging.morphit.io), pointed at Blurt mainnet (Blurt has no testnet that mirrors mainnet's RC model closely enough to be worth staging there). - Registration UI deployed to
staging.morphit.io, configured to hit the staging relay. - Maintainer registers 2-3 test accounts, confirms they exist, confirms RC drain is within expectations.
- Production cutover: relay moves to
relay.morphit.io, frontend CSP updates, release-discovery op broadcast. - Tarball handed off to user for git commit.
Open questions
- Relay operator account key rotation. The relay's active
key is what's at risk if the VPS is compromised. Plan is to
rotate it quarterly via
useActiveKey()from a cold device. Documenting the procedure is part of 3a's README. - Relay RC budget. Blurt account creations cost varying RC;
at current chain state one create is ~0.5% of a mid-vested
account's daily RC. We'll size
morphit-relay's vesting based on observed Phase 2 registration interest — for 3a launch, 500 BLURT vested should cover tens of registrations per day with headroom. Maintainer tops up as needed. - Abuse response. A determined attacker can burn through the 5/hour IP bucket from a VPN pool. If we see this happen, the mitigation is to require a small proof-of-work stamp on the create request. Designed but not implemented in 3a.
Design-doc changelog
- 2026-04-18 — stack pivoted from Go to Node.js/TypeScript. The
original design specified a Go relay. Mid-implementation we
discovered no actively-maintained Go library for Blurt exists; the
only option would have been writing the signing + serialization
layer by hand. The Node.js ecosystem has
@beblurt/dblurt— Promise-based, TypeScript-native, documents every op we need, and happens to be what the frontend uses too. ADR-0006 captures the security posture under the new stack (it transfers directly — nothing in the threat model was Go-specific). Architecture, endpoints, error taxonomy, and deployment posture are unchanged. - 2026-04-18 — keygen curve corrected and dblurt package name fixed. Phase 2 shipped Ed25519 keygen (wrong curve for Blurt) and a typo'd package name. Neither could ever have worked against a real chain. ADR-0007 records the fix. The Phase 3a registration flow is the first place in the codebase where this ever mattered, which is how it went undetected in Phase 2.