Verifiable connectivity data for DePIN β measured on phones, committed on Ethereum Sepolia, proven by the Attestcoin Protocol, settled and rewarded on Creditcoin.
Live app Β· Demo video Β· Documentation Β· Deck (PDF) Β· Technical architecture Β· Hackathon
Anyone can claim coverage numbers; nobody can prove them. Operators buy drive tests and crowdsourced quality reports whose provenance is a spreadsheet. SignalProof makes the claim checkable:
- A phone measures real network conditions β latency, throughput, network class and a coarse area (a geohash cell of ~1.2 km Γ 0.6 km; the raw coordinate never leaves the device).
- The measurement is committed as a hash on Ethereum Sepolia, signed by the contributor and verified on-chain.
- The Attestcoin Protocol attests the Sepolia block on Creditcoin and produces an inclusion proof.
- A contract on Creditcoin CC3 Testnet verifies the proof through the BlockProver precompile and accrues a reward to the contributor β the only party that decides what is real, and it trusts nobody but the proof.
- Buyers pay CTC into that same reward pool for per-area data with provenance down to the transaction.
Everything below is deployed and running on testnet: 24 settled measurements from 9 contributors across 4 cells as of 2026-09-13, one paid API purchase on chain, and a dashboard whose every number is a join of the two chains' logs β no database required.
| Component | Where | Link |
|---|---|---|
| Application | https://signalproof.mdloglabs.org |
Console Β· Verifier Β· Ops Β· Docs |
SourceBatchRegistry |
Ethereum Sepolia (11155111) | 0x32c0923cD58523864D2727FCaaB109783664c236 |
SignalProofSettlement |
Creditcoin CC3 Testnet (102031) | 0x8F14B2cC1b807203d332DE6E3DA6274176FDb584 |
SignalProofBatchSettlement |
Creditcoin CC3 Testnet (102031) | 0x3B90e22f246bBa68f6de682b564c33b121D68C85 |
All contracts are verified on their explorers. A retired registry (0x15F3β¦59Dc, relayer-gated without the on-chain signature) is still read for history via RETIRED_REGISTRIES.
Proof of life on chain
| What | Transactions |
|---|---|
| Single measurement through the signed registry, 9.8 min end to end | Sepolia 0xbd7261β¦7223 β Creditcoin 0x60d9e4β¦a2b5 |
| Three measurements settled under one continuity proof | Creditcoin 0xcf1a01β¦9598 (3 Γ MeasurementVerified + BatchSettled) |
Contributor claim() |
Creditcoin 0x148536β¦0030 |
| First paid API access, into the reward pool | Creditcoin 0xed00a0β¦8d30 (pool 4.997 β 5.047 CTC) |
flowchart LR
subgraph Device["Contributor device (PWA)"]
M["Measure<br/>latency Β· throughput Β· network class Β· geohash-6"]
W["Wallet<br/>EIP-191 signature over the measurement root"]
M --> W
end
subgraph Server["SignalProof server (Node 22)"]
G["Gateway (tRPC)<br/>recompute root Β· recover signer<br/>freshness Β· uniqueness Β· rate limit"]
R["Relayer<br/>submitMeasurement on Sepolia"]
P["Proof worker (15 s tick)<br/>poll attestation Β· fetch proof Β· execute()"]
RM["Read model<br/>join of both chains' logs"]
API["Buyer API /v1<br/>catalog Β· areas Β· brief Β· exports Β· verifier"]
G --> R
R --> P
end
subgraph Sepolia["Ethereum Sepolia"]
REG["SourceBatchRegistry<br/>relayer-gated Β· ecrecover on-chain<br/>emits MeasurementSubmitted"]
end
subgraph Attestcoin["Attestcoin Protocol"]
ATT["Attestation<br/>Sepolia headers attested on Creditcoin"]
PRV["Proof service<br/>Merkle inclusion + continuity proof"]
end
subgraph CC3["Creditcoin CC3 Testnet"]
BP["BlockProver precompile 0xβ¦0FD2"]
S["SignalProofSettlement (ASCBase)<br/>receipt status Β· emitter binding Β· freshness<br/>rewards[contributor] += reward"]
B["SignalProofBatchSettlement<br/>one continuity proof, many measurements"]
POOL[("Reward pool<br/>claim() by contributor")]
BP --> S
BP --> B
S --> POOL
B --> POOL
end
subgraph Consumers["Consumers"]
UI["Console<br/>map Β· proof queue Β· area Β· contributors Β· ops"]
BUY["Buyer<br/>pays CTC for a key"]
ANY["Anyone<br/>/verify/<hash>"]
end
W -->|"signed payload"| G
R -->|"tx"| REG
REG -.->|"block attested"| ATT
ATT --> PRV
P -->|"getProof"| PRV
P -->|"execute(proof)"| BP
REG -.->|"logs"| RM
S -.->|"logs"| RM
B -.->|"logs"| RM
RM --> UI
RM --> API
API --> BUY
API --> ANY
BUY -->|"0.05 CTC"| POOL
The Creditcoin contract is the only place that decides whether a measurement is real. It does not trust the gateway, the relayer or the submitter β only the proof, and two checks the precompile leaves to the application (see Attestcoin integration).
sequenceDiagram
autonumber
participant D as Contributor device
participant G as Gateway
participant R as Relayer / Proof worker
participant E as Sepolia Β· SourceBatchRegistry
participant A as Attestcoin (attestation + proof service)
participant C as Creditcoin Β· SignalProofSettlement
D->>D: measure latency, throughput, network class, geohash-6
D->>D: root = keccak(canonical payload) Β· wallet signs it (EIP-191)
D->>G: submitMeasurement(payload, signature)
G->>G: recompute root Β· recover signer Β· freshness β€ 15 min Β· rate limit 3/cell/10 min
G-->>D: accepted (SUBMITTED)
R->>E: submitMeasurement(root, areaHash, contributor, β¦, signature)
E->>E: relayer gate Β· ecrecover == contributor
E-->>R: MeasurementSubmitted (AWAITING_ATTESTATION)
Note over E,A: Attestation frontier advances ~10 blocks / 2 min<br/>reaches the block after ~7β9 min
loop every 15 s
R->>A: getLatestAttestedHeightAndHash(chainKey)
end
R->>A: getProof(txHash) β Merkle siblings + continuity roots
R->>C: execute(proof) β or the contributor's own wallet does
C->>C: BlockProver verifies inclusion + continuity
C->>C: receiptStatus == 1 Β· log.address == registry Β· age β€ maxMeasurementAge
C-->>R: MeasurementVerified(root, area, contributor, reward) (SETTLED)
D->>C: claim() β pull payment, msg.sender only
stateDiagram-v2
[*] --> SUBMITTED: gateway accepts
SUBMITTED --> AWAITING_ATTESTATION: relayed to Sepolia
AWAITING_ATTESTATION --> PROOF_VERIFIED: proof fetched, execute() sent
PROOF_VERIFIED --> SETTLED: MeasurementVerified on Creditcoin
SUBMITTED --> REJECTED: permanent failure
AWAITING_ATTESTATION --> REJECTED: permanent failure
PROOF_VERIFIED --> REJECTED: permanent failure
SETTLED --> [*]
canTransition encodes the machine as data, so the worker cannot present an unproven measurement as settled; SETTLED is terminal. Transient failures back off (15 s β 30 min, five attempts); permanent ones β forged emitter, already-settled root, reverted source transaction β stop immediately.
SignalProofSettlement inherits ASCBase from @gluwa/asc-contracts and overrides one hook, _processAndEmitEvent. The BlockProver precompile at 0xβ¦0FD2 verifies Merkle inclusion and continuity; ASCBase deduplicates by query id; the application logic runs only after both pass.
The precompile proves exactly one thing: the transaction was included in a block that genuinely belongs to the attested source chain. It does not prove the transaction succeeded, and it does not prove which contract emitted the logs inside it.
// 1. Inclusion is not success.
if (receipt.receiptStatus != 1) revert SourceTransactionFailed(receipt.receiptStatus);
// 2. Emitter binding. Without this, anyone deploys a lookalike registry on Sepolia, emits a
// byte-identical MeasurementSubmitted naming themselves, proves it, and drains the pool.
if (log.address_ != sourceRegistry) revert WrongEmitter(log.address_, sourceRegistry);Check 2 is missing from the SimpleMinterASC example on the Attestcoin documentation site. It is present in Gluwa's own ASCLoanManager, which ships a regression test for it. We mirror both: test_rejectsWrongEmitter, and the whole-system attack in contracts/test/EndToEnd.t.sol (test_forgedRegistryCannotSettleEvenWithValidProof), where an attacker controls a registry, emits an identical event and holds a proof the precompile accepts β only the binding stops them.
| Detail | What we do | Why |
|---|---|---|
chainKey |
Resolved at startup from getSupportedChains(), matched on chainId === 11155111 |
It is a uint64; the chain returns chainName hex-encoded, so name matching finds nothing; key 1 means a different chain on CC3 Mainnet |
| Attestation wait | Poll getLatestAttestedHeightAndHash once per tick |
waitUntilHeightAttested blocks 8β20 min and would freeze the queue |
getProof |
Check .success, then unwrap .data |
It returns ProofResult, not ContinuityResponse |
ProofBuilder |
120 s timeout + own backoff | The default is 10 s with no retry |
estimateGas |
try/catch, manual fallback, 35 % buffer | Estimation fails against precompiles even when the call succeeds |
| Event reading | From the receipt, never a filter | Public RPCs expire filters |
| Deploy | forge create, not forge script |
CC3 headers carry no mixHash; Foundry's simulation aborts on prevrandao |
| Batch route | executeBatch drives the precompile's batch verifyAndEmit, which ASCBase does not wire |
One continuity proof for many measurements; capped at 50 entries because the marginal cost rises with batch size (BatchGasProbe.t.sol) |
| Double settlement | The batch contract skips roots the single-proof contract already paid (siblingSettlement, staticcall with explicit success check) |
Two routes, one registry: the same genuine measurement must not be paid twice |
| Route | For | What it shows |
|---|---|---|
/ |
operators | Coverage map (geohash cells drawn at their real resolution, prefix search, quality filter), KPIs, quality trend, proof queue with the live Attestcoin proof and Settle from my wallet, data products |
/?mode=measure |
contributors | Real measurement in the browser (PWA-installable), wallet-signed; auto-measure every 10 minutes |
/verify/<hash> |
anyone | A measurement root, Sepolia tx or Creditcoin tx β commitment, attestation, proof, settlement; never metered |
/area/<geohash> |
buyers | The cell, quality over time, every sample with provenance, CSV/JSON export, brief, embeddable badge |
/contributors[/<address>] |
contributors | Leaderboard; per-address history, earned/claimed/unclaimed, claim() |
/ops |
operators | Relayer balances, pool runway in settlements, worker and RPC health, attestation lag |
/docs |
everyone | Eleven documentation pages, also readable on GitHub under docs/site/ |
sequenceDiagram
participant B as Buyer wallet
participant S as SignalProof server
participant C as Creditcoin Β· SignalProofSettlement
B->>C: transfer 0.05 CTC β receive() β Funded (reward pool grows)
B->>B: sign "SignalProof API access / Transaction: tx / Address: me"
B->>S: POST /v1/access/redeem { txHash, address, signature }
S->>C: getTransaction / getReceipt: to == settlement, value β₯ price, status == 1, from == signer
S-->>B: key = sp1_<expiry>_<txHash>_<HMAC> (30 days, deterministic per payment)
B->>S: GET /v1/areas/qqguw6 Authorization: Bearer key
| Endpoint | Access | Returns |
|---|---|---|
GET /v1/areas |
free | Every measured cell with aggregates |
GET /v1/access Β· POST /v1/access/redeem |
free | Terms; key issuance |
GET /v1/verify/{hash} |
free | Both chains' view of a measurement plus the proof |
GET /v1/areas/{geohash}/badge.svg |
free | Embeddable quality badge |
GET /v1/areas/{geohash} |
key | Aggregates and every sample with sourceTxHash, creditcoinTxHash, verifyUrl |
GET /v1/areas/{geohash}/brief |
key | Markdown brief a buyer can forward |
GET /v1/areas/{geohash}/export.csv Β· export.json |
key | Bulk export |
The key travels as Authorization: Bearer <key> or ?key=<key> on a link; gated responses are Cache-Control: private. Keys are HMACs over the payment β nothing is stored, so there is no replay to defend and the same payment always yields the same key. What is sold is the service β aggregation, the provenance join, briefs, exports, uptime β not the data, which is public on two chains and free on the dashboard. Without BUYER_ACCESS_SECRET the gate is open and the purchase card is hidden, so a keyless clone keeps working.
curl -s https://signalproof.mdloglabs.org/v1/areas # catalog
curl -s https://signalproof.mdloglabs.org/v1/verify/0x60d9e438e8549a101b144bd1e474475301c696640062601ddfd18f788e6aa2b5
curl -s -H "Authorization: Bearer $KEY" https://signalproof.mdloglabs.org/v1/areas/qqguw6/export.csv| Party | Can | Cannot |
|---|---|---|
| Contributor | Attribute a measurement to itself (signature recovered on-chain), claim() its own accrual, self-settle any proven measurement |
Claim for another address, alter a signed payload (gateway: MEASUREMENT_ROOT_MISMATCH) |
| Relayer | Withhold a measurement (liveness) | Forge attribution, move a reward, make the contract accept an event the registry did not emit |
| Anyone | Call execute() with a valid proof, verify any hash, read the catalog |
Settle the same root twice (per route and across routes), settle a lookalike registry's event |
| Buyer | Fund the pool and receive a key | Redeem someone else's payment (SIGNER_MISMATCH), forge a key |
Four findings from our own review are kept as inverted exploit tests, so reopening a hole fails a test instead of quietly working: the permissionless first registry (AdvUnlimitedMint.t.sol), the same measurement paid on both routes (DoubleSettlement.t.sol), a poisoned batch (BatchPoison.t.sol), and a relayer-named payee (SourceBatchRegistry.t.sol, with a signature vector shared byte for byte with the TypeScript client).
Other properties: pull-payment rewards (no value moves on the verification path), execute() permissionless by design, the public API never returns signature/nonce/sessionHash, secrets never reach the client, the dashboard read model never shrinks on a flaky RPC read.
Known limitations β stated so a judge does not have to find them: the relayer is still the admission point (offline relayer stalls new measurements; already-committed ones can be settled by anyone); anti-Sybil is a per-cell rate limit, not device attestation; the reported cell is self-declared by the client; buyer keys cannot be revoked individually and there is no retention policy; read direction only (Sepolia β Creditcoin); attested β finalized (attestation runs ahead of Sepolia's finalized tag); end-to-end latency is 9β13 minutes, so a demo pre-warms a proof.
Requirements: Node 22+, pnpm 10, Foundry (contracts). MySQL is optional β without DATABASE_URL the store is in-memory and the dashboard still reads the chain.
git clone --recursive https://github.com/mdlog/signalproof.git && cd signalproof
pnpm install
cp .env.example .env # pre-filled with the live testnet deployment β no key needed to read
pnpm dev # http://localhost:3000 β real settlements, no database| Variable | Purpose |
|---|---|
SEPOLIA_RPC_URL, CREDITCOIN_RPC_URL, ATTESTCOIN_PROOF_SERVICE_URL |
Chain reads and proofs (keyless) |
SOURCE_BATCH_REGISTRY_ADDRESS, SETTLEMENT_CONTRACT_ADDRESS, BATCH_SETTLEMENT_ADDRESS + *_DEPLOY_BLOCK, RETIRED_REGISTRIES |
What to read; deploy blocks keep the first paint fast |
SEPOLIA_RELAYER_PRIVATE_KEY, CREDITCOIN_RELAYER_PRIVATE_KEY |
Enable relaying and settling (funded burners) |
PROOF_WORKER_MODE |
full (default) or relay-only β leave settlement to contributors' wallets |
BUYER_ACCESS_SECRET, BUYER_ACCESS_PRICE_CTC, BUYER_ACCESS_DAYS |
Metered buyer API; open gate when the secret is unset |
VITE_WALLETCONNECT_PROJECT_ID |
Optional; enables WalletConnect for phone wallets (build-time) |
DATABASE_URL, JWT_SECRET |
Optional persistence; session secret |
The first chain read after a cold start takes ~20 s on the public CC3 RPC; every read after that is served from a single-flight, stale-while-revalidate cache. Run only one proof worker per relayer key β two workers race on nonces.
pnpm verify # tsc --noEmit + 185 Vitest + 75 Foundry tests
pnpm smoke # keyless read-path check: resolved chainKey, attestation lag
pnpm e2e:surface # 28 live checks of the product surface (E2E_BASE=https://host to target a deployment)
pnpm e2e:live # one measurement end to end against the live chains, ~9 min
pnpm e2e:batch # three measurements under one continuity proofOn a server you own (this is how the public host runs, behind a Cloudflare tunnel):
pnpm build && pnpm pm2:start # dist/ under pm2, PORT 3011 from ecosystem.config.cjs; pm2 save records it
pm2 startup # once per machine: resurrect on reboot (prints the command to run as root)
pnpm pm2:deploy # after a git pull: rebuild and reloadThe process is restarted if it crashes or grows past 700 MB; SIGTERM stops the proof worker, lets in-flight requests finish and exits, and in production a busy port is fatal rather than silently moving to the next one. Container alternative:
docker build -t signalproof . && docker run --rm -p 3000:3000 --env-file .env signalprooffly.toml and render.yaml are included (fly secrets set β¦ / Render Blueprint). Contracts: pnpm deploy:check, then pnpm deploy:all (Sepolia β CC3 β fund the pool); contracts/deploy.sh documents each step.
contracts/ Foundry β SourceBatchRegistry, SignalProofSettlement, SignalProofBatchSettlement, 75 tests
server/signalproof/ gateway helpers, relayer, proof worker, chain read model, buyer API, access keys,
verifier, area exports, contributors, ops, live e2e scripts
server/routers.ts tRPC gateway
shared/ measurement canonicalisation + signing message, geohash, quality score, auto-measure schedule
client/src/ React 19 console: pages per route, AppShell, RainbowKit wallet, measurement client, docs site
docs/ technical architecture, deck, submission package, docs-site markdown
- Documentation site β introduction, quickstart, concepts, architecture, Attestcoin integration, contracts, API, security, operations, FAQ, changelog (source in
docs/site/) docs/TECHNICAL_ARCHITECTURE.mdβ as-built architecture and status machinedocs/deck/SignalProof-deck.pdfβ 12-slide deckdocs/JUDGE_QA.mdβ the hard questions, answered
Built on the Attestcoin Protocol by Gluwa; contract patterns follow gluwa/attestcoin-protocol-examples (Apache-2.0). Submission to BUIDL CTC 2026 Fall, DePIN track.
MIT β see LICENSE.
