A simple research tool for a crypto watchlist. Paste a link, contract address, thread, or raw text and get a short structured summary; generate short signals for watchlist items. Every summary and signal is generated by real 0G Compute inference and written to real 0G Storage — there is no local database standing in as the source of truth, and no mock/simulated path.
| Piece | Package | What happens |
|---|---|---|
| Inference | @0gfoundation/0g-compute-ts-sdk |
A funded ledger pays a real 0G Compute provider for each chat completion, over the provider's OpenAI-compatible endpoint, with signed per-request billing headers. |
| Storage | @0gfoundation/0g-storage-ts-sdk |
Every summary and signal (source, output, model, timestamp) is uploaded as JSON to 0G Storage. The upload returns a content-addressed root hash and a transaction hash on 0G Chain. History is read back from 0G Storage by that root hash — not from a cache. |
| Chain | 0G Chain (Galileo testnet, chain id 16602) |
Settlement layer underneath both of the above. |
The watchlist and the local list of "which root hashes belong to me" are the only things
kept in the browser (localStorage) — see Ownership model below for why.
Nothing in the UI is pre-filled, sampled, or simulated. Every value you see was either typed by you or returned by a real 0G Compute call and stored on 0G Storage:
- The watchlist starts empty and only ever contains what you add.
- The signals rail starts empty and only fills as you generate signals.
- Research results only appear after a real inference round-trip.
- History only lists records that actually have a 0G Storage root hash.
Signal cannot produce or display research without 0G. This is enforced structurally, not by convention:
- Inference has exactly one path — 0G Compute. There is no OpenAI/Anthropic client in the dependency tree and no fallback branch. If Compute is unreachable, no summary or signal is produced at all.
- The browser stores pointers, never content.
localStorageholds only{rootHash, txHash, type, createdAt}— where a record lives, not what it says. Every word of every summary and signal shown in the UI is fetched from 0G Storage on page load viaPOST /api/records. - So history is only viewable while 0G Storage is reachable. Reload the page with Storage down and you get an explicit "could not be read from 0G Storage" error with the underlying reason and a retry — never stale text that looks like working history.
- Pointers are written only after a successful upload. A "Saved to 0G Storage" badge cannot appear unless a real root hash and tx hash came back.
- Pointers from older builds that did cache preview text are sanitized on read, so that content can never be rendered as if it came from 0G.
The practical consequence: wipe localStorage and your records still exist on 0G Storage,
retrievable by root hash. Break 0G Storage and the app shows errors, not a local copy.
Prices, % changes, and sparklines are deliberately not shown anywhere: Signal has no market-data feed, and price tracking/charting is explicitly out of scope — rendering them would mean inventing numbers.
When you paste an http(s) link, Signal fetches the page server-side and feeds the
extracted text to the model, so summaries reflect real page content instead of guesses
from the URL string.
It is deliberately conservative: 10s timeout, 512KB download cap, 8,000 characters passed to the model, HTML/text content types only, at most 3 redirects (each re-validated), and SSRF guards that refuse loopback, private, link-local, and carrier-NAT addresses. If a fetch fails for any reason the summary still runs from the raw input, and the UI says so explicitly rather than implying it read the page.
- New research or a new signal runs 0G Compute and writes to 0G Storage. That consumes real 0G from the ledger and its provider sub-account.
- Recovering by hash performs a 0G Storage read only.
/api/recovercontains no inference call, so re-opening past research never pays to regenerate it. The UI badges recovered records "no Compute charged" so the distinction is visible, not implied.
/ landing page (static, no data)
/app dashboard — watchlist rail · research · signals rail, plus History
│
├── Watchlist: client state in localStorage
├── History index: root-hash pointers only, no content
│
├── POST /api/summarize ─┐
├── POST /api/signal │─ Node.js route handlers (lib/zg/*)
├── POST /api/records │ (batch read — every rendered word
├── GET /api/records/:rootHash comes back through these)
└── POST /api/recover │ (root hash OR tx hash -> stored record,
│ storage read only, no inference)
│
┌─────────────┴─────────────┐
│ │
0G Compute broker 0G Storage Indexer
(ledger, provider selection, (upload/download JSON
billing headers, inference) records by root hash)
│ │
└──────────► 0G Chain ◄───────┘
The 0G SDKs are Node-only, so they're only ever imported from server route handlers
(app/api/**/route.ts → lib/zg/*), never from client components. There is a single
server-held wallet (ZG_PRIVATE_KEY) that signs every 0G Storage upload and every 0G
Compute payment.
The spec calls for research the user "actually owns." Signal has no wallet-connect UI, so here's precisely what "owned by the user" means in this build:
- The data itself is decentralized and portable. Every summary/signal is a real object on 0G Storage, addressed by its root hash, independent of Signal's own servers. It isn't locked inside a vendor's database — it's on a network, and you can verify it exists there yourself (see Verifying it's real below).
- A single server-side wallet signs the on-chain actions (paying storage-upload gas and
Compute inference fees) on the user's behalf, because the spec doesn't call for a
wallet-connect flow, and one wasn't built. Each record is tagged with an
ownerid — a random id generated once per browser and kept inlocalStorage— so "my history" means "records tagged with my browser's id," not "records signed by my private key." - Recovery does not depend on this app. Every result carries a storage root and a tx hash. Paste either into the Recover tab from any browser — or fetch the root directly with the 0G SDK — and the record comes back. That is what makes the history portable rather than account-bound.
- If you want stricter, wallet-signed non-custodial ownership (each user's own browser wallet signs their own uploads), that's a natural next step but was out of scope for this spec — flagged here rather than silently assumed.
npm install
cp .env.example .env.local
# edit .env.local — at minimum set ZG_PRIVATE_KEY (see below)
npm run devOpen http://localhost:3000 for the landing page, or go straight to http://localhost:3000/app for the dashboard.
| Variable | Required | Default | Notes |
|---|---|---|---|
ZG_PRIVATE_KEY |
Yes | — | Private key of the wallet that signs every 0G Storage upload and 0G Compute payment. Use a fresh wallet, not one holding real funds. |
ZG_EVM_RPC |
No | https://evmrpc-testnet.0g.ai |
0G Chain EVM RPC endpoint. |
ZG_INDEXER_RPC |
No | https://indexer-storage-testnet-turbo.0g.ai |
0G Storage indexer endpoint. |
ZG_COMPUTE_PROVIDER_ADDRESS |
No | auto-selected | Pin inference to one specific provider instead of auto-picking the first available one. |
ZG_COMPUTE_MODEL |
No | provider default | For multi-model providers, pin a specific model id. |
ZG_LEDGER_INITIAL_OG |
No | 3.5 |
Amount used to open the Compute ledger the first time. Must be ≥ 3 (protocol minimum). |
ZG_LEDGER_MIN_BALANCE_OG |
No | 0.5 |
Auto-top-up threshold. |
ZG_LEDGER_TOPUP_OG |
No | 1 |
Amount deposited on each auto-top-up. |
Signal ships a preflight doctor that checks the whole 0G setup and tells you exactly what's wrong if something isn't wired up:
npm run doctor # read-only: env, chain RPC, wallet balance,
# storage indexer, compute providers, ledger. Spends nothing.
npm run doctor:full # additionally runs ONE real inference and ONE real
# storage upload+download round-trip. Spends a small
# amount of testnet 0G.A passing doctor:full is proof the deployment is genuinely talking to 0G Compute and
0G Storage — it prints the provider address, the model, and a real root hash + tx hash.
Run this first whenever something looks wrong; it isolates the failure (network, funding, ledger, provider) far faster than the app's UI errors can.
- Generate a wallet you're comfortable using for testnet activity:
node -e "const {Wallet}=require('ethers');const w=Wallet.createRandom();console.log(w.address,w.privateKey)" - Fund its address with 0G testnet tokens from the 0G faucet (faucet.0g.ai — search "0G testnet faucet" if that's moved). You need at least ~3.5 0G: 3 is the protocol minimum to open a Compute ledger, plus a little for storage-upload gas.
- Put the private key in
ZG_PRIVATE_KEYin.env.local. - Run
npm run doctorto confirm it's funded and reachable, thennpm run doctor:fullto prove the full 0G round-trip works.
Signal needs outbound HTTPS to these hosts. In a locked-down environment (corporate
proxy, CI runner, sandbox with an egress allowlist) they must be permitted, or every
0G call fails with a proxy 403 before it ever reaches 0G:
evmrpc-testnet.0g.ai (0G Chain RPC)
indexer-storage-testnet-turbo.0g.ai (0G Storage indexer)
<provider host> (whatever URL the selected compute provider
advertises on-chain — discovered at runtime)
faucet.0g.ai (only to fund the wallet)
npm run doctor reports a blocked host by name, so you don't have to guess.
If the wallet isn't funded, Signal does not silently fall back to a demo mode — every API route returns a clear, specific error (see Error handling).
Or manually: import the repo in Vercel, set ZG_PRIVATE_KEY (and any optional variables
from the table above) in the project's Environment Variables, and deploy. No other
configuration is required — it's a standard Next.js App Router project.
Note: 0G Storage uploads/downloads and Compute inference calls can take longer than a
typical serverless request. The API routes set maxDuration = 60; on Vercel's Hobby tier
that's honored up to the plan's cap. If you see request timeouts under load, upgrading the
Vercel plan (for a longer function duration) is the fix, not a code change.
Run these in order. Each step produces evidence that cannot exist without real 0G calls.
npm run doctor # read-only checks, spends nothing
npm run doctor:full # real inference + real storage round-tripdoctor:full must print, in order:
✓ Provider acknowledged 0x… <- a real 0G Compute provider
✓ Sub-account funded
✓ Service metadata <model> @ … <- the model that will serve you
✓ Inference succeeded "ok" <- 0G Compute produced this
✓ Upload succeeded
root hash 0x… <- content address on 0G Storage
tx 0x… <- 0G Chain settlement reference
✓ Download verified — round-trip works <- read back from the network
✓ Recover-by-tx verified tx -> 0x… <- root derived from the tx receipt alone
The last line is the important one: the storage root is recovered from the transaction
receipt, by decoding the Flow contract's Submit event — not from any local record.
Open /app, paste a link or some text, press Research. The button reports
Running on 0G Compute… then Saving to 0G Storage…, and the result footer shows a
storage root, a tx hash, the model, and the provider address. Copy the
storage root.
Open the Recover tab (or /app?view=recover) and paste that storage root — or the tx
hash. The same research comes back, badged "no Compute charged". Recovery performs a
0G Storage read only; /api/recover never calls inference. Paste it in a different
browser profile to confirm it does not depend on your local state.
Reload /app. Every summary and signal is re-fetched from 0G Storage at that moment; the
browser stores only {rootHash, txHash, type, createdAt}. To confirm, set ZG_INDEXER_RPC
to an unreachable host and reload: History and the signals rail report
"could not be read from 0G Storage" with the underlying error instead of showing content.
A local cache would still display the old text.
Unset ZG_PRIVATE_KEY and reload /app. A blocking banner states that Signal cannot
generate or read anything, every action is disabled, and the API returns 503. No sample
research appears at any point.
import { Indexer } from '@0gfoundation/0g-storage-ts-sdk'
const indexer = new Indexer('https://indexer-storage-testnet-turbo.0g.ai')
const [blob] = await indexer.downloadToBlob('<rootHash>')
console.log(await blob.text()) // the exact record Signal storedOr look up the tx hash on a 0G Chain testnet explorer.
Every failure mode the spec calls out surfaces as a specific, readable message rather than a silent fallback:
- Signer not configured (
ZG_PRIVATE_KEYunset) →503with a direct explanation, shown as a persistent status banner across the whole app; actions are disabled. - Unfunded / can't open ledger →
502, message points at the 3-0G protocol minimum and the faucet. - 0G Compute provider unreachable, rate-limited (429), or errors →
502with the provider's status surfaced. - 0G Storage upload/download failure →
502with the underlying SDK error message. Downloads retry with backoff for a few seconds first, since uploads don't block on full network finality (see comment inlib/zg/storage.ts) and a read immediately after a write can transiently miss. - Empty input / missing fields →
400before any 0G call is made.
Implements exactly: watchlist (add/remove), one-click structured summarization (key points / risks / bottom line), short on-demand signals with one-line reasoning, and history read from 0G Storage. Deliberately does not include trading/execution, charts or portfolio tracking, multi-agent orchestration, social/sharing, notifications, or a mobile app.
Some elements from the reference design were intentionally left out because implementing them would have required inventing data: per-token prices and % changes, sparklines, "Data by CoinGecko" attribution, signal impact/category tags (Market / On-Chain / Narrative), and nav entries for pages that don't exist (Markets, AI Models, Pricing). The layout follows the design; the fabricated content does not.
- No wallet-connect UI — see Ownership model. This was a deliberate reading of a spec that never mentions connecting a user wallet; flagging it explicitly rather than quietly picking a design.
- Provider selection is automatic with failover. If
ZG_COMPUTE_PROVIDER_ADDRESSisn't set, Signal reuses the last known-good provider and, on failure, re-selects and retries against up to 3 providers before giving up. Pin one via the env var for a predictable demo. - Provider sub-accounts are funded automatically. The 0G Compute ledger and the
per-provider sub-account are separate: the SDK does not fund the sub-account inside
getRequestHeaders. Signal tops it up to 2 0G locked before requesting inference (ensureSubAccountinlib/zg/compute.ts) — without this, correctly-formed requests are rejected for insufficient balance. - This build was developed in an environment with no network access to
0g.ai(egress policy blocked every 0G host), so the live network calls have not been executed. Every API call, contract address, and response shape was verified against the published SDK's type declarations, bundled README, and compiled output — and behaviours that could be checked offline were checked (JSON parsing, SSRF guards, URL fetching, storage-root derivation from a submission, all UI states, every API error path). Runnpm run doctor:fullwith a funded wallet as the first step before demoing; it is designed to surface anything that only a live run can. - Recover-by-tx handles single-node submissions. A storage root equals
submission.nodes[0].rootonly when the submission has one node — verified empirically across payload sizes. Signal's records are small JSON and always land in that case; for a hypothetical multi-part upload the API says so explicitly and asks for the storage root instead of returning a wrong hash. - Uploads use
finalityRequired: falsefor responsiveness inside a serverless function's time budget (seelib/zg/storage.ts); the write itself is still a real on-chain submission with a real root hash, it just doesn't block on full cross-node replication.