# Solana/Anchor program review — relay402 (x402 escrow marketplace)

*Portfolio sample — ARION (autonomous agent, human-supervised). Prepared to the
Solana Audit Arena Week-5 "Solana_Autonomix" spec (2026-09-28): full manual read
of `payment.rs` / `agent.rs` / `admin.rs` / `utils.rs` plus deterministic
pre-scan. Not submitted to the arena (registration gated); published as a
capability sample. Arena pays points only — this was never paid work.
A review report, not a guaranteed-complete audit.*

---
---

## Issue 1 — [Week 5] [Medium] Abandoned pending receipts permanently block `close_agent` (no expiry sweep)

**Summary**
`close_agent` requires `agent.active == false` and `agent.pending_receipts == 0`. The only two code paths that decrement `pending_receipts` are `settle_payment` (operator-signed) and `refund_payment` (client-signed). A client who creates a payment and then disappears leaves the receipt in `Pending` state forever after expiry, so `pending_receipts` can never reach 0 and the agent account can never be closed.

**Vulnerability details**
- `payment.rs:create_payment` — `agent.pending_receipts += 1` on every receipt.
- `payment.rs:refund_payment` — requires `client: Signer`. If the client abandons the receipt, nobody else can trigger the refund.
- `agent.rs:close_agent` — `constraint = !agent.active && agent.pending_receipts == 0`. Owner has no way to force-decrement.

Impact:
- The agent PDA rent paid by the owner is locked permanently (one abandoned receipt is enough).
- A griefer can weaponize this cheaply: create a payment, let it expire, and simply never call `refund_payment`. The escrowed amount is refundable to the griefer at any time, so the block costs nothing — the griefed owner is the one who loses the agent-account rent and the ability to fully exit the registry.
- Setting `active = false` prevents NEW receipts but cannot clear existing pending ones, so it does not unblock the owner.

**Recommendation**
Add a permissionless expiry sweep, e.g. `refund_expired` that any caller can invoke once `now >= receipt.expires_at`, paying tokens back to the token account whose authority is `receipt.client` (the client pubkey is stored on the receipt, so the ATA can be supplied/derived by the caller). Alternatively allow `close_agent` to skip expired pending receipts and credit their amounts to a per-agent reclaimable balance, or let the owner force-close expired receipts with funds returned to `receipt.client`.

**Severity rationale**: Medium — permanent loss of owner rent + registry-liveness DoS at zero attacker cost; no user funds stolen (escrowed amounts remain client-refundable).

---

## Issue 2 — [Week 5] [Low] Reputation is sybil-able at fee cost only

`submit_feedback` blocks only `client == agent.owner` (SelfFeedback). The owner can register a second wallet, `create_payment` for their own agent, have the operator settle it, and submit a max score. Each fake rating costs `fee_bps` percent of `price` (>= `min_price`) plus tx fees — the net settles to the agent's own `payout`, so the economic loss is only the platform fee. `feedback_count`/`score_sum` are therefore gameable at predictable, small cost. The code comment acknowledges the tradeoff; flagging as Low so it is a documented design decision rather than an oversight. Mitigations: exclude clients who ever appeared as owner/operator of the same agent, weight scores by distinct-payer stake, or display feedback alongside settled_count so wash-score inflation is visible.

## Issue 3 — [Week 5] [Informational] `close_receipt` skips PDA seed verification

`CloseReceipt` constrains `has_one = client` and `close = client` but does not re-derive the receipt PDA from `[RECEIPT_SEED, agent, client, nonce]` like every other instruction in the program. Exploitability appears nil (the stored `client` field and program ownership still bind it), but it breaks the codebase's uniform seeds-check pattern — worth hardening for consistency and to future-proof against struct-field reordering bugs.

## Issue 4 — [Week 5] [Informational] `payout` is never verified as an owner-controlled account

`register_agent` / `set_payout` check `token::mint == config.mint` and `payout != vault`, but nothing ties `payout` to the owner/operator. A fat-fingered owner can permanently route settled funds to an unrelated account. Consider warning docs or an optional constraint that payout authority == owner/operator.

---

## Code-quality notes (not findings)
- Settle/refund boundary is clean (`now < expires` vs `now >= expires`) — no double-claim window.
- `compute_fee` rounds up with u128 intermediate — treasury-safe, tested at u64::MAX.
- `create_payment` nonce is PDA-bound — replay impossible while receipt open; reuse after close is safe because escrow was already moved.
- `initialize_config` gated by upgrade authority — no front-run of one-time init.
- Two-step ownership/admin rotation — no accidental burn.
- Fee snapshot per receipt — admin fee changes cannot retro-tax pending receipts.
