Skip to content

feat: Palinurus — depin-attest + depin-rewards (Track C, DePIN)#138

Open
rz1989s wants to merge 38 commits into
zeroclaw-labs:mainfrom
RECTOR-LABS:feat/depin-plugins
Open

feat: Palinurus — depin-attest + depin-rewards (Track C, DePIN)#138
rz1989s wants to merge 38 commits into
zeroclaw-labs:mainfrom
RECTOR-LABS:feat/depin-plugins

Conversation

@rz1989s

@rz1989s rz1989s commented Jul 23, 2026

Copy link
Copy Markdown

ℹ️ Renamed branch: feat/depin-attestfeat/depin-plugins. The original name was a misnomer — this branch carries both plugins (depin-attest + depin-rewards) plus the shared palinurus-core. Renaming the head branch on the fork closed the previous PR (#76) — GitHub doesn't allow re-pointing a PR's head. All commits + history are preserved here; nothing else changed. (Supersedes #76.)


🦞 Palinurus

The Solana DePIN node that talks. A navigator at the physical edge, attesting back to the chain.

A ZeroClaw agent on a $40 Raspberry Pi becomes a Solana-attesting, reward-watching DePIN node — the agent proposes, a human/Squads multisig disposes, no main key ever leaves the cold path.

Track Custody Tests crates.io mainnet license site

Palinurus wiring: physical edge → ZeroClaw agent → Solana, custody tiers + cold signing path

Track C (DePIN & the physical edge). A pair of ZeroClaw WIT tool plugins + a shared wasm32-wasip2-friendly Solana substrate, with a real on-chain attestation verified on mainnet.

📖 Full overview, wiring diagram, and the custody/safety writeup live in the palinurus README. This PR is the engineering surface — the two plugins + their demo drivers — for maintainer review.


Summary

This PR adds two Solana-native tool plugins for ZeroClaw, both built as wasm32-wasip2 WIT components, plus the shared core crate they depend on:

  • depin-attest — turns a sensor reading into an on-chain Solana Attestation Service attestation (or memo fallback), composed with a durable-nonce replay guard. T1 (unsigned — a human/Squads multisig signs) default + T2 (autonomous, scoped session key) opt-in. T2 path verified live on mainnet (see below).
  • depin-rewards — watches a public Helium/Hivemapper-class hotspot (no ownership required) for online/offline flips + rewards; fires real Telegram alerts; drafts an unsigned rewards-claim tx. T0/T1 only — no signing key anywhere in the crate.
  • palinurus-core — the shared wasm32-wasip2-friendly Solana substrate (PDA derivation, base58, borsh, versioned-tx, durable-nonce, JSON-RPC over waki, ~200-token response shaping), published on crates.io as v0.1.0. Both plugins depend on the published crate — no fork git URL.

212 host tests across the trio (71 core + 83 attest + 58 rewards), all clippy -D warnings + wasm32-wasip2 clean (both crates, both feature modes). Layout matches redact-text exactly.


Table of contents


Verified on mainnet

The depin-attest T2 custody path is confirmed on Solana mainnet — a real, explorer-verifiable attestation paying real fees. Not a mock, not an unsigned draft, not devnet play-money (no ?cluster=devnet in the URL — a judge verifies it directly).

The confirmed mainnet transaction — Success · Finalized · Mainnet Beta:

Solana explorer: confirmed MAINNET attestation — Finalized, Mainnet Beta

Programs & Logs — the attestation memo committed on-chain (Advance Nonce + Memo):

Solana explorer Programs & Logs: Advance Nonce Account + Memo 'palinurus: bme280-1=24.7celsius @ 1784707747'
Transaction YZTS16nNNrDhLhFHCtSMhcYTYAkcYQvPn2QUWfWvkw4bJxif77d1Ww36o3c4LYe6r69NAzYNJLDpz93DjR3G9TC
Status Confirmed — Success / Finalized · Mainnet Beta
Slot / fee / version 434472270 · 0.000005 SOL (5000 lamports, within the 10k cap) · version 0 (versioned tx → durable nonce as blockhash)
Replay guard durable nonce advanced on this run (BXANchUJ…rbsPHyV7X374…bCMZf) — a replayed/stale attestation is rejected
Custody (pre-sign) session-key identity (signer DZdeez…7RRC = authority = payer = nonce_authority) · program allowlist {System, SAS, Memo} (the only System ix is AdvanceNonceAccount — value transfer is unexpressible) · lamport cap · daily cap — all enforced before signing

The sensor reading is committed on-chain as a memo: palinurus: bme280-1=24.7celsius @ 1784707747. The devnet worked-example (BsdBnMtJ…2qGYo, slot 477808575 — the on-camera demo) + an earlier devnet run (65UzT3h1…APir, slot 477806741) are also explorer-verifiable — the same T2 path, two clusters, three real signed attestations.

SAS vs memo path. The default is the memo program (cheap, high-throughput — the landed proof above). The Solana Attestation Service path (create_attestation, verifiable + credential-bound) builds the instruction and derives the PDA, but landing it on-chain is blocked on schema creation (SAS custom error 0x4 — a stale-arg issue against sas-lib@1.0.10's getCreateSchemaInstruction; the credential creates cleanly). The SAS instruction + PDA derivation are tested + cross-checked against sas-lib's getCreateAttestationInstruction / deriveAttestationPda (TS oracles). On-chain SAS landing is the focused next milestone.

Components

1. palinurus-core — the shared substrate (MIT, crates.io)

The minimal wasm32-wasip2-friendly Solana substrate the plugins import: PDA derivation, base58, borsh, versioned-tx construction, durable-nonce handling, JSON-RPC over waki, ~200-token response shaping. Hand-rolled where solana-sdk/solana-program cannot run inside a WIT component; PDA derivation cross-checked byte-for-byte against solana_program and @solana/web3.js.

  • 📦 palinurus-core = "0.1" on crates.io — both plugins depend on the published crate (clean for upstream reviewers — no fork git URL).
  • 71 host tests, wasm clean, clippy -D warnings clean.

2. depin-attest — flagship (T1 default + T2 opt-in)

Sensor reading → on-chain attestation via the Solana Attestation Service (SAS) create_attestation (memo fallback), with a durable nonce solving blockhash expiry (the bounty's trap #1). T1 default (unsigned — multisig signs) + T2 opt-in (scoped session key, program allowlist, hard caps, fail-closed injection test).

3. depin-rewards — daily-useful, no hardware

Watches Helium/Hivemapper-class hotspots via public reads, fires Telegram alerts. No Pi, no hotspot ownership required.

  • status — hotspot online/offline + owner + location (Relay get-hotspot).
  • summary — daily rewards total + beacon/witness/dc breakdown (Relay reward-shares list endpoint, client-side sum — the /totals aggregate is 401-gated on the free Community tier; verified live).
  • watch — the cron workhorse: online→offline flips (instant Telegram alert) + 08:00 daily rewards summary. Stateless (SOP persists is_active).
  • Telegram alerts — via the Bot API (BYO free bot token).
  • Custody hardened — T0/T1 only, no signing key anywhere in the crate (a test asserts it); hotspot allowlist wired into every action; redacting Debug; chat_id from config. Fail-closed injection transcript (4 vectors) in the plugin README + tests/injection.rs (bounty hard req feat: publish 8 channel plugins to the registry + repo-relative release base #8).
  • WIT shim wiredexecute() dispatches to the pure core via execute_entry + RewardsRequest (3 dispatch tests) + a wasm WakiHttp (waki-backed HttpClient impl, cfg(target_family="wasm")). The plugin functions as a WIT component — wasm32-wasip2 build is clean.
  • 📋 claim_tx design documented, deferred — Helium hotspots are compressed NFTs, so the claim ix is distribute_compression_rewards_v0 (not distribute_rewards_v0), needing a merkle ProofArgs fetched per-claim via DAS get_asset_proof. Verified PDAs + program id + custody posture documented in the README; impl is the next focused milestone (alerts core ships complete + correct rather than rush a half-verified claim tx).
  • 58 host tests (55 core + 3 demo), wasm + clippy clean. Rewards path verified live against the real Relay API on the free Community tier (the live smoke test surfaced & fixed 3 real bugs the mocked tests couldn't catch).

Custody at a glance

Plugin Reads (T0) Unsigned tx (T1) Autonomous sign (T2)
depin-attest ✅ durable-nonce attestation ✅ opt-in, scoped session key, allowlist + caps — verified on mainnet
depin-rewards ✅ Relay + Telegram 📋 unsigned claim tx (design documented, deferred) ❌ never (claim moves value → multisig)

The agent never holds a main wallet key. Pattern: agent proposes, multisig disposes. Each plugin README declares + defends its tier with a fail-closed prompt-injection transcript (4 attack vectors each, test-backed) — see plugins/{depin-attest,depin-rewards}/README.md.


Build & test

rustup target add wasm32-wasip2
cargo test                                  # 212 host tests, no wasm toolchain needed
cargo build --release --target wasm32-wasip2 # the core compiles to the component target
cargo clippy --all-targets -- -D warnings    # zero warnings, both crates, both feature modes

Per-plugin:

cd plugins/depin-attest   && cargo test --features demo   # 83 host tests (77 core + 6 demo)
cd plugins/depin-rewards  && cargo test --features demo   # 58 host tests (55 core + 3 demo)

Reproduce the on-chain attestation

The T2 demo driver (host-only, behind the demo feature) runs the shipped pure core against the live devnet durable-nonce account, signs, and submits:

cd plugins/depin-attest
ATTEST_MODE=t2 ATTEST_MEMO_FALLBACK=true \
ATTEST_SESSION_KEYFILE=<path/to/solana-keypair.json> \
cargo run --features demo --bin depin-attest-demo

The rewards beat (no hardware, live Relay):

cd plugins/depin-rewards
RELAY_API_KEY=<your-community-tier-key> cargo run --features demo --bin palinurus-demo -- all

Status & next

  • palinurus-core v0.1.0 live on crates.io.
  • depin-attest complete (T1 + T2, 83 tests) + on-chain T2 attestation verified on mainnet (sig YZTS16nN…3G9TC, slot 434472270, Finalized, Mainnet Beta).
  • depin-rewards alerts + custody + docs complete (55 tests); WIT shim wired (execute() → pure core + WakiHttp). Rewards path verified live (Relay Community tier). claim_tx design documented; impl is the next focused milestone (compression path + DAS).
  • Uses the public Relay API (Helium-Foundation-sponsored, free Community tier) — within the bounty's granted "read access to any aggregator API."

Feedback

Genuinely welcome — especially on the custody posture, the claim-tx design (Helium cNFT compression path — deferred deliberately, documented), and the on-chain attestation. Particularly from anyone who's touched SAS or durable nonces. 🦞

rz1989s added 30 commits July 19, 2026 09:41
…e + shim

Palinurus Track C (DePIN) flagship plugin. Standalone [workspace] crate
at plugins/depin-attest/, matching the canonical redact-text layout:
- Cargo.toml: cdylib+rlib, palinurus-core (git dep), wit-bindgen, serde,
  borsh, sha2, base64, ed25519-dalek (T2 signing), solana-program (dev-dep oracle)
- manifest.toml: tool capability, http_client + config_read permissions
- src/lib.rs: thin #[cfg(target_family="wasm")] WIT shim (stub exports)
- src/depin_attest.rs: pure core (empty, logic lands in slices B-G)
- tests/depin_attest.rs: host smoke test

Dep graph verified on wasm32-wasip2: palinurus-core + wit-bindgen + waki +
ed25519-dalek + borsh + sha2 + base64 all compile clean. Host tests green.
Clippy clean. The full execute flow (config → nonce → ix → durable-nonce →
serialize → shape) lands slice by slice per PLAN-2.
Slice B (PLAN-2): the pure data layer for depin-attest.

- AttestConfig::from_section: flat string→string config → typed struct.
  Fail-closed on missing required keys, malformed base58, unknown custody
  mode, empty section, TTL overflow guard. Parses T2 fields (session key,
  caps) when custody_mode=t2.
- CustodyMode enum (T1 default / T2 opt-in).
- SensorReading: BorshSerialize/Deserialize struct (sensor_id, value, unit,
  timestamp). encode() → borsh::to_vec. derive_nonce() → Pubkey(sha256(
  sensor_id ‖ timestamp_le ‖ value_le ‖ unit)) — deterministic, unique per
  reading, cryptographically bound to the reading content.
- AttestError: 8 specific variants (Config/InvalidReading/Rpc/NonceAccount/
  Borsh/Custody/Signing/Submit).
- Manual Debug for AttestConfig (session key + rpc_api_key redacted as
  [REDACTED] — never leaked in errors/logs; part of T2 injection defense).

25 host tests green: config valid/invalid/missing/empty/T2, nonce
determinism + uniqueness (per sensor/value/timestamp/unit) + identical-readings
collide (natural dedup), borsh encode layout + round-trip.
clippy clean, wasm32-wasip2 build clean.
Slice C (PLAN-2): the consensus-critical instruction-building layer.

- build_attest_ix: builds the SAS create_attestation instruction for a
  sensor reading. Derives the attestation PDA via find_program_address(
  ["attestation", credential, schema, nonce], SAS), computes expiry =
  timestamp + ttl (overflow-checked), encodes ix data via
  CreateAttestationIxData, assembles 6 accounts in the Codama-verified
  order (payer W-signer, authority R-signer, credential R, schema R,
  attestation W, system R).
- build_memo_ix: memo program instruction (raw UTF-8 data, no accounts).
- ATTESTATION_SEED constant (verified from sas-lib ATTESTATION_SEED).

8 new host tests: ix program is SAS, 6 accounts in correct order with
correct W/R/signer flags, attestation PDA matches find_program_address
cross-check, ix data matches CreateAttestationIxData encoding, expiry =
timestamp + ttl, expiry overflow detected, memo ix = raw UTF-8 + no
accounts + correct program, memo ix empty string.

The PDA is cross-checked against palinurus_core::find_program_address
(oracle-verified against @solana/web3.js in substrate slice 1). The ix
data is cross-checked against CreateAttestationIxData (oracle-verified
against sas-lib borsh encoder in substrate slice 3). TS oracle scripts
(verify-attest-ix.mjs, verify-attest-pda.mjs) to follow as a secondary
full-ix cross-check against sas-lib's getCreateAttestationInstruction.

33 host tests green, clippy clean, wasm32-wasip2 build clean.
Slice D (PLAN-2): the full T1 flow that wires everything together.

- execute_t1: validate reading → build_attest_ix → (memo ix) →
  rpc.get_account_info(nonce_account) → parse_nonce_account → verify
  authority → build_with_durable_nonce → serialize_tx → base64 → shape.
  Returns AttestOutput (attestation PDA, tx_b64, explorer URL, ~200-token
  summary). Unsigned (0 signatures) — the human/Squads multisig signs.
- AttestOutput struct (#[derive(Debug)] — no secrets in it).
- Response shapers: shape_t1 (active), shape_memo_fallback + shape_t2
  (#[allow(dead_code)] — used in slices E/G).
- validate_reading: fail closed on empty sensor_id, non-finite value,
  empty unit, non-positive timestamp.

10 new host tests: happy path (PDA correct, summary ≤200 tokens/≤800 chars,
unsigned, explorer URL), tx contains AdvanceNonceAccount data, memo ix in tx,
empty memo ignored, invalid reading (empty sensor_id, NaN), nonce account
not found, nonce account uninitialized, nonce authority mismatch, RPC error.

43 host tests green, clippy clean, wasm32-wasip2 build clean.
Slice E (PLAN-2): memo fallback path + WIT shim wiring.

- execute_memo_fallback: when cfg.memo_fallback = true, skip SAS, build a
  memo-only tx with the reading as UTF-8 ('palinurus: id=value unit @ ts'),
  compose with durable nonce. The operator's explicit escape hatch.
- execute_t1_entry: routes to SAS path (execute_t1) or memo fallback based
  on cfg.memo_fallback. This is the main entry point the shim calls.
- shape_memo_fallback: ~200-token summary for the memo path.
- WIT shim wired: execute() now parses args (ExecuteArgs), builds
  AttestConfig from __config, creates WakiRpc, calls execute_t1_entry,
  returns ToolResult with the ~200-token summary. Structured log-record
  at Start/Complete/Fail. No longer a stub.

5 new host tests: memo fallback happy path, with optional memo, explorer URL
points to nonce account, validates reading, SAS path used when fallback=false.
48 host tests green, clippy clean, wasm32-wasip2 build clean (shim compiles
with real execute logic on wasm).
…+ caps

Slice F (PLAN-2): the T2 safety stack that makes autonomous signing safe.

- enforce_program_allowlist: only {System, SAS, Memo} programs permitted.
  Any other program → fail closed. This is the primary guard — it makes
  value transfer impossible (no SPL Token, no Stake, no any-other-program).
- enforce_session_key_identity: the session key's verifying key must equal
  authority + payer + nonce_authority. One scoped key, one identity.
- enforce_daily_cap: UTC-day counter (DailyCapState, thread_local in shim).
  Resets on day rollover. Exceeding cap → fail closed (flood/replay guard).
- enforce_lamport_cap: estimated fee = num_sigs * lamports_per_signature.
  Exceeding cap → fail closed. Secondary bound (allowlist is primary).
- T2_ALLOWED_PROGRAMS const: [System, SAS, Memo].

13 new host tests: allowlist allows System/SAS/Memo, rejects SPL Token +
random program; identity allows all-match, rejects authority/payer/
nonce_authority mismatch, rejects missing session key; daily cap allows
under cap, rejects over cap, resets on day rollover; lamport cap allows
small fee, rejects over cap.

61 host tests green, clippy clean, wasm32-wasip2 build clean.
Slice G (PLAN-2): the T2 autonomous signing flow. T2 NOT left behind.

- execute_t2: same tx construction as T1, but custody guards enforced
  BEFORE signing (identity → allowlist → nonce fetch → lamport cap →
  daily cap), then the session key signs (ed25519-dalek deterministic),
  serializes the signed tx, and submits via rpc.send_transaction.
- execute_entry: unified entry point routing to T1 or T2 based on
  custody_mode. The shim passes thread_local DailyCapState for T2.
- Shim updated: thread_local DAILY_CAP state, calls execute_entry with
  Some(&mut cap) for T2 routing. The full execute path (T1 + T2) is now
  wired through the WIT component.
- shape_t2: ~200-token summary with attestation PDA + sig + nonce + tx explorer URL.

7 new host tests: T2 happy path (signature verifies against session key
pubkey via ed25519-dalek VerifyingKey::verify), signed message matches
serialize_message, memo fallback T2, daily cap exceeded, identity mismatch
rejected, execute_entry routes T2, execute_entry routes T1.

68 host tests green, clippy clean, wasm32-wasip2 build clean.
T1 + T2 both functional. The shim is fully wired.
… verify

Slice H (PLAN-2): the one-page writeup + fail-closed injection test.

- Full README: what it does, config keys table, custody tier (T1/T2),
  threat model (what the agent can/cannot do), worked example with real
  output (attestation PDA 9FMN…AWcu, explorer-verifiable), prompt-injection
  test transcript (4 attacks, 4 rejections: transfer-via-memo inert, SPL
  Token blocked by allowlist, session key never in output, daily cap
  enforced), what we'd build next, what fought us on wasm32-wasip2 (6 gotchas
  documented: solana-sdk friction, PDA layout, borsh 1.x, waki, durable
  nonce, ed25519 signing).
- Removed temporary capture_example test.

Final verify — all 9 hard requirements (BOUNTY-REQS §5) met:
1. ✅ Layout matches redact-text
2. ✅ Pure core + thin shim, cdylib+rlib
3. ✅ cargo test — 68 host tests green
4. ✅ cargo build --target wasm32-wasip2 --release — clean
5. ✅ Structured logging via log-record
6. ✅ manifest.toml complete
7. ✅ README.md complete
8. ✅ Prompt-injection test FAIL CLOSED
9. ✅ MIT license

depin-attest implementation complete: 8/8 slices (A-H). T1 + T2 both
functional. 68 host tests, wasm clean, clippy clean.
Phase 2: secondary oracle cross-checks against sas-lib@1.0.10.

- verify-attest-pda.mjs: derives the attestation PDA via sas-lib's
  deriveAttestationPda for a fixed test vector.
- verify-attest-ix.mjs: builds the full create_attestation instruction
  via sas-lib's getCreateAttestationInstruction, prints accounts (order,
  isSigner, isWritable) + data hex for cross-checking against the Rust
  build_attest_ix output.

The Rust tests already cross-check the PDA against palinurus_core's
find_program_address (oracle-verified in substrate slice 1) and the ix
data against CreateAttestationIxData (oracle-verified in substrate slice
3). These TS scripts provide a secondary full-instruction cross-check.
….1.0

palinurus-core is now published on crates.io (v0.1.0). Replace the git
dependency (RECTOR-LABS/palinurus#main) with the crates.io version
`palinurus-core = "0.1"`. Cleaner for upstream PR reviewers — no fork git
URL, standard registry resolution.

Verified: 68 host tests green, wasm32-wasip2 build clean, clippy clean.
Cargo.lock re-resolved (palinurus-core v0.1.0 from registry).
…wasm clean

Slice A (PLAN-3). Establishes the plugins/depin-rewards/ crate mirroring
depin-attest's layout exactly (hard req zeroclaw-labs#1): standalone [workspace] cdylib+rlib,
manifest.toml (http_client + config_read), thin #[cfg(target_family="wasm")]
wit shim with real name/description/parameters_schema + placeholder execute
(wired in slices C–G), empty pure core.

TDD slice A production logic (all in src/depin_rewards.rs, host-tested via
MockHttp — no wasm dep, no live network):
- HttpClient trait (Bearer GET + form POST) — object-safe, the pure core takes
  &dyn HttpClient so the shim can swap MockHttp (host) for waki (wasm).
- MockHttp (RefCell-based, compiles for wasm32-wasip2) — scripted GET/POST
  responses + recorded POST calls for assertion.
- HttpError + RewardsError enums (specific, actionable, Debug).

6 host tests green, clippy -D warnings clean, wasm32-wasip2 build clean.
Custody posture established: no signing key anywhere in the crate (grep-clean).
Slice B (PLAN-3). RewardsConfig::from_section parses the flat config_read
section: relay_api_key + relay_base_url (default api.relaywireless.com/v1) +
hotspots (JSON array, >=1 entry) + telegram_bot_token + telegram_chat_id +
poll_interval_minutes (default 120) + network (mainnet-beta|devnet).

Fail-closed validation: empty section, missing/empty required keys, malformed
hotspots JSON, empty hotspot array, empty-string entries, bad network,
non-numeric poll -> RewardsError::Config with a specific message.

enforce_hotspot_allowlist(cfg, id) rejects hotspot ids not in the configured
list (wired into every action's entry in slice F).

Custody: RewardsConfig uses a manual redacting Debug impl — relay_api_key +
telegram_bot_token never print (SPEC-3 sec 10). Claim-tx fields (rpc_endpoint,
claim_nonce_*) deferred to slice G (no premature Pal pubkey dep).

16 new host tests (22 total), clippy -D warnings clean, wasm32-wasip2 clean.
…_status

Slice C (PLAN-3). The first T0 read action.

HotspotInfo::parse — parses the Relay GET /helium/l2/hotspots/:id response
(owner, name, networks[], iot_info/mobile_info.is_active, iot location,
maker name) from a serde-derived Raw struct; tolerant of missing mobile_info
(iot-only) and missing maker. is_active() reduces {iot,mobile} booleans
(Some(true) if any active; Some(false) if all inactive; None only if both
unknown). primary_network() -> iot|mobile.

fetch_hotspot(http, cfg, id) — GET with Bearer auth; HTTP status codes map to
specific RewardsError::Relay messages (404 not found, 402 quota exhausted, 429
rate-limited, 5xx server error); other transport errors pass through as
RewardsError::Http.

do_status(http, cfg, id) — enforce_hotspot_allowlist -> fetch -> shape_status
(<=200-token summary: name + ONLINE/OFFLINE/UNKNOWN + net + owner short +
networks + maker). Returns RewardsOutput { is_active, summary }.

MockHttp gains set_get_err (inject an HttpError for a URL) to test the
status-code mapping. HttpError now derives Clone.

Fixture tests/fixtures/hotspot-iot-online.json — schema-accurate Relay response
(to swap for a real captured hotspot once RECTOR has a Relay key). serde (derive)
dep added; palinurus-core deferred to slice G (claim tx) where it's substantive.

8 new host tests (30 total), clippy -D warnings clean, wasm32-wasip2 clean.
Slice D (PLAN-3). The second T0 read action (daily rewards summary).

RewardSummary::parse_totals — parses the Relay GET
/helium/l2/{iot|mobile}-reward-shares/totals response (total_amount +
beacon/witness/dc-transfer breakdown). The /totals endpoint is the clean
primary for the daily-summary use case (one call, all numbers); the list
endpoint's deeply-nested per-reward amount path needs real-response
verification (Relay key) and is deferred.

fetch_rewards(http, cfg, net, hotspot_key, from, to) — GET with Bearer auth;
status codes map as in fetch_hotspot (404/402/429/5xx → RewardsError::Relay).

format_amount(atomic, decimals) — human 2dp rounded string (3_420_000 ->
3.42; 0 -> 0.00; 999_999 -> 1.00). Never emits raw u64.

do_summary(http, cfg, id, from, to) — allowlist -> fetch_hotspot (name+net) ->
fetch_rewards -> shape_summary (earned X.XX IOT + beacon/witness/dc breakdown
+ owner short, <=200 tokens).

4 new host tests (34 total), clippy -D warnings clean, wasm32-wasip2 clean.
… summary)

Slice E (PLAN-3). The cron-tick workhorse — the Real-utility 30% core.

send_telegram(http, cfg, text) — POST https://api.telegram.org/bot<token>/
sendMessage (chat_id + text) via the Bot API; parses the {"ok":true} ack,
ok=false -> RewardsError::Telegram with the description. Bot token + chat_id
sourced from config, never the message (custody: a malicious message cannot
redirect alerts to an arbitrary chat).

do_watch(http, cfg, id, prev_active, send_summary, from, to) — the workhorse:
  1. allowlist + fetch_hotspot -> current is_active.
  2. offline-flip: prev_active=Some(true) && current=Some(false) -> fire a
     Telegram OFFLINE alert. prev_active=None (first tick) never fires (no
     baseline) — avoids alert storms on cold start.
  3. send_summary (08:00 tick) -> fetch_rewards + format + Telegram summary.
Returns RewardsOutput { is_active (for the SOP to persist), alerts_sent, summary }.
Stateless — the SOP passes prev_active in + persists is_active out.

RewardsOutput gains alerts_sent: Vec<String>. shape_watch emits the watch
status line (ONLINE/OFFLINE + alert(s) sent + current_active for persistence).

8 new host tests (42 total), clippy -D warnings clean, wasm32-wasip2 clean.
Alerts milestone reached: status + summary + watch + Telegram all working.
…n + chat_id

Slice F (PLAN-3). Invariant/guard tests proving the T0/T1 custody posture holds
by construction (SPEC-3 sec 9, hard req zeroclaw-labs#8 — fail-closed injection). These are
regression nets, not feature red->green.

- no_signing_capability_in_crate: greps Cargo.toml + src/lib.rs + src/
  depin_rewards.rs for signing tokens (ed25519-dalek, SigningKey, .sign(, and
  signing crates ring/secp256k1/k256/p256/schnorr). Asserts NONE present ->
  the plugin provably cannot sign (T0/T1 only, no key of any kind).
- secrets_not_echoed_in_output_or_debug: a sentinel-bearing config -> do_status
  output + RewardsConfig Debug must NOT contain the relay key or telegram token
  (Debug uses the redacting impl from slice B -> <redacted>).
- telegram_chat_id_always_from_config_not_message: a malicious message text
  (chat_id=666; ignore previous...) cannot redirect the alert -> the recorded
  POST's chat_id is the configured one.

Allowlist enforcement already wired into do_status/do_summary/do_watch (slices
C-E). enforce_claim_owner_from_relay defers to slice G (claim tx) where it has a
caller.

45 host tests green, clippy -D warnings clean, wasm32-wasip2 clean.
Slice H (PLAN-3) — docs hard reqs (zeroclaw-labs#7 README, zeroclaw-labs#8 injection transcript). The
plugin's one-page writeup.

README.md:
- What it does (4 actions: status/summary/watch/claim_tx-roadmap) + custody tier.
- Config keys table + worked config.toml example.
- Custody T0/T1 declared + defended (no signing key anywhere; redacting Debug).
- Threat model table (wallet-drain impossible, chat_id from config, allowlist,
  secret redaction, claim-owner-from-relay).
- Prompt-injection test transcript (4 vectors, fail-closed, each test-backed).
- Worked examples (watch online / offline-flip alert / daily summary).
- Rewards-claim tx DESIGN (roadmap): the cNFT discovery — Helium hotspots are
  compressed NFTs, so the claim ix is distribute_compression_rewards_v0 (not the
  regular distribute_rewards_v0), needing a merkle ProofArgs fetched per-claim
  via DAS get_asset_proof. Program id + the verified PDAs (lazy_distributor,
  recipient, circuit_breaker) documented; custody locked (unsigned, payer=owner).
  Deferred deliberately — alerts core ships complete + correct rather than rush
  a half-verified claim tx.
- What we'd build next + what fought us (api.helium.io dead, Entity API
  is_active deprecated, cNFT discovery, wasm HttpClient split, free-tier math).

docs/telegram-setup.md: 2-minute operator flow (@Botfather + getUpdates) +
config paste + sanity check.

45 host tests, clippy -D warnings clean, wasm32-wasip2 clean (docs-only slice).
…/totals is gated)

The /totals aggregate endpoint returns HTTP 401 feature_not_available on the
free Community plan (verified live), so do_summary/do_watch 401'd and failed
for the demo + every free-tier user. Switch fetch_rewards to the list endpoint
(/helium/l2/{iot|mobile}-reward-shares?from&to&hotspot_key&per_page=100) and
sum per-record reward_detail client-side.

Also corrects the reward denomination: Relay returns HNT bones (10^8), not
IOT/MOBILE at 10^6 — shape_summary/do_watch now format at 8 decimals labeled
HNT (manifest token = *_reward_token_hnt; formatted_* fields confirm 10^8).

- RewardSummary::parse_totals -> from_records (sums beacon/witness/dc,
  saturating_add, tolerant of missing reward_detail, single-page cap noted)
- +3 tests: list-endpoint regression guard, real Relay fixture (Capybara),
  missing-detail robustness
- 3 real fixtures captured live: hotspot-iot-real-offline, rewards-iot-real,
  hotspot-404 (reward-key that 404s on get-hotspot -> do_status must handle)

48 tests, clippy -D warnings clean, wasm32-wasip2 clean.
…acon/witness/dc)

Live check revealed mobile reward_detail carries a single 'amount' (+
formatted_amount + unallocated_reward_type), whereas iot carries beacon_amount
+ witness_amount + dc_transfer_amount. The iot-only sum silently reported 0.00
for every mobile hotspot (silent failure). from_records now sums all present
fields (amount for mobile, beacon/witness/dc for iot) so total_amount is correct
for either network; breakdown fields stay 0 for mobile.

- shape_summary: show the beacon/witness/dc breakdown line only when non-zero
  (avoids misleading '0.00 . 0.00 . 0.00' for mobile / zero-reward days)
- +1 test: mobile shape sums amount into total
- mobile amount scaling (presumed 10^8, consistent with iot) NOT yet verified
  against a filtered hotspot_key sample — documented in from_records; verify
  before mobile production use. iot path fully verified live.

49 tests, clippy -D warnings clean, wasm32-wasip2 clean.
The repo had no .env rule. The per-repo .env (Relay key, Telegram token) is
kept symlinked to ~/Documents/secret and never committed; this makes that
intent explicit and prevents an accidental 'git add .' from leaking creds.
.env.example is kept trackable (!.env.example) for a future template.
Host-only demo driver ('cargo run --features demo --bin palinurus-demo')
that runs the shipped pure core against live services on camera — the
recording unblocker for the Phase 5 demo video (see
palinurus/docs/demo-recording-guide.md).

Cargo: new 'demo' feature (off by default) gating an optional reqwest
(blocking, rustls) dep + a [[bin]] palinurus-demo. 'cargo build --target
wasm32-wasip2' (no feature) and the 49 core tests are unaffected — verified.

Slice 1 — ReqwestHttp: reqwest-backed impl of the pure-core HttpClient
trait (Bearer GET + form POST). Error mapping: reqwest transport err
-> HttpError::Transport; non-2xx -> HttpError::Status(code, body).
TDD: 3 mockito tests (GET sends Bearer + returns body; non-2xx maps to
Status(404); POST form encodes fields + returns body). All green.

demo_rpc.rs is a stub for slice 2 (ReqwestRpc impl of palinurus_core::Rpc
for the attest beat's execute_t1). palinurus-demo.rs is a stub for slice 3
(the bin that wires env -> config -> the tested actions -> printed lines).
…ice 2)

The on-camera harness for chunks 2-5 of the recording guide:
  cargo run --features demo --bin palinurus-demo -- [status|summary|watch|custody|all]

Builds RewardsConfig via from_section (the real config-validation path,
fail-closed) from env, then runs the SHIPPED pure core over ReqwestHttp
against live Relay (+ Telegram for watch). Subcommand selector keeps
status/summary/custody safe to smoke (no Telegram) and 'watch' explicit
(it fires the real alert — RECTOR's on-camera step).

Default 'all' = status + summary + custody (no watch) for a safe smoke.

Live-verified against the real Capybara hotspot:
  status   -> 'OFFLINE (iot) · owner 99Yu...ZCUm · maker SenseCAP'
  summary  -> 'earned 0.02 HNT [iot] · beacon 0.01 · witness 0.01 · dc 0.00'
  custody  -> no-signing-key one-liner

The hero number (0.02 HNT) is LIVE, matching the recording guide.

Removed the demo_rpc stub — the attest beat (chunk 6) is a separate driver
in plugins/depin-attest (the two plugins are standalone [workspace]
crates; each carries its own demo driver). Recording guide updated next.

clippy -D warnings clean (--features demo); 49 core tests + 3 demo tests
green; wasm32-wasip2 build unaffected (no feature).
The on-camera harness for chunk 6 of the recording guide:
  cargo run --features demo --bin depin-attest-demo

Runs the SHIPPED execute_t1 pure core over a reqwest-backed Rpc
(ReqwestRpc impl of palinurus_core::Rpc) against the LIVE devnet
durable-nonce account, with a SIMULATED sensor reading. The reading is
fake; the Solana side is not: real create_attestation ix, real attestation
PDA (recomputable), real durable nonce as blockhash, real unsigned
versioned tx base64 (pastable into the explorer tx inspector), real
explorer link.

ReqwestRpc reuses the shared pure parse layer (rpc_request/rpc_result/
parse_account_info from palinurus-core). The 3 unused trait methods
(get_latest_blockhash / get_signatures_for_address / send_transaction)
return a clear 'not used by the T1 demo' error — execute_t1 only calls
get_account_info. TDD: 3 mockito tests (get_account_info parses response;
null -> None; send_transaction unsupported). All green.

Config defaults to the devnet artifacts provisioned 2026-07-21 (credential
feWL... + nonce 9Kaivz..., authority = shared devnet wallet) — env-
overridable. No secrets in the bin (devnet pubkeys + public RPC; T1
unsigned so no keypair needed).

Live-verified: execute_t1 -> attestation PDA 52QV...D3uW, real explorer
link, real unsigned durable-nonce tx base64. The rewards beat (chunks 2-5)
is the separate palinurus-demo driver in plugins/depin-rewards.

clippy -D warnings clean (--features demo); 68 core tests + 3 demo tests
green; wasm32-wasip2 build unaffected (no feature).
…ied devnet)

Extends the demo driver with a T2 mode (ATTEST_MODE=t2) that runs the shipped
execute_t2 pure core against the live devnet durable-nonce account, SIGNS +
SUBMITS, and prints the real explorer link. The on-camera harness now proves
the full custody path end-to-end on real Solana — not just an unsigned draft.

New demo-host glue (all TDD'd, host-only behind the 'demo' feature):
- ReqwestRpc::send_transaction: POST a JSON-RPC sendTransaction with the tx
  bytes base64-encoded + {encoding: base64}, return the result string (the
  tx signature) via the shared palinurus-core parse_send_tx layer. T1 never
  calls this (the no-submit invariant lives in the pure core, which the 68
  core tests assert: T1 output has 0 signatures).
- load_session_key_b58: read a Solana keypair JSON file (64 bytes) and return
  the 32-byte SECRET as base58, so it flows through the existing fail-closed
  AttestConfig::from_section. Fail closed on wrong length / non-array.
- bin T2 mode: ATTEST_MODE=t2 + ATTEST_SESSION_KEYFILE (Solana CLI keypair).
  The identity guard requires verifying_key == authority == payer ==
  nonce_authority, so for the devnet demo the session key IS the shared
  devnet wallet (FGSk...BWWr) wearing all four hats. Real deployments derive
  a separate scoped session key.
- ATTEST_MEMO_FALLBACK env: switch to the memo program (the README's default
  cheap path) — sidesteps the SAS schema-creation 0x4 TODO while still landing
  a real on-chain attestation via the full custody path.

Verified live on devnet (2026-07-21):
  ATTEST_MODE=t2 ATTEST_MEMO_FALLBACK=true ATTEST_SESSION_KEYFILE=...     cargo run --features demo --bin depin-attest-demo
  → signature 65UzT3h1...APir, Confirmed (err=None), slot 477806741,
    fee 5000 lamports (within 10k cap), version 0 (versioned tx → durable
    nonce), nonce advanced F3tGxZwV... → HxmL2Nu7... (replay guard live).
    Explorer: explorer.solana.com/tx/65UzT3h1...APir?cluster=devnet

README worked example now leads with this REAL on-chain T2 attestation
(replacing the stale never-submitted T1 PDA), with an honest SAS-vs-memo
note: the SAS create_attestation ix is built + cross-checked (TS oracles)
but on-chain SAS landing is blocked on schema creation (SAS 0x4, TODO).

Tests: 6 demo_rpc (was 3) + 68 core = 74 host tests. clippy -D warnings
clean (--features demo). wasm32-wasip2 build clean (no feature, unchanged).
… pure core

The depin-rewards WIT shim's execute() was a slice-A scaffold stub that
returned 'execute not wired yet (lands in slices C-G)' for every action. The
pure core (do_status/do_summary/do_watch, 49 tested) + the demo driver worked,
but the actual WIT plugin entry point — the thing built to wasm + installed —
was never wired. A judge who built + called the rewards component got 'not
wired'. This sank Real utility (30%) + Code quality 'clean shim' (20%) +
Merge-readiness (15%) = 65% of the rubric. Caught in the pre-recording audit.

Fix (mirrors depin-attest's architecture):
- execute_entry + RewardsRequest (pure core, host-testable dispatch seam): routes
  action -> do_status / do_summary / do_watch; claim_tx -> honest fail-closed
  ('roadmap-only: Helium cNFT compression path + DAS merkle proof, not yet
  shipped — alerts core ships complete'); unknown action -> error. TDD: 3 new
  dispatch tests (status routes, claim_tx honest error, unknown action).
- WakiHttp (src/waki_http.rs, #![cfg(target_family="wasm")]): waki-backed impl
  of the HttpClient trait (Bearer GET + form POST, non-2xx -> HttpError::Status).
  Mirrors palinurus_core::WakiRpc; verified by the wasm32-wasip2 build (waki is
  wasm-only, no host test).
- lib.rs: declare waki_http module; replace the stub execute with real wiring
  (parse args + __config -> RewardsConfig::from_section -> WakiHttp ->
  execute_entry -> shaped ToolResult + structured logging).
- description(): fix stale 'distribute_rewards_v0' -> honest 'compression path,
  roadmap-only' to match the README.
- Cargo.toml: add waki = 0.5 (json) — compiles for host (harmless), only
  constructed in the cfg-gated wasm shim.

Verify: 52 core tests (49 + 3 dispatch) + 3 demo = 55 host tests green.
clippy -D warnings clean (both feature modes). wasm32-wasip2 build clean — the
component now actually functions (WakiHttp compiles, execute wired).
depin-attest: 68→74 host tests; worked-example sig now BsdBnMtJ (matches the
PR/screenshots) with 65UzT3h1 noted as the earlier real run — two real signed
attestations, not one.
depin-rewards: 45→55 host tests (52 core + 3 demo, post C2 shim-wire dispatch tests).
…te, single-tool note

- [profile.release] overflow-checks = true in both plugins (amount math:
  lamports + HNT bones 10^8 — silent overflow is a real footgun; defense in depth).
- tests/injection.rs in both plugins: the curated, executable prompt-injection
  transcript (bounty hard req zeroclaw-labs#8), grep-discoverable. depin-attest runs the
  README's 4 attack vectors through the real custody guards; depin-rewards runs
  them through execute_entry (the C2-wired WIT dispatch seam) — proving the wired
  path enforces the guards end-to-end, not just the guards in isolation.
- depin-rewards README: a one-line justification for the single-tool / action-
  field design (one data source, one custody tier, one discovery surface — not a
  god-tool across capabilities/tiers), defusing the 'action enum' concern.
- Counts: attest 74 -> 78 (72 core + 6 demo); rewards 55 -> 58 (55 core + 3 demo).
  clippy -D warnings + wasm32-wasip2 release clean (both crates, both modes);
  repo validation (build-registry.py + 17 unittests) still exit 0.
…y hardening)

Security audit F2: enforce_program_allowlist was program-level only, so a
System::Transfer (disc 0x02) would pass the guard even though System is in the
{System,SAS,Memo} allowlist. Safe by construction (the T2 path only ever emits
AdvanceNonceAccount, auto-prepended by build_with_durable_nonce), but value
transfer must be unexpressible at the GUARD, not merely by construction — the
README/PR claim 'a value-transfer instruction is not expressible' is now true
at the guard layer.

Hardening: any System-program ix must be AdvanceNonceAccount
(data[0..4] == [0x04,0x00,0x00,0x00]); any other System variant (Transfer,
CreateAccount, Assign, Withdraw, Authorize, ...) or a short ix is rejected.
SAS + Memo stay program-level (neither moves SOL). The auto-prepended nonce
advance is constructed by palinurus-core (trusted); this guards the user_ixs.

TDD: 3 new injection tests (system_transfer_rejected, short_system_rejected,
system_advance_nonce_allowed) + updated allowlist_allows_system_sas_memo to
use the AdvanceNonceAccount disc. 81 attest tests (was 78), clippy -D warnings
+ wasm32-wasip2 release clean. Audit: palinurus/docs/security-audit.md (F2).
…YTES=566)

Security audit F5: build_memo_ix accepted an unbounded attacker-supplied memo
(LLM args). No fund risk, but a huge memo could blow past Solana's 1232-byte tx
limit (RPC reject / wasted nonce read) or bloat the tx. Added validate_memo,
wired into all three execute paths (t1, memo_fallback, t2) before any tx build
or RPC call. TDD: execute_t1_rejects_oversized_memo. 82 attest tests, clippy +
wasm clean. Audit: palinurus/docs/security-audit.md (F5).
rz1989s added 8 commits July 22, 2026 14:33
…-in-depth)

Security audit F6: execute_t1/memo_fallback/t2 parsed the nonce account without
checking its on-chain owner == System. The configured nonce_account is operator-
trusted (and a non-System account fed to AdvanceNonceAccount fails on-chain),
but verify the owner anyway as cheap defense-in-depth — reject before parsing.
TDD: execute_t1_rejects_non_system_nonce_owner (owner=SPL Token → NonceAccount).
83 attest tests, clippy + wasm32-wasip2 release clean. Audit: security-audit.md (F6).
…-depth)

Security audit F4: the base58-decoded session-key bytes (key_bytes Vec + the
32-byte arr) lingered on the stack after SigningKey::from_bytes. Zeroize both
immediately after the SigningKey is constructed so the seed doesn't outlive the
key (the SigningKey itself is dropped at end of execute, not persisted across
calls; wasm-sandboxed). Adds zeroize = 1 (tiny, wasm-friendly). No behavior
change — 83 tests, clippy + wasm clean. Audit: security-audit.md (F4).
…onale (audit accept)

Security audit F7: SensorReading::encode's borsh::to_vec().expect() panics only
if serialization or allocation fails — impossible for a fixed-shape struct of
infallible borsh types (String/f64/i64/String) into a <100-byte Vec. The expect
is a deliberate ACCEPT of an unreachable panic, now documented in-code so a
reviewer sees the rationale instead of an unexplained expect. The build_unsigned
panics in the PUBLISHED palinurus-core are likewise unreachable for our <=10-
account ixs; a Result-returning variant is roadmaped for a future core bump.
No behavior change. Audit: security-audit.md (F7 — accepted with rationale).
… not guard)

Security audit F3: the disclosure said the daily cap is a 'soft bound' and the
'hard security boundary is the program allowlist' — but under-framed that the
cap is not a guard at all (the per-day counter comes from the attacker-supplied
timestamp, so it can be rolled; thread_local resets on reload). Sharpened to
state plainly: the daily cap is a rate-hint that bounds nuisance spam, not
funds; the real fund bounds are the program allowlist + the session-key lamport
balance. The hard, tamper-proof cap (on-chain counter PDA) stays in 'What we'd
build next.' No code change — documentation honesty. Audit: security-audit.md (F3).
The T2 section now leads with the real mainnet attestation (sig YZTS16nN...3G9TC,
slot 434472270, Finalized, Mainnet Beta, memo palinurus: bme280-1=24.7celsius @
1784707747 — no ?cluster=devnet in the URL). Devnet (BsdBnMtJ... + 65UzT3h1...)
stays as the worked-example + the on-camera demo. Custody line updated to note
the dedicated mainnet wallet DZdeez...7RRC + instruction-level allowlist
(only AdvanceNonceAccount).
cargo lock now records the zeroize dep added in the F4 audit fix (zeroize the
raw session-key bytes). Regenerated by the build; keeps --locked green.
The demo driver previously required manual `source`/`set -a` to export
RELAY_API_KEY (the .env has no `export` prefix) — fiddly on camera and
error-prone. Add `dotenvy` (optional, demo-gated) and call
`dotenvy::dotenv()` at the top of main() so a per-repo `.env`
(gitignored, symlinked to ~/Documents/secret/) auto-loads. Real env vars
still win. The wasm component is untouched (dep is optional + demo-only).

58 tests still pass; clippy --features demo clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant