Pellet Docs

Methodology

Every formula, threshold, and on-chain check Pellet uses. Versioned and public.

This document specifies how Pellet decodes Tempo activity and how Pellet Wallet enforces caps. Versioned so changes don't silently mutate historical interpretations.

Methodology version: 0.2 (2026-04-29) Wallet methodology: Phase 4 / 5 release (testnet) (2026-04-29)


Decoding Tempo activity

Scope

Pellet's lens is the public Tempo ledger:

  • In scope: every TIP-20 stablecoin transfer on Tempo mainnet (Presto) — peg, supply, policy, reserves, role holders, flows. Plus the Moderato testnet for development surfaces.
  • In scope: the Tempo MPP Gateway escrow contract (0x33b901018174ddabe4841042ab76ba85d4e24f25) — Settlement events emitted there give per-provider attribution.
  • In scope: the AccountKeychain precompile (0xaAAAaaAA00000000000000000000000000000000) — when Pellet Wallet authorizes an agent key, that's a public on-chain event you can audit.
  • Out of scope: zone-internal transfers (private parallel ledgers by design).
  • Out of scope: off-chain issuer operations, private counterparty agreements.

Where a metric cannot be measured under this scope, the response returns null with an explanatory note — never a fabricated estimate.

Pipeline

Tempo chain
  ↓ /api/cron/ingest (every 6h)
events table (raw logs, idempotent by tx_hash + log_index)
  ↓ /api/cron/match (every 6h, methodology v0.2)
agent_events table (decoded, joined to watched agents.id, summary built)
  ↓ /api/cron/attribute (every 6h)
agent_events.routed_to_address    (Pattern A)
agent_events.routed_fingerprint   (Pattern B)
  ↓ LEFT JOIN address_labels (by address OR by 'fp_<hex>')
recentDecoded / serviceDetail / providerDetail / dashboardSnapshot

Pattern A · Settlement event attribution

When a gateway tx forwards funds to a provider, the escrow contract 0x33b9…4f25 emits a custom event keyed at topic 0x92ed5fe0fe56b3f4185e688efb342e92a4492b9df29ad5de596c44e64d097b51:

event Settlement(
  bytes32 indexed sessionRef,
  address indexed serviceProvider,    // ← topic[2] is what we want
  address indexed gateway,
  uint256 amountIn,
  uint256 amountOut
);

/api/cron/attribute walks each unprocessed gateway tx, finds the matching log via SQL JOIN against the events table (Settlement events ingested at the source — see Phase 3.B research), extracts topic[2], persists as routed_to_address. Coverage: ~9% of gateway txs.

Pattern B · Calldata fingerprint attribution

User→gateway txs use the USDC.e selector 0x95777d59 (transferWithMemo) with args (address recipient, uint256 amount, bytes32 ref). The bytes32 ref has stable structure:

0xef1ed71201 + <10-byte service fingerprint> + 0000000000000000 + <7-byte per-call nonce>
   magic        deterministic per service        zero pad           per-call

Bytes 5–14 are a deterministic per-service fingerprint set by Tempo's MPP client. We persist them as routed_fingerprint so all gateway txs cluster by service even when the provider address can't be recovered. Coverage: ~91% of gateway txs (combined with Pattern A: essentially 100%).

Provenance

Every agent_events row carries:

  • methodology_version — bumped when the matcher's logic changes
  • source_block — chain block the original event was emitted in
  • tx_hash + log_index — exact event reference
  • matched_at — when our matcher decoded it

Re-deriving any number is a matter of running the matcher against the raw events table — no opaque steps, no off-chain inputs.


Wallet

Trust model

Pellet Wallet doesn't claim to defeat all threat models. It claims to enforce a specific cap-bounded autonomy guarantee:

Within the KeyRestrictions the user signs at pairing time, the agent can spend autonomously. Above those restrictions, both server-side validation AND on-chain enforcement reject.

On-chain (Tempo AccountKeychain):

  • enforceLimits=true rejects any call that would exceed the TokenLimit.amount (cumulative across the period)
  • period > 0 auto-resets the cap (e.g. 86400 for daily reset)
  • expiry rejects any call after the timestamp
  • allowAnyCalls=false rejects any call to a target not in allowedCalls
  • Each CallScope.SelectorRule rejects any call whose 4-byte selector or recipient isn't in scope

Server-side (Pellet's /api/wallet/pay):

  • Bearer token validated against wallet_sessions.bearer_token_hash (sha256)
  • wallet_sessions.revoked_at IS NULL
  • wallet_sessions.expires_at > now()
  • wallet_sessions.authorize_tx_hash IS NOT NULL (on-chain authorize completed)
  • amount_wei ≤ per_call_cap_wei
  • spend_used_wei + amount_wei ≤ spend_cap_wei

The chain is the source of truth; the server checks fail fast to avoid wasted gas.

What protects what

Compromise scenarioWhat still protects user funds
Server compromise (Pellet)All on-chain caps still enforced. Worst case: attacker holds the encrypted agent session keys but can't decrypt without WALLET_MASTER_KEY. Even with that key: caps + expiry + scope still bound the blast radius.
WALLET_MASTER_KEY leakAttacker can decrypt agent session keys. But AccountKeychain caps enforce on-chain; expiry auto-revokes; selector scoping blocks anything but transferWithMemo on USDC.e. Worst case: cap-period worth of payments.
Browser compromise during pairingA malicious browser could try to authorize an attacker-controlled agent key. Phase 3.B.3 receipt verification mitigates: /approve-finalize re-fetches the tx receipt from chain and confirms to=AccountKeychain, from=user.managed_address, status=success before issuing a bearer. A forged tx hash fails.
Bearer token theftAttacker can spend up to the caps until expiry. No protection beyond on-chain caps + expiry. Treat ~/.pellet/config.json as a credential.
User loses passkeyWallet is irrecoverable in v0. Day-one mitigation: register a second passkey at enroll time. Phase 5+: smart-account guardian wallets.

Receipt verification

/api/wallet/device/approve-finalize calls eth_getTransactionReceipt on Moderato RPC, polls 5x with 1.5s backoff, and fails closed unless:

  • receipt.status === "success" — tx didn't revert
  • receipt.to === ACCOUNT_KEYCHAIN_ADDRESS — call hit the precompile
  • receipt.from === user.managed_address — sender matches the authenticated user

Defends against a malicious browser POSTing a forged hash to obtain an unauthorized bearer.

Session-key cryptography

  • Generation: server-side, fresh secp256k1 private key per pairing via viem/accounts.generatePrivateKey()
  • Encryption at rest: AES-256-GCM with WALLET_MASTER_KEY (32 bytes base64url, sensitive Vercel env var). Ciphertext shape: 12-byte IV ‖ 16-byte tag ‖ 32-byte ciphertext = 60 bytes total, persisted as wallet_sessions.session_key_ciphertext (bytea).
  • Wire transmission: unencrypted private key returned in /approve-init JSON response, over TLS, to the authenticated user only. Browser uses it to construct viem's Account.fromSecp256k1 for the authorize call. In-memory only browser-side; discarded after the call.
  • Decryption at use: /api/wallet/pay decrypts on demand to sign a payment, then drops the cleartext from memory.

Future hardening

  • Mainnet sponsor. Tempo doesn't run a public mainnet sponsor; we'll run our own. Until then mainnet release is paused.
  • Recovery. Register a second passkey at enroll time as a guardian. Phase 5+.
  • TEE / MPC custody. AES-256-GCM at rest is reasonable for testnet + small-cap mainnet. For high-cap production accounts, key custody should move to a TEE (Privy/Turnkey pattern) or MPC (Para/Lit pattern).
  • Server-side revoke. Enforced. The bearer-auth path checks wallet_sessions.revoked_at and rejects revoked sessions with 403.

Source