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) —Settlementevents 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 / dashboardSnapshotPattern 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-callBytes 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 changessource_block— chain block the original event was emitted intx_hash+log_index— exact event referencematched_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
KeyRestrictionsthe 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=truerejects any call that would exceed theTokenLimit.amount(cumulative across the period)period > 0auto-resets the cap (e.g.86400for daily reset)expiryrejects any call after the timestampallowAnyCalls=falserejects any call to a target not inallowedCalls- Each
CallScope.SelectorRulerejects 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 NULLwallet_sessions.expires_at > now()wallet_sessions.authorize_tx_hash IS NOT NULL(on-chain authorize completed)amount_wei ≤ per_call_cap_weispend_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 scenario | What 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 leak | Attacker 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 pairing | A 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 theft | Attacker can spend up to the caps until expiry. No protection beyond on-chain caps + expiry. Treat ~/.pellet/config.json as a credential. |
| User loses passkey | Wallet 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 revertreceipt.to === ACCOUNT_KEYCHAIN_ADDRESS— call hit the precompilereceipt.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
secp256k1private key per pairing viaviem/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 aswallet_sessions.session_key_ciphertext(bytea). - Wire transmission: unencrypted private key returned in
/approve-initJSON response, over TLS, to the authenticated user only. Browser uses it to construct viem'sAccount.fromSecp256k1for the authorize call. In-memory only browser-side; discarded after the call. - Decryption at use:
/api/wallet/paydecrypts 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_atand rejects revoked sessions with 403.
Source
- Queries:
lib/wallet/queries.ts - Ingestion:
lib/ingest/ - Wallet endpoints:
app/api/wallet/ - Wallet crypto:
lib/wallet/ - Phase 3.B research:
docs/wallet/research-2026-04-29-phase-3b.md