+ over the QR ceiling — this payload cannot be encoded as a scannable + code. Use copy-to-clipboard instead. +
+ ) : overPractical ? ( ++ past where most phone cameras reliably scan. Dense but technically + valid — copy-to-clipboard is the safer path. +
+ ) : null} +
+ {code}
+
+ + This page isn't a secure context (no `crypto.subtle`) — the SDK below + will throw on every derivation. Load it over HTTPS or from + `localhost`; a plain-HTTP LAN address won't work. +
+
+ Every read this SDK ever makes goes to{" "}
+
+ users/{userId}/…
+ {" "}
+ — and userId is not the
+ wallet address. It's derived, deterministically, from a chain that runs
+ entirely in this tab. No node involved yet.
+
+ {privateKey || "generating…"} +
++ a throwaway demo key kept in this browser's localStorage — reload and + it's still the same identity. Never sent anywhere — see the counter + above. Hit "regenerate" for a fresh one. +
+ {customKeyOpen ? ( ++ sha256(rootEdPub) — full digest, then the surviving 32 hex + characters +
++ {fullHash.slice(0, 32)} + {fullHash.slice(32)} +
++ → users/{userId}/accounts + + {" "} + ← the literal path every read below uses + +
+ ++ same key, a hypothetical alternate scheme registered locally + for this demo (not shipped by the SDK): +
++ bip44 → + {userId} +
++ demo-hypothetical → + {compareUserId} +
++ two different identities, two disjoint sets of synced data. + Pick the wrong one against a real node and every call still + succeeds — it just authenticates as a wallet the node has + never seen. Reads come back empty, not with an error. +
+
+ Nine ways a call into this SDK ends badly — eight typed{" "}
+ OctoBotError{" "}
+ subclasses, plus one you have to catch yourself. This whole panel is
+ offline: click an entry to construct a real instance of that class right
+ here in the tab. Nothing below is a hand-written string — it is the
+ actual .message the
+ constructor produced.
+
+ new {selected.className}(…) — real output +
++ handler +
++ {selected.note} +
+ ) : null} ++ Everything above is the paved path. These five are the ways out of it — + for proxies, for collections this package hasn't wrapped yet, for + skipping I/O you don't want, and for knowing which node you can actually + talk to. +
+ +Custom fetch
+
+ ConnectOptions.fetch{" "}
+ — for proxies, mTLS, or a React Native crypto/fetch polyfill.
+
+ verify: false +
+
+ Skips the connect-time probe.{" "}
+ connectOctoBot() then
+ does zero I/O, and the first real call (e.g.{" "}
+ accounts.list())
+ surfaces any connectivity or auth problem lazily instead.
+
+ Raw documents — client.documents +
+
+ Escape hatch for any collection this package's typed facades don't
+ cover, typed loosely.{" "}
+ client.documents.raw{" "}
+ exposes the underlying{" "}
+ StarfishClient and
+ cap provider directly for anyone building a lower-level integration.
+
+ seedDerivation: 'auto' +
+
+ Tries every scheme currently registered in the derivation-scheme
+ registry — bip44 is
+ the only one this package ships by default — and keeps whichever one
+ the node authorizes. It's really only useful once a consumer has
+ registered a second scheme via{" "}
+
+ registerDerivationScheme
+ {" "}
+ (from{" "}
+
+ @drakkar.software/octobot-client/identity
+
+ ) for a different wallet type; with only{" "}
+ bip44 registered,{" "}
+ 'auto' and{" "}
+ 'bip44' behave
+ identically except{" "}
+ 'auto' costs an extra
+ round-trip.
+
+ Compatibility +
+
+ This demo audits every claim in this section against the real source
+ rather than trusting the README verbatim — the README's compat table
+ is currently stale (it still shows{" "}
+ 0.1.x); the numbers
+ below are the real ones.
+
| + package + | ++ required version + | +
|---|---|
| {pkg} | +{version} | +
+ A TypeScript client for a self-hosted OctoBot trading node: wallet
+ identity, accounts, automations, strategies, and an append-only action
+ queue, over the Starfish sync transport. No cloud account, no
+ registration — anyone running their own node can be your{" "}
+ url.
+
{title}
++ {detail} +
++ runtime requirements +
+
+ WebCrypto (crypto.subtle), fetch, btoa/atob, AbortController.
+
+
+ Native in Node ≥18, every modern browser, Deno, Bun. React Native
+ needs a crypto polyfill this package does not bundle.
+
+
+ license +
+
+ GPL-3.0
+
+
+ worth knowing before week three, not after.
+
+
+ install +
++ hello world +
+
+ A read-only pairing payload carries a bearer credential for one device
+ — never the wallet's private key. Reads behave exactly like a
+ privileged client:{" "}
+ accounts.list(),{" "}
+ automations.list(){" "}
+ pull straight from the node. Every write —{" "}
+ create,{" "}
+ update,{" "}
+ delete,{" "}
+ refresh,{" "}
+ stop — takes a
+ different path: it builds the action(s) locally and hands back a{" "}
+ ProposedAction{" "}
+ instead of sending it.
+
+ This scoping is enforced client-side today, not by the node. The node
+ currently authorizes every collection by identity alone —{" "}
+
+ connectReadOnlyDevice()
+
+ 's write methods simply never call the node's append endpoint on this
+ session's behalf. Treat this payload as "read, and propose", not yet
+ as a boundary the node itself checks.
+
+ mint a read-only pairing +
++ the same url and wallet as the connect panel above — never sent + anywhere, only used locally to sign the pairing. +
+ {mintError ? ( +{mintError}
+ ) : null} + + {pairingPayload ? ( ++ mint a pairing above first — this tab previews the same proposal built + by a connected read-only client. +
+ ) : connectedError ? ( ++ {connectedError} +
+ ) : connectedLoading && !activePayload ? ( ++ building proposal… +
+ ) : activePayload ? ( +
+ accounts.create() and{" "}
+ automations.create(){" "}
+ don't make an RPC call that finishes when the promise resolves. They
+ append an action to the node's queue and hand back an{" "}
+ ActionHandle the
+ instant that append happens.{" "}
+ settled() only lets you
+ watch what the node does with it afterward. A caller who reads the
+ resolved promise as "it's created now" is already wrong — and a caller
+ who never calls{" "}
+ settled() at all hasn't
+ left anything half-done, the append still happened without them
+ watching.
+
+ the same wallet from the derivation panel above:{" "} + {privateKey} +
++ connect makes one real call here — a probe pull that confirms this + key is actually authorized on this node — before handing back a + client. Fails loudly, naming the derived address, rather than + deferring the problem to the first real read below. +
+ {connectError ? ( ++ {connectError.code ? `[${connectError.code}] ` : ""} + {connectError.message} +
+ ) : null} + > + ) : ( ++ url → + {client.url} +
++ address → + {client.address} +
++ userId → + {client.userId} +
++ resolved locally, zero network calls — not proof anything is + listening at that url. Step 2 proves that. +
++ {listError.code ? `[${listError.code}] ` : ""} + {listError.message} +
+ ) : null} + + {accounts && !listError ? ( + accounts.length === 0 ? ( ++ call succeeded — this node really has zero accounts. +
+ ) : ( +
+ no real account found yet — will use the placeholder accountId{" "}
+ "demo-account", which
+ does not exist on any node.
+
+ action.ids — the instant the promise resolved +
++ {handleIds.length > 0 + ? handleIds.join(", ") + : "(empty so far — grows as each phase starts)"} +
++ already appended — the node has this queued right now, whether or + not you ever call settled(). +
++ onProgress — strategy_create confirmed before automation_create is + even sent +
++ settled() rejected — the append above still happened +
+ {isExpectedPlaceholderRejection({ + usedPlaceholder: usedPlaceholderAccount, + errorCode: createError.code, + }) ? ( +
+ expected: the node validated the queued action and correctly
+ rejected the placeholder "demo-account" — it
+ doesn't exist. List accounts above to find a real one (or create
+ one on your node), then try again.
+
+ {createError.code ? `[${createError.code}] ` : ""} + {createError.message} +
++ settled() resolved +
++ id → + {automation.id} + · status → + {automation.status} +
++ This section runs a real, live pairing request against Drakkar's + production sync server and waits up to 5 minutes for a phone to + approve it. Nothing happens until you click below. +
+ ++ website +
++ code shown to the user +
++ {session.code} +
++ phone +
++ a website wants to pair —{" "} + {foundRequest.origin} +
+ {foundRequest.label ? ( ++ label:{" "} + {foundRequest.label} +
+ ) : null} ++ requested collections:{" "} + + {(foundRequest.requestedCollections ?? []).join(", ") || + "(none)"} + +
+ ++ phone's private key — approving mints a REAL Starfish space + owned by this identity on the live sync server. There is no + cleanup path yet, so use a throwaway key unless you want to + keep managing this space afterward. +
++ phone's own root identity (sealer) — userId{" "} + {phoneUserId} +
++ edPub: {phoneRootEdPub} +
+ {mintedGrant ? ( ++ mirror space:{" "} + {mintedGrant.spaceId} · + covers{" "} + + {mintedGrant.coveredCollections.join(", ")} + +
+ ) : null} ++ sync mirror data again — same space, no new grant +
++ change the BTC amount and re-sync — the website panel's "pull + the mirror again" then shows this exact number, proving the read + is live rather than a one-time export. +
++ {resyncError} +
+ ) : null} + {lastSyncedAmount !== null ? ( ++ last written to the mirror:{" "} + {lastSyncedAmount} BTC +
+ ) : null} ++ website receives — a live, cap-authenticated read of the mirror +
++ grant arrived, sealed by {result.sealedBy} +
+
+ this is a live feed, not a point-in-time export — polling again
+ (e.g. fetchPairingGrant) shows
+ whatever the mirror holds right now
+
+ {refreshError} +
+ ) : null} ++ user-accounts +
+
+ {JSON.stringify(result.collections["user-accounts"], null, 2)}
+
+ + user-data (automations) +
+
+ {JSON.stringify(result.collections["user-data"], null, 2)}
+
+ + note what's absent: no node credential, no private key — the website + only ever received a `space:member` cap it uses to decrypt the + mirror's own content, nothing more. +
+ ++ try it anyway — write directly with this grant +
++ the cap above is read-only. Click either button to make the + website attempt a raw write straight into the node, no phone + involved — the rejection below is the expected, correct outcome. + To actually change something, the request has to be approved and + sent by the user's own device. +
++ {directWriteStatus === "running" + ? "writing…" + : directWriteStatus === "done" + ? "rejected, as expected — a read-only cap cannot write" + : "unexpected — see below"} +
+ {directWriteError ? ( +
+ {directWriteError}
+
+ ) : null}
+ {directWriteResult ? (
+
+ {JSON.stringify(directWriteResult, null, 2)}
+
+ ) : null}
+
+ What's real above: everything, once you click through both phone
+ buttons. The pairing request's proof-of-possession signature, the
+ phone's wallet derivation (from whatever private key you typed or
+ generated), a real Starfish space (
+ octobot-mirror)
+ created and written to under that identity, the space-member cap{" "}
+ mintPairingGrant{" "}
+ mints and seals to the website's ephemeral KEM key, the website's
+ signature-verified unsealing of it, and the rendezvous both sides
+ publish to and pull from — the live, public{" "}
+ joinsessions{" "}
+ collection on Drakkar's shared sync server (the same address, one
+ code-keyed slot, that carries the request and is then overwritten
+ in place by the grant). Nothing here talks to an in-memory
+ stand-in.
+
+ What's still fixtures: the phone's portfolio. There's no real OctoBot
+ node behind this "phone" — its account and automation data (
+ DEMO_ACCOUNTS_DOC/
+ DEMO_AUTOMATIONS_DOC{" "}
+ above) is hardcoded, so the mirror node the website reads is genuinely
+ written, encrypted, and decrypted — it just isn't mirroring data
+ pulled from anywhere real. The space itself, unlike the fixture data
+ inside it, is real and persists after you leave this page.
+
+ website side +
++ phone side +
+