Nectar Labs is testnet software with mock assets. It has not been audited. Do not use it with real funds.
Reporting a vulnerability
- Contact: kunal@nectarnetwork.fun, subject line
SECURITY: nectar-labs. Please include the commit, the component and the steps to reproduce. - Coordinated disclosure: please allow 90 days before public disclosure. We acknowledge reports within 5 working days.
- Bug bounty: none exists.
- Scope: the code in this repository and the testnet deployments in the README table "Live on testnet".
- Out of scope: third-party services (NEAR, OutLayer, the Chain Signatures MPC signer, 1Click, Pyth, NEAR AI Cloud), denial of service against public RPC endpoints, and social engineering.
- The assets are mocks, but other people use the demo. Please do not drain or grief the deployments beyond what is needed to show the issue.
Policy source: docs/PRD.md §15 ("Disclosure"). The same policy is on the docs site under Security › Bug disclosure.
Audit status
| Component | External audit | Internal review |
|---|---|---|
nectar-core (NEAR, contracts/nectar-core) |
none | pre-submission review 2026-11-11 (below) |
mock-ft (testnet tokens only) |
none | same review (SR-27) |
nectar_vault (Solana, solana/) |
none | same review |
NectarVaultBase (EVM, evm/) |
none | same review |
quoter / vault_read (OutLayer WASM, quoter/) |
none | spot-checked only |
keeper, Feed, x402 (services/) |
none | same review |
web app, SDK and UI (apps/web, packages/) |
none | review 2, 2026-11-11 (below): web, SDK, copilot, privacy claims |
An audit is a PROD requirement before any mainnet deployment (PRD §18). The internal review is not an audit.
Pre-submission review (2026-11-11)
Full report: docs/work/security-review-v7.md. It was an automated multi-agent review: 102 agents, read-only, 60 candidate findings, 46 survived adversarial verification, 14 were rejected, and 33 remained after deduplication: 5 high, 8 medium, 20 low. Its main theme was remote settlement: several separate defects could leave a signed remote leg stuck, with the buyer's escrow spent and the collateral locked in the vault, and nothing inside the protocol could recover it. SR-1 and SR-2 were reachable on the live Solana devnet vault.
Decision D-027 (docs/DECISIONS.md) fixed every high and medium finding plus the cheap lows before submission. Interface: docs/work/interface-v7.md §E. Two independent reviewers then re-traced every finding at commit 6e51276. Contract: 19 fixed, 3 partial, no theft or double execution found. Vaults and keeper: mostly fixed, plus new items. The follow-up work orders (docs/work/wo-35-final-security-followups.md) fixed them: Solana nonce seeds include the authority (N1), the keeper accepts a nonce record as proof of a landed leg (N2–N4), remote triggers get a gas budget (a contract reviewer's item), and an EVM release must name the depositor as recipient (test_release_recipientNotDepositor_reverts). A live re-run then found an SR-5 regression: every remote loan was refused on 2.2.0-v7. It was fixed in 2.2.1-v7 (WO-36c).
What is deployed after the fix wave
| Component | State on 2026-11-11 | Source |
|---|---|---|
nectar-core-v2.nectar.testnet |
2.2.1-v7. Two code-only upgrades: 2.1.0 → 2.2.0 (tx 7J1DsrKP19HTd1c2AWuoRbW2AyCUR75nd8hLGwNuKPAr) and 2.2.0 → 2.2.1 (tx EfTCTQbX4Vq2YbQdPPKWik1s56CEkK6e4EW54Q3hw5ay), deployed code hash 3mv4LRm6mNtyDLkR8fmaiTQmpHc12KGdfd2BLsjovFvk. Built with cargo near build non-reproducible-wasm. The 2.2.1-v7 wasm rebuilt from a fresh clone with cargo near build non-reproducible-wasm matched the deployed code hash (3mv4LRm6…) on the verifier's machine; it is not the Docker reproducible build |
evidence/rollout/security-upgrade-20261111T070338Z.log, evidence/rollout/upgrade-221-20261111T075031Z.log; docs/EVIDENCE.md §G2b re-run after D-027 (recorded in b8784c1) |
usdc-mock, nvdax-mock |
mock-ft code-only upgrade (SR-27) |
same rollout log, step 3 |
Solana nectar_vault ENZaNFHosYeNhsjC85K73feMFwX4w8i1dzuhQ3PzpubW (devnet) |
in-place upgrade to the v2 account layout. No v1 positions were open. Last deployed in slot 521021977 | same rollout log, step 4; solana/deploy.md |
| OutLayer quoter | version 5f9bb28796c6c355522444252be380f09e0a1067a453a6d48e06724f581bc062 pinned (vault_read reads the v2 layout) |
same rollout log, step 5 |
| EVM vault | redeployed on a local fork of Base Sepolia (anvil, chain id 84532), not on Base Sepolia (D-026): vault 0xC64b733424C8d25fB574F275007ED8756e261226, MockB20 0x634F88DAEf0a1f77645c523C3FA351Fe37aaf81d |
same rollout log, step 6 |
Re-runs on this stack, recorded by the verifier in docs/EVIDENCE.md on 2026-11-11 (fresh clones of commit 3ab3026):
- G2b, §G2b re-run after D-027 (recorded in b8784c1):
contract_version2.2.1-v7, the rebuilt code hash matches (row above), the keeper-v2 key and the views hold.pnpm verifywas not part of this re-run. - G7 on Solana devnet, §G7 re-run (recorded in b8784c1), checking evidence/G7/rerun/summary.md from both chains. Position 8: refused loan (aggregate limit), then release and withdraw. Position 9: funded loan, release, withdraw. Position 10: settle to the buyer, then the depositor withdraws the remainder.
- G13 on the local fork, §G13 re-run (recorded in b8784c1). Position 11 released, position 12 settled, and replayed calldata reverts with
NonceUsed(10)andNonceUsed(14). - G14 on testnet, §G14 re-run (recorded in b8784c1): the borrow (position #13) and the delegated mock-USDC payment are verified on chain. The verifier then ran the HTTP 402 → 200 step itself (§G14, 2026-11-11T13:54Z–13:55Z, fresh clone of 45ed1f3, recorded in 2e16f9b): unpaid GET → 402 with the route terms, borrow tx
7UFpexpd…(position #14), relayed delegate paymentEskZKj9s…, paid GET → 200 with the CSV (110 rows). That run used the demo agent from before the borrow guard (R2-05 below). The verifier then re-ran G14 on HEAD with the guarded agent (§G14, 2026-11-11T15:02Z, fresh clone of 48b5c5a, recorded in a677689):borrow_verifiedlogged before signing, borrow txE2suM2LC…(position #15), relay4vW2tPF5…, 200 with 123 CSV rows. - G11, §G11 re-run (recorded in 5083d30): 608 unit + 50 sandbox tests, mock-ft 23, model.csv 132 rows, max receipt 67.69 Tgas; forge 105 pass / 3 skip; Solana 10 cargo + 55 TS; keeper 293, x402 17, feed 63, sdk 178. The G1 check that every change method has a gas row then ran on 2.2.1 (§G1, same 45ed1f3 clone, recorded in 2e16f9b): FAIL. One owner setter,
remove_extra_remote_chain, had no gas row; the other 66 change methods measured below 100 Tgas (max 78.72 Tgas per tx), and the tested wasm equals the deployed code hash. WO-45 added the sandbox test (a60343b, no contract source change), and the verifier's re-check passed (§G1, fresh clone of 48b5c5a, recorded in a677689): 67 of 67 change methods have a gas row, max 78.60 Tgas per tx, 608 unit + 51 sandbox tests, nocontracts/*/srcchange since 2.2.1, tested wasm = deployed code hash. - G3b, §G3b re-run (recorded in 5083d30): e2e 392 pass / 82 skip, accessibility ≥ 90 on all 22 route/theme pairs. After the review 2 web fixes, G3b re-run 2 passed (see Review 2 below).
Status per finding
"Fixed" means the code change is merged and a reproducing test is in the repository. The test names below come from contracts/nectar-core/src/tests_v7c.rs / tests_v7d.rs (srN_*), solana/tests/nectar_vault.test.ts, evm/test/SecurityV7.t.sol and services/*/test. The verifier ran those suites on the deployed versions (docs/EVIDENCE.md §G11 re-run after D-027, recorded in 5083d30): every finding SR-1…SR-33 has at least one test named after it, and 124 sr*-named contract tests pass. The tests were run on the fixed code only, not on the pre-fix code to show that they fail there.
| Id | Sev | Finding (short) | Status | Evidence |
|---|---|---|---|---|
| SR-1 | high | Anyone could burn the vault-wide nonces (Solana, EVM) | fixed. Solana nonce PDA ["nonce", position, authority, nonce] (incl. N1); EVM nonceUsed[slotKey][nonce] |
"nonces burnt on the attacker's own slots…", "pre-burning nonces by … under the depositor's own key…"; test_SR1_v2_* |
| SR-2 | high | Keeper took any account at the nonce PDA as proof a leg landed | fixed. Only a program-owned NonceRecord with every field matching counts (incl. N2–N4) |
keeper tests "N2a…", "N2b…", "N3…", "SR-1: a nonce burned on another slot does not count as landed" |
| SR-3 | high | An expired, unrelayed signed remote leg had no recovery | fixed. resign_remote_settlement(position_id): anyone, after expiry, re-signs the identical leg with a fresh nonce; no rollback |
sr3_expired_signed_leg_resigns_identically_with_a_fresh_nonce, sr3_resign_refused_after_relay, sr3_base_resign_signs_a_fresh_digest_of_the_same_leg |
| SR-4 | high | Depositor or recipient could make every settle revert via its token account | Solana: fixed (pull payouts: the recipient claims when its ATA is unusable, the depositor withdraws). EVM: open, see "What remains" |
"depositor reassigns its ATA owner…", "recipient ATA reassigned: settle credits a claim…" |
| SR-5 | high | Remote loans sized against capacity that cannot settle them; truncate-before-filter | partial, see "What remains" | sr5_remote_limit_boundary, sr5_two_remote_loans_cannot_exceed_remote_capacity, sr5_unfit_quotes_cannot_crowd_out_a_fitting_one, sr5_borrow_capped_at_the_largest_fitting_quote |
| SR-6 | medium | Anyone could drain gas_reserve_yocto |
partial, see "What remains" | sr6_stranger_cannot_confirm, sr6_job_requests_are_keeper_or_owner_only, sr6_short_reserve_falls_back_to_p0_instead_of_blocking_the_rescue |
| SR-7 | medium | Remote recipients only format-checked | partial, see "What remains" | sr7_recipient_validity, sr7_funding_refuses_a_recipient_no_vault_can_pay, sr7_legacy_quote_with_a_bad_recipient_is_never_signed |
| SR-8 | medium | Vault position ids could be squatted | fixed. Depositor committed at reservation (remote.committed_depositor); Solana slots ["pos", vault, id, depositor]; EVM slots keyed by (id, depositor) |
sr8_squatted_slot_never_confirms_the_reservation, "an attacker opening the same id with another depositor…", test_SR8_v2_* |
| SR-9 | medium | Quoter deposit spent before checking that a settle can succeed | fixed. Pre-check, abort cooldown, owner write_off_bad_debt |
sr9_a_settle_that_cannot_succeed_never_spends_the_deposit, sr9_write_off_is_the_terminal_state |
| SR-10 | medium | Rounding dead end in underwater remote legs | fixed | sr10_underwater_rounding_seizes_every_unit |
| SR-11 | medium | Keeper looped on underfunded deposits | fixed. Compares against remote.reserved_units; doubling, capped cooldown |
keeper tests "top-level units are 0, the reserved amount binds…", "N4: an unchanged deposit is retried on a doubling, capped cooldown…" |
| SR-12 | medium | Keeper vault discovery could be blocked | fixed. Vault program and mint pinned from config | keeper test "SR-12: a look-alike vault under another mint changes nothing" |
| SR-13 | medium | One OutLayer calendar answer latched forced_closed |
fixed. An OutLayer close now expires | sr13_an_outlayer_answer_closes_for_a_bounded_time |
| SR-14 | low | Untrusted callers grow contract-paid storage | fixed. Minimum borrow, 8 open reservations per account | sr14_open_reservations_are_capped_per_account, sr14_remote_borrow_below_the_minimum_is_refused |
| SR-15 | low | Remote deposits not bound to a mint | partial, see "What remains" | sr15_a_vault_serves_one_collateral |
| SR-16 | low | Relisting a quote skipped the Reserve-discount check | fixed | sr16_top_up_cannot_relist_above_the_reserve_discount |
| SR-17 | low | owed_collateral had no claim path |
fixed. claim_owed_collateral(symbol) (1 yoctoNEAR) |
sr17_owed_collateral_claim_round_trip, sr17_claim_refused_while_paused |
| SR-18 | low | A short deposit was never recorded | fixed. Recorded, loan refused, releasable | sr18_short_deposit_is_recorded_refused_and_releasable |
| SR-19 | low | upsert_collateral could change a live asset's token or decimals |
fixed | sr19_token_change_refused_with_open_debt |
| SR-20 | low | Dust Q_c captured the buyers' share; fee index overflow risk |
fixed | sr20_dust_q_collects_no_premium_and_never_moves_the_index |
| SR-21 | low | Recipient change without 1 yoctoNEAR | fixed | sr21_recipient_change_needs_one_yocto |
| SR-22 | low | A depleted Reserve refused new deposits | fixed. Reserve epochs | sr22_join_on_a_depleted_reserve_mints_one_to_one |
| SR-23 | low | Refused Note transfer not returned if a settlement started meanwhile | fixed | sr23_a_refused_note_goes_back_even_if_a_settlement_started_meanwhile |
| SR-24 | low | Stale quote_refs locked Quote Notes |
fixed | sr24_an_aborted_run_leaves_no_lock_on_its_quotes |
| SR-25 | low | withdraw_claimable ignored pause |
fixed | sr25_withdraw_claimable_refused_while_paused |
| SR-26 | low | Anyone could force a release during the borrower's retry window | fixed | sr26_stranger_cannot_kill_the_retry |
| SR-27 | low | mock-ft free registration drained storage stake |
fixed and deployed (mock tokens upgraded) | sr27_free_registration_refused |
| SR-28 | low | Base keeper's non-attest mode looped forever | mitigated. Without attestation the keeper reports unsupported and sends nothing; the local fork uses the labelled keeper attestation |
keeper test "SR-28: without attestation or an OutLayer read…" |
| SR-29 | low | Base "nonce used" recovery scanned only 10,000 blocks | fixed. Scans from the reservation block | keeper tests "SR-29: …" |
| SR-30 | low | EVM expiry off by one second | fixed. Valid while block.timestamp < expires |
test_SR30_v2_settleAtExactExpiry_reverts |
| SR-31 | low | Keeper's vault program not pinned | fixed | keeper tests "SR-31: …" |
| SR-32 | low | x402 facilitator settled caller-chosen terms without auth | fixed. Bearer secret from a 0600 file, route terms pinned, binds 127.0.0.1; in-process is the default | x402 tests "every endpoint answers 401 without the shared secret…", "pins each route term…" |
| SR-33 | low | Feed preview said ok for borrows the contract refuses | fixed | services/feed/test/skill.test.ts "v2: above pre_liq but under the LTV cap fails with the contract reason and no call" |
Also fixed after the review: the "no-code phantom deposit" from the review's coverage gaps. The EVM constructor refuses a token with no code outside the B20 precompile prefix, and every transfer must return exactly one word of true (test_phantom_v2_*).
Review 2 (2026-11-11): web, SDK, copilot, privacy claims
Full report: docs/work/review-2-findings.md. One independent, read-only reviewer covered the web app API routes, every wallet signing site, the SDK, the UI modals, the copilot, the reference x402 agent and every privacy claim, at commit f0efa1e. It kept 12 findings (5 medium, 7 low) plus notes. Severities assume a public deployment; none put real funds at risk (testnet, mock assets). "Fixed" means merged with the test named in the row. Merges: WO-42a (2111b71), WO-42b (313f660), WO-42c (0bd0cb3), WO-43 (fb3f05c), WO-44 (b1931ea). No contract code changed. The verifier's G14 re-run on HEAD (48b5c5a, recorded in a677689) used the R2-05 guarded agent; The web fixes passed G9 run 3 (§G9 run 3, fresh clone of 3107a1a, recorded in 837cd12: R2-03, R2-04, R2-05, R2-06, R2-09 and R2-11 present in the code, file and line in that block; security headers on next start; the wallet selector opens in Chrome with 0 CSP console messages) and G3b re-run 2 (§G3b re-run 2, fresh clone of fd6592d, recorded in 5770fa7: e2e 404 passed / 0 failed / 88 skipped (desktop 229/0/17, phone 175/0/71), e2e/screens.spec.ts 76/76, Lighthouse 28/28 = 100, security headers present, pnpm verify green). G3b re-run 1 on 3107a1a had failed on screenshot tests only, fixed in WO-49.
| Id | Sev | Finding (short) | Disposition | Evidence |
|---|---|---|---|---|
| R2-01 | med | "Wallet addresses are never joined to IPs" was false; the third-party list was incomplete | fixed (copy), WO-42b + WO-43. The banner and landing now say "in logs we control" and that the NEAR and Solana RPC providers see your IP and the accounts you query. The privacy policy lists FastNEAR, Solana devnet RPC, GitHub raw / jsDelivr (wallet list), the chosen wallets and 1Click. Account-scoped views still go from the browser to the RPC (disclosed) | packages/ui/src/ConsentBanner.tsx, apps/web/components/landing/Landing.tsx, docs-site Privacy policy |
| R2-02 | med | "Private market-maker parameters are read inside a TEE" did not hold | fixed (copy) + disclosed, WO-42b, WO-42c, WO-43. The landing (app and landing/index.html) says the TEE prices with operator-set demo parameters, published in the docs, and that each settlement's discount reveals their product; same in the docs-site "Quoter (TEE)" Honest limits, README and SUBMISSION. Residual below |
apps/web/components/landing/Landing.tsx, apps/docs-site/docs/protocol/quoter-tee.md |
| R2-03 | med | The copilot's "verified" answer was not bound to the question or the model | fixed, WO-42a. New required checks: request_matches_input (the signed request equals the one the browser rebuilds from the notes it sent) and model_id (signed model = announced model). The DCAP verdict is still computed on the server (said in the panel) |
"R2-03: an exchange signed for other notes or another model is not verified, even with an accepted DCAP verdict" (apps/web/lib/copilot/verify.test.ts) |
| R2-04 | med | /api/copilot was an unauthenticated, unmetered LLM relay |
fixed, WO-42a. Same-origin only (Sec-Fetch-Site / Origin, 403), content-type: application/json (415), 8 KB body cap (413), 5 live calls a minute per client plus a global 30 a minute, fixture 30 a minute from a verdict computed once, max_tokens 400 set by the server, generic upstream errors |
"POST /api/copilot: 429 after 5 live calls from one client, other clients still served; fixture has its own bucket", "POST /api/copilot: a global ceiling holds when clients rotate x-forwarded-for" (apps/web/lib/api/api.test.ts); "/api/copilot refuses cross-origin, non-JSON and oversized bodies before doing any work" (apps/web/e2e/hardening.spec.ts) |
| R2-05 | med | The reference agent signed whatever the MCP server returned | fixed, WO-44. services/x402/src/borrow-guard.ts rebuilds the call from the agent's inputs and config.yaml, compares every field (collateral token, ft_transfer_call, nectar-core, amount, byte-equal msg with no pay_to / referrer / shield, 1 yoctoNEAR, gas ≤ 300 Tgas) and signs only its own rebuild; any mismatch exits 3 with nothing signed. agent1 still signs with a full-access key: a function-call key cannot attach the 1 yoctoNEAR (F-015) |
"refuses the review scenario: ft_transfer of the whole balance to an attacker on usdc-mock", "hostile MCP (ft_transfer to an attacker on usdc-mock): exit 3, clear message, zero RPC requests" (services/x402/test/borrow-guard.test.ts); EVIDENCE §G14 re-run on HEAD (recorded in a677689): borrow_verified before signing |
| R2-06 | low | The borrow review showed the typed string ("1,5" locked 15) | fixed, WO-42b. Commas are accepted only as thousands separators ("1,5" is refused); the review shows the parsed amount; the sign modal adds a decoded amount line for ft_transfer_call |
"R2-06 decoded amount for the sign modal" (packages/sdk/test/review2.test.ts); "review 2 (R2-06, R2-07): decimal comma refused; …" (apps/web/e2e/design-app.spec.ts) |
| R2-07 | low | The borrow intent could change under an open review; fees are not bounded on chain | web part fixed, WO-42b: Confirm needs a fresh contract preview and the intent hash captured at Review; any change closes the review ("Review again"). On-chain fee bound: disclosed, below | "R2-07 intent hash" (packages/sdk/test/review2.test.ts); same e2e test as R2-06 |
| R2-08 | low | No CSP, frame-ancestors or HSTS; the wallet manifest is fetched unpinned | partly fixed, WO-42a: every page sends X-Frame-Options: DENY, HSTS and a CSP with frame-ancestors 'none', object-src 'none', base-uri 'self', form-action 'self'. The rest is disclosed below |
"pages carry X-Frame-Options, HSTS and a CSP with frame-ancestors/object-src/base-uri/form-action" (apps/web/e2e/hardening.spec.ts) |
| R2-09 | low | A Note transfer to a non-existent account stranded the claim | fixed, WO-42b. A named receiver must answer view_account (UNKNOWN_ACCOUNT refuses; an RPC failure keeps Transfer off); implicit 64-hex / 0x accounts need no lookup; LOAN, QUOTE and RESERVE Notes need the receiver typed twice |
"R2-09 view_account through the resilient transport" (packages/sdk/test/review2.test.ts); "review 2 (R2-09): a receiver that does not exist is refused; …" (apps/web/e2e/notes.spec.ts) |
| R2-10 | low | The network guards were mostly tautological | fixed, WO-42b. A signer must be a .testnet or implicit account, otherwise the app shows "Wrong network" and the sign modal refuses; NEXT_PUBLIC_NEAR_RPC / NEXT_PUBLIC_SOLANA_RPC naming mainnet are refused at startup. Genesis-hash check: disclosed, below |
"R2-10 network guards" (packages/sdk/test/review2.test.ts); "review 2 (R2-10): a signer that is not a testnet account sees Wrong network and the sign modal refuses" (apps/web/e2e/design-app.spec.ts) |
| R2-11 | low | API route hygiene: raw error text, no cache on /api/health, .. passed the indexer allow-list |
fixed, WO-42a. /api/health reuses a read for 5 s and returns a generic error; /api/indexer returns generic errors and logs details on the server; . and .. id segments are refused |
"feed proxy allow-list: documented paths pass; '.', '..' and anything else → 404" (apps/web/lib/api/api.test.ts); "/api/indexer refuses '.' and '..' id segments", "/api/health answers with an age and reuses a fresh read" (apps/web/e2e/hardening.spec.ts) |
| R2-12 | low | Prompt injection has no action path, but the badge lent authority | fixed (copy), WO-42a. The badge reads "Signed by an attested NEAR AI TEE"; the notes and the model id sit beside the answer with "Model output, not advice."; the request carries no tools | "R2-04/R2-12: live body carries max_tokens and no tools; fixture body has neither" (apps/web/lib/copilot/verify.test.ts) |
Also from the report's notes (R2-13): the copilot server module imports server-only, so a client import fails the build; the E2E key adapter works on localhost only; the Feed's zod is now 4.6.7 (WO-44).
Disclosed residuals (known, not fixed):
- R2-07, on-chain fee bound (contract, PROD).
LockCollateralAndBorrowcarries nomax_total_fee_usd_e6ormin_net_usd_e6. An oracle move or anorigination_fee_bpschange between preview and inclusion changes the fee with no bound the user signed. - R2-08, partial CSP. There is no full
script-src/connect-srcpolicy (Next's inline scripts need nonces, and wallet sandboxes load from several origins). The near-connect wallet manifest is not pinned: it is fetched on "Connect wallet" from GitHub raw / jsDelivr branch URLs. The real-wallet flow under the new headers is not covered by E2E, which uses mock wallets; it is checked by hand before the demo recording (docs/DEMO_SCRIPT.md). - R2-10, no Solana genesis-hash check. The devnet genesis hash is not in docs/FACTS.md, so only the RPC URL is checked.
- R2-02, market-maker secrets (PROD). This build prices with operator-set demo parameters that are published, the OutLayer coordinator saw the plaintext (F-167), and each fill's discount with the public
vol_bpsreveals q·γ. The PROD path is MM-owned secrets, client-side encryption with no plaintext step, and coarser or batched outputs. - R2-04, client key. The per-client rate-limit key comes from proxy headers (
x-real-ip, else the lastx-forwarded-forhop) and can be spoofed without a trusted proxy; the global ceiling of 30 live calls a minute bounds the cost anyway. - Dependencies (
pnpm audit --prodin the report). The zod advisory GHSA-mrhx-c7xc-m3w5 / CVE-2023-54404 (high, CVSS 3.1 7.5, memory exhaustion on oversized arrays; zod < 4.6.6, no patched 3.x; F-383, F-384) still reachesservices/x402through@x402/core2.18.0 (zod 3.25.76). It is not reachable from untrusted input (F-385): the paying client's PAYMENT-SIGNATURE header is regex-checked andJSON.parsed, never zod-validated, and Node caps any header at 16 KiB. zod runs only on facilitator responses, which come from our own facilitator or the operator-setX402_FACILITATOR_URL. Our facilitator checks the bearer token, caps bodies at 64 KiB and usesJSON.parse. Residual: a hostile facilitator could still send a hugekindsarray, which zod 4.6.6 would not stop either (unbounded array). Agent side (checked 2026-11-12, F-451): services/x402/src/agent.ts imports onlydecodePaymentRequiredHeaderanddecodePaymentResponseHeaderfrom@x402/core/http(ESM; services/x402 is"type": "module"). In @x402/core 2.18.0 both are a base64 regex test followed byJSON.parse(grep -nA5 "function decodePayment" node_modules/@x402/core/dist/esm/chunk-UCXEF6EL.mjs→ l.1245–1259, noz.call;grep -cE "zod|\.parse\(" node_modules/@x402/core/dist/esm/http/index.mjs→ 0). The chunk importszfor other schemas, but neither decoder calls it, so a server's PAYMENT-REQUIRED or PAYMENT-RESPONSE header does not reach the vulnerable zod API through the agent.elliptic(low) reaches the web server through@phala/dcap-qvl. Docs-site advisories (F-449, F-450): no version bump, because@docusaurus/core3.10.2 is the latest 3.10.x and each fix needs a major bump (out of scope before the freeze). Reachability checked 2026-11-12 (WO-35d):pnpm --filter docs-site buildexit 0 (333 files), then fixed-string greps ofapps/docs-site/build. The only hits on a package name are this disclosure itself (the Security › Audits page, its JS chunk and the search index).tinypoolGHSA-5gmw-xhrv-c9v3, GHSA-85c8-ppgw-ccpr (critical): build-time only (absent from the static output:grep -rlF Tinypool apps/docs-site/build→ 0,grep -rlF workerData apps/docs-site/build→ 0;grep -rlF tinypool→ 3 files, all Audits page text). It runs the SSG worker pool insidedocusaurus build.serialize-javascriptGHSA-5c6j-r48x-rmvq (high), GHSA-qj8w-gfj5-8c6v (moderate), GHSA-m5mm-p8h7-m29v (low): build-time only (absent from the static output:grep -rlF serializeJavascript apps/docs-site/build→ 0,grep -rlF PLACE_HOLDER_REGEXP apps/docs-site/build→ 0,grep -rlF UID_LENGTH apps/docs-site/build→ 0,grep -rlF copy-webpack-plugin apps/docs-site/build→ 0;grep -rlF serialize-javascript→ 3 files, all Audits page text). It builds webpack cache keys.uuidGHSA-w5hq-g745-h8pq (moderate): dev-server only (absent from the static output:grep -rlF sockjs apps/docs-site/build→ 0,grep -rlF webpack-dev-server apps/docs-site/build→ 0,grep -rlF 'v4(options, buf, offset)' apps/docs-site/build→ 0;grep -rlF uuid→ 1 file, a.uuidcounter property in a vendor chunk, not the package). The advisory concerns v3/v5/v6 withbuf; sockjs calls v4.deepmergeGHSA-gcqx-w48m-w2h4 (low, no patched version): build-time only (absent from the static output:grep -rlF deepmerge apps/docs-site/build→ 0,grep -rlF isMergeableObject apps/docs-site/build→ 0,grep -rlF plugin-svgo apps/docs-site/build→ 0). It runs in the SVG-to-React transform. The verdict per advisory is "build-time only"; none is shipped.
- FACTS C-020 (kept by decision, D-029). Sources define "implicit account" differently. The SDK takes the safe side: only 64-hex and
0xids count as implicit, and0s/0ureceivers take theview_accountpath and fail closed (F-382). Kunal kept this behaviour on 2026-11-11; see "What remains" item 11.
What remains (known, not fixed)
- SR-5, partial: remote capacity is checked only when a loan is originated. A remote loan must pass two checks. The first is the aggregate check. The second compares remote debt plus the new borrow with (1 − h) × the fitting, non-exiting quotes, and caps the borrow at the largest single fitting quote. Settlement now filters by recipient before it cuts the list to 8 candidates. Remote settlement is still single-leg: one quote must clear a position's debt (or take every unit). Nothing re-checks the bin-packing after origination. Two loans that each fit the largest quote can depend on the same quote, and buyers can clear their remote recipient (
set_quote_remote_recipient(None)) or exit after origination. Either way a position can become impossible to pre-liquidate until its value falls below what some fitting quote can cover. There is no Reserve stage and no multi-leg remote settlement. - SR-6, partial: the gas reserve can still be spent by keeper reads on anyone's reservations. Confirms are limited to the borrower, the keeper or the owner. The keeper gets at most 4 reserve-paid reads per position, and the borrower's read comes out of its held reservation deposit. Job requests are keeper or owner only. Non-liquidation spends leave a floor of 4 quoter runs, and a short reserve falls back to static (P0) pricing instead of blocking the rescue. But any account can open reservations (8 at a time per account, deposit refunded), and the keeper spends reserve on them. OutLayer refunds are not credited back to
gas_reserve_yocto(see contracts/nectar-core/src/p1.rs). So the reserve above the floor can be worn down, after which quoter-priced settlement degrades to static pricing until the owner tops up the reserve. - SR-7, partial: there is no B20
isAuthorizedcheck at funding. Recipients must now be a 32-byte on-curve Solana key or a non-zero 0x address, at funding, atset_quote_remote_recipientand before signing. The contract does not call the B20 policy registry (isAuthorized(policyId, account), F-346). With an allowlist policy, a 0x recipient that is not on the allowlist (or is removed later) makes the EVMsettlerevert withNotAuthorized.resign_remote_settlementre-signs the same recipient, so that Base leg stays stranded until a contract upgrade. - SR-4, EVM remainder still pushed. On Solana, payouts are pulled. On the EVM vault,
settlestill transfers the seized units to the recipient and the remainder to the depositor in the same transaction, and checks both against the B20 policy (evm/src/NectarVaultBase.sol_executeSettle). If either party is not authorized, for example the depositor's policy is revoked after it deposited, the whole settle reverts and the leg is stuck as in item 3. - SR-15, partial: there is no mint binding in
vault_read. The OutLayer read reports no mint. The contract instead refuses to let one (chain, vault) serve a second collateral (assert_vault_unbound). Correctness therefore depends on each vault holding one mint: the Solana vault PDA is derived from its mint, and the EVM vault has one token. - Keeper relays are advisory.
relay_settlement_resultandrelay_release_resultare keeper-only and check the format of the tx reference. nectar-core does not verify that the Solana or Base transaction happened. A keeper that lies, or a leaked keeper key, can close a remote position on NEAR without the leg landing, and a relayed leg can no longer be re-signed. PROD needs a light-client or attested proof (PRD §9 trust note). The keeper-v2 key is a function-call key with no deposits (D-019). - Keeper residuals after WO-35k (services/keeper/README.md):
- Base vault events do not name the slot. A leg that landed through another contract is used only when no direct call on the slot emitted the event, which is a heuristic.
- Base has no OutLayer
vault_read(SR-28). A short deposit on an expired Base reservation is not attested, because attestation books the full reserved units, so no automated path proves it for release. - The landed-tx search on Solana is bounded per tick. Anyone can pad an address's history, which delays (but does not block) that leg's relay.
- The keeper's reserve-paid reads stop at 4 per position. After that a deposit must be confirmed by the borrower or the owner.
- The daemon runs locally, not continuously. A keeper outage longer than a signed leg's expiry leaves it unrelayed. Anyone can then recover it with
resign_remote_settlement, and the web app shows a "Re-sign settlement" button.
- The Base spoke runs on a local fork of Base Sepolia, not on Base Sepolia (D-026; no usable faucet without a login, F-368). The NEAR side, which derives the key, builds the EIP-712 digest and gets the MPC signature, is real on testnet. The vault transactions exist only on a local anvil chain and have no public explorer.
- Not reviewed (coverage gaps in the report): callback gas on failure branches; upgrade safety (there is no
migrate; layouts change only through new raw keys); 18–24-decimal collateral; MEV and economic attacks; git-history secret scan (G9 runs gitleaks). The web app, SDK, copilot, reference agent and privacy claims were covered by review 2 (above). - Trust in the owner. A single deployer key on testnet sets parameters, pauses, and moves the mock price (a disclosed demo control). PROD needs a DAO owner with a timelock.
- FACTS C-020, kept by decision: no Note transfer from the web app to a
0s/0uaccount that does not exist yet. The web app's Note-transfer receiver check (R2-09:isImplicitAccountin packages/sdk/src/network.ts,receiverNeedsLookupin packages/sdk/src/notes.ts) treats only 64-hex and0x+ 40-hex ids as implicit (F-381). Deterministic0sand universal0uids, which near-account-id 3.0.0 also calls implicit (F-382), take theview_accountpath. A missing account answersUNKNOWN_ACCOUNT(F-380), so Transfer stays off. This errs on the safe side: such a Note cannot be sent before the account exists, and nothing is stranded. Kunal kept the behaviour on 2026-11-11 (D-029). The contract does not check receivers; this is a web app and SDK rule, and a directnft_transfercall is not affected. - Remote Loan Notes do not carry the collateral release. A Loan Note transfer (
nft_transfer→move_note, contracts/nectar-core/src/notes.rs:332-349) setsposition.borrowerto the new owner, and repay is permissionless (on_repay, contracts/nectar-core/src/lib.rs:824). For collateral held on NEAR, a full repay releases the units to the current Note owner (lib.rs:852-858). For remote positions (Solana devnet, Base local fork),request_remote_release(contracts/nectar-core/src/remote.rs:957) signs the release to the depositor recorded at deposit, not to the Note owner, and the vault pays only that depositor (withdraw). A new owner who repays a remote loan therefore does not receive the collateral. The review's verifiers rejected this as a finding (documented behaviour, below); it is listed here so that nobody takes a remote Loan Note expecting the collateral.
Rejected by the review's verifiers (not fixed by design)
The report lists 14 candidates that verifiers rejected, each with the reason, in docs/work/security-review-v7.md "Rejected by verifiers". One of them is the transferable remote Loan Note: it carries no collateral claim, and release always goes to the vault depositor. That is documented behaviour, listed as a known limitation in "What remains" item 12.