Loroco runs on a WASM-compiled fork of Sage. This page covers the repo layout, how to get a dev build running, and how to talk to the provider from a dApp.
Loroco injects window.loroco (and an opt-in window.chia mirror). The provider
shape mirrors Goby / CHIP-0002, so existing dApp code usually works unchanged.
// 1. Detect the provider
const chia = window.loroco ?? window.chia;
if (!chia) throw new Error("Install Loroco");
// 2. Connect — the ONLY entry point. Pops an approval popup.
await chia.request({ method: "connect" });
// 3. Reads work under ANY connection (full OR read-only), no prompt
const [addr] = await chia.request({ method: "accounts" });
const bal = await chia.request({
method: "getAssetBalance",
params: { type: "xch", assetId: null },
});
// 4. Mutating calls re-prompt the user EVERY time — unless the user
// granted read-only, in which case they're rejected with 4001 and
// no popup. Always handle 4001 (see "Security levels" below).
const { id } = await chia.request({
method: "transfer",
params: { to: "xch1…", amount: "1000000000000", fee: "0" },
});
chia_connect, chip0002_connect or connect — the router
canonicalises before gating. See the RPC reference for the full surface and
error codes.
These are not an API you call. The user controls them at connect time; your
job is to degrade gracefully. The wire surface is unchanged from Goby/CHIP-0002 — the only thing you must add
is honest handling of 4001.
A connection is granted at one of two levels:
transfer, signCoinSpends, signMessage, takeOffer, …) is
rejected outright with 4001 — no popup. It's "this site may never ask me to sign."Your dApp declares the ceiling; the user can only lower it (principle of least privilege).
Pass a scope in your connect params to request the least you need:
// Default: requests FULL (write). User may downgrade to read-only.
await chia.request({ method: "connect" });
// A read-only dApp asks for exactly that — the wallet LOCKS it there,
// the user cannot upgrade it to write.
await chia.request({ method: "connect", params: { scope: "read-only" } });
"full" (or omit it) → the prompt defaults to full and the user may downgrade to read-only."read-only" → the prompt shows it locked; the grant is read-only no matter what.getScope). You discover read-only the same way you discover "not connected": a signing call returns
4001. If your dApp needs to sign, request "full", and if signing still returns
4001 after a reconnect, tell the user to reconnect choosing full access.
A grant stays alive as long as it's used; after 7 days of inactivity it auto-disconnects.
The next call then returns 4001 — exactly like a fresh origin. Treat 4001 on
any method as "reconnect needed."
Don't assume "connected ⇒ can sign" and don't cache connection state forever. Wrap requests so a
4001 triggers a single reconnect + retry:
const chia = window.loroco ?? window.chia;
async function request(method, params) {
try {
return await chia.request({ method, params });
} catch (e) {
// 4001 = not connected / expired / blocked by read-only scope
if (e?.code !== 4001) throw e;
// Re-establish the connection (pops the approval + scope picker).
await chia.request({ method: "connect" });
try {
return await chia.request({ method, params });
} catch (e2) {
// Still 4001 after reconnect ⇒ the user granted READ-ONLY.
if (e2?.code === 4001 && isSigning(method)) {
throw new Error(
"This site needs full access. Reconnect and choose \"Full access\"."
);
}
throw e2;
}
}
}
const SIGNING = new Set([
"transfer", "sendTransaction", "signCoinSpends", "signMessage",
"signMessageByAddress", "createOffer", "takeOffer", "cancelOffer",
]);
const isSigning = (m) => SIGNING.has(m);
For signCoinSpends / sendTransaction the popup runs the actual CLVM and shows recipients,
amounts, fees, value leaving via unrecognised puzzles, and any replayable AGG_SIG_UNSAFE. If the
bundle contains anything Loroco can't classify, the user must tick an explicit acknowledgement
before Approve unlocks. Practical implications:
AGG_SIG_UNSAFE conditions; they're flagged loudly as a phishing tell.transfer, takeOffer, …) over raw bundles when you can —
their popups summarise cleanly with no acknowledgement gate.On takeOffer, Loroco re-derives the royalty amount and destination puzzle hash from the
offered NFT's on-chain royalty puzzle — not from anything your offer asserts — and shows it as "verified
on-chain." Build honest offers; a mismatched royalty claim is simply ignored in favour of the real one.
4001
everywhere (reconnect + retry), request the least scope you need
(connect({ scope: "read-only" }) if you never sign), don't assume connect implies signing, keep
signable bundles standard and small, and present offers honestly.
The Sage source lives directly under vendor/sage/. This is a deliberate temporary choice —
it makes the dev loop simpler (one git clone = everything) while the fork is iterated heavily.
vendor/sage/ and packages/extension/ live side-by-side in one history.rsync from a clone) rather than git submodule update.vendor/chia-wallet-sdk/ is a normal git submodule — it's modified rarely enough that submodule semantics still work.@loroco/wallet-wasm is published, vendor/sage/ will move out.loroco/
├── packages/
│ ├── extension/ # WXT app (popup + service worker + content script)
│ ├── goby-provider/ # window.loroco + window.chia mirror; CHIP-0002 / WC2 types
│ ├── storage-idb/ # IndexedDB-backed Storage impl for the WASM module
│ └── wallet-wasm/ # wasm-pack output of crates/sage-wasm (regenerated)
├── vendor/
│ ├── sage/ # EMBEDDED Sage Rust source
│ └── chia-wallet-sdk/ # SUBMODULE
├── scripts/ # Playwright smoke + sync benches + security suite
└── .cargo/config.toml # wasm32-unknown-unknown rustflags
git clone --recurse-submodules git@github.com:MarvinQuevedo/loroco.git
cd loroco
pnpm install
# already cloned? pull the chia-wallet-sdk submodule
git submodule update --init --recursive
You also need:
rustup, target wasm32-unknown-unknown)wasm-pack (cargo install wasm-pack)brew install llvm) — used by CC_wasm32_unknown_unknownRun all of these from the repo root.
| Command | What it does |
|---|---|
pnpm start | Fast build (skips wasm rebuild) + launch Chrome with the extension. Use this most of the time. |
pnpm start:full | Full build (rebuilds wasm too, ~1 min) + launch Chrome. Run after editing vendor/sage/. |
pnpm dev | WXT dev mode — live reload + auto-launch. Best for iterating on UI. |
pnpm chrome | Open Chrome with the existing build (no rebuild). Uses a per-repo profile at ./.chrome-profile/. |
pnpm build:fast | Build the extension only — does not touch wasm. ~2 s. |
pnpm build | Full build: rebuild wasm + rebuild extension. ~1 min cold. |
pnpm zip | Produce .output/chrome-mv3.zip for distribution. |
pnpm typecheck | Run tsc --noEmit across all workspace packages. |
chrome://extensions/ → enable Developer mode → Load unpacked → pick
packages/extension/.output/chrome-mv3/. Stable Chrome 148+ ignores --load-extension;
the dev scripts use Chrome for Testing with --disable-features=DisableLoadExtensionCommandLineSwitch.
ChiaMethodMap in packages/goby-provider/src/types.ts with exact param/result types.NO_APPROVAL_METHODS (read) or ALWAYS_APPROVAL_METHODS (mutating) in permissions.ts.chia_* / chip0002_* / snake_case alias to METHOD_ALIASES in rpc-router.ts.x ?? y through the body).ApprovalSummary case in popup/App.tsx showing every field the handler will act on.pw-wc-coverage.mjs and an attack case to the security suite if it writes state.to ?? address, secure ?? true), the popup must display the normalised value —
and never truncate an address/asset id enough to hide a swap.
Loroco syncs against coinset.org over HTTP by default. For real P2P peer sync, run the
loroco-local-sync daemon on
127.0.0.1 — the extension auto-detects it via sidecar-client.ts and talks to it over mTLS.
web/coinset-sync).