This covers the code in this repository:
- The three Solidity contracts in
packages/contracts(FeeController,TokenFactory,Multisend), deployed or not. - The web app in
apps/web, including its API routes.
Third-party dependencies (Next.js, wagmi, viem, OpenZeppelin, and the rest) are out of scope — report those upstream.
Open the repository's Security tab and choose "Report a vulnerability". That opens a private advisory only the maintainers can see. Please don't open a public issue for a security problem. Include a description of the issue, the steps to reproduce it, its impact, and a contract address and network if the issue is on-chain.
Don't test a finding against mainnet contracts or real funds beyond what's needed to demonstrate it.
There's no bounty program at this time.
An acknowledgement within a few business days, and a report back once the issue is understood or fixed. Coordinated disclosure — please give us time to address a report before making it public.
https://4rcos.com runs on Firebase App Hosting (Cloud Run, behind Google's load balancers), configured in
apps/web/apphosting.yaml. It has three server secrets: BLOCKSCOUT_API_KEY (the key for Blockscout's PRO API, which
the server's explorer reads use because explorer.arc.io refuses server requests), ARCOS_SESSION_SECRET (the key of
the sign-in cookie, below) and TELEGRAM_WEBHOOK_SECRET (what Telegram sends in the X-Telegram-Bot-Api-Secret-Token
header of each call to /api/telegram/webhook, which the route compares in constant time). All three are Secret Manager
secrets, pinned to a version and available at runtime only. None is ever a NEXT_PUBLIC_ value or in the repository.
Every other
setting in that file is public: NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID, for one, is a client identifier that ships in
the browser bundle.
Signing in is free and sends no transaction: the wallet signs an EIP-4361 message (apps/web/src/lib/siwe.ts,
apps/web/src/lib/auth-server.ts).
- Nonce.
GET /api/auth/noncemakes 16 random bytes with the server's CSPRNG and stores them in the Firestore databasearcosfor 10 minutes. A nonce is accepted once: it is read and deleted in the same transaction that signs the wallet in, and its expiry is checked in code as well as by the TTL policy. It is bound to the browser that fetched it: the same answer sets__Host-arcos_nonce(HttpOnly; Secure; SameSite=Lax; Path=/, 10 minutes) to the nonce's SHA-256, and verify refuses a message whose nonce doesn't match that cookie (compared in constant time), then clears it. - Message. It must be exactly the canonical EIP-4361 text of its own fields, name the host of
NEXT_PUBLIC_SITE_URLas its domain and that site's origin as its URI (never the request's Host header), name the active chain, carry the site's own statement and no resources, request id or Not Before, and be inside a lifetime of at most 10 minutes. - Signature. viem's
verifySiweMessageover sign-in's own RPC client (CCIP-Read off). It covers ordinary wallets and smart-contract wallets (ERC-1271, and ERC-6492 before deployment). That client keeps its own endpoint-health record and reads an out-of-gas answer as the node's, so a signature built to burn gas costs one call at one endpoint and never puts the endpoints the Inspector,/api/pulse,/badgeand/tshare on cooldown. Before the check, verify reads, without consuming it, that the nonce is stored and live, so a nonce that can't sign in never reaches the RPC. - Session. The cookie
__Host-arcos_sessionis an HS256 JWT{ sub: address, aud: site host, iat, exp: +7 days }, signed withARCOS_SESSION_SECRET(a Secret Manager secret of 32 bytes or more) and sentHttpOnly; Secure; SameSite=Lax; Path=/with noDomain, as the__Host-prefix requires, so a sibling subdomain can neither plant nor overwrite it. A request that carries it twice is treated as signed out. Each sign-in issues a new token. It also carries the wallet's session version: signing out moves the version on, so every earlier cookie of that wallet, anywhere, stops counting. Rotating the secret signs everyone out. Without the secret, sign-in answers 503; there is no fallback key. - CSRF.
POST /api/auth/verifyandPOST /api/auth/logoutrequire anOriginequal to the site's own; the verify body must beapplication/json. Every auth route is rate-limited per client and answersno-store. - Logs. No address, message, signature, cookie or secret reaches a log line; a failure logs the name of the step.
/api/inspect, /api/approvals, /api/event and /api/csp-report limit each client, in memory and per server
instance: a first line of defence, not a guarantee. A client is the entry of x-forwarded-for that the site's own
proxies vouch for (clientKey in apps/web/src/lib/rate-limit.ts), else x-real-ip, else one shared "unknown" key.
By default that entry is the rightmost one, the hop the nearest proxy added: every entry to its left is client-supplied
and trivially spoofed, and is never trusted. An IPv6 address counts by its /64, so a client cycling through a block
doesn't dodge its limit.
If a Google load balancer in front of App Hosting adds an entry of its own to the right of the client's, every visitor
is keyed by that one entry and they share a single bucket: stricter, never looser. ARCOS_TRUSTED_HOPS (server-only,
optional) says how many entries, counted from the right, belong to the site's own proxies; n skips n entries before it
reads the client. Unset, empty, negative or not a whole number, it is 0, and the rightmost entry is the client's. A
header with n entries or fewer holds no entry the proxies vouch for, so the rightmost is read, the same key as without
the setting: for a header that short the setting is never looser than leaving it unset, whatever n is. The value is
measured, not guessed (apps/web/apphosting.yaml says how) and never set higher than the measurement shows. A value
above the real number of the site's own proxies makes the key an entry the client wrote once the client adds entries of
its own to the header, and it can then pick its own bucket on every request. The value is also safe only if every
address the backend answers on, 4rcos.com and the default *.hosted.app address, reaches the app through the same
number of proxies, since one value can't be right for two paths. Both are measured, and if they differ it stays unset.
x-vercel-forwarded-for is read only when process.env.VERCEL === "1", which the platform sets and no request can, so
only on a Vercel deployment; anywhere else nothing tells a genuine header from one a client set on itself, and it is
ignored. The live site doesn't run on Vercel. Running the app behind a different reverse proxy that doesn't set these
headers correctly, or exposing it directly to the internet, would let every request collapse onto one key: not a
security hole in itself, but the safety valve for those endpoints would stop being per-client.
Every response carries these headers (headers() in apps/web/next.config.ts, built in
apps/web/src/lib/security-headers.ts):
Strict-Transport-Security: max-age=63072000; includeSubDomains, withoutpreload.X-Frame-Options: DENYand a content security policy offrame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'self', both enforced: the windows that ask a wallet to sign can't be framed by another site.X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-originand aPermissions-Policythat turns off the camera, microphone, geolocation, payment and browsing-topics features.Cross-Origin-Opener-Policy: same-origin-allow-popups, notsame-origin, which would cut a wallet's popup off from the page that opened it.
The full policy is sent as Content-Security-Policy-Report-Only, so it blocks nothing. It says the page takes its
scripts and styles from itself (and one font stylesheet from Google Fonts, for the WalletConnect modal) and calls only
itself, Arc's RPC nodes and explorers, WalletConnect and Reown, and Circle's App Kit; security-headers.ts lists each
host with who calls it. Browsers post what it would have blocked to /api/csp-report, which writes a log line (the
directive, the blocked origin or keyword, and the page's path) for at most ten distinct violations per request and
answers 204 to everything. It logs no query string, no address, no client address and no user agent, reads at most
256 KB (Chromium puts the whole policy in every report, and keeps up to 100 for one upload) and limits each client. The
policy watches real traffic for a few days before it is enforced, in a later step.
script-src allows 'unsafe-inline'. Next's own bootstrap scripts and the theme script are inline and carry no nonce,
and a nonce is made for each request, which would make every page render on the server on each visit instead of being
served as a static file. So the policy keeps every other host's script out, but not an inline one: a known gap that
nonces or hashes would close.
The site counts a few things itself, so its maker can see whether they are used: an inspection run, a proof-page share,
a mint, a drop, a swap, a bridge, a revoke, a click on a fix that isn't ready yet, and the name of a Terminal command.
When one happens the page sends its name and a few small values (how many checks passed, how many recipients, which pair
or chains, the command's name) to /api/event on this site, and that route writes one line to the host's logs, which on
Firebase App Hosting is Cloud Logging. There are no cookies and no third-party script.
The line holds the event's name and those values and nothing else: no address (a wallet's or a token's), no IP address,
no user agent and nothing typed in the Terminal past a command's name. It has no visitor or session identifier, so a
count says how often something happens and not who did it. The route takes only the nine event names, and for each only
its own values, which are whole numbers up to 1,000,000 or labels of up to 32 letters, digits, dots, underscores and
hyphens (an address is longer, so it can't pass); anything else is dropped. It ignores a body over 2 KB and a request
whose Origin is another site, limits each client, and answers 204 to everything. The host keeps its own request log,
as it does for any page, and these counts add nothing to it. They are also separate from what the services the page
calls do on their own: WalletConnect and Reown (below), and Circle's App Kit behind Swap and Bridge, which by default
sends its own usage and error reports to Circle.
Inspector reads whichever token contract a visitor asks about, so CCIP-Read (EIP-3668) is off on every client that
does: the server's four clients (serverRpcClient, approvalsRpcClient, authRpcClient and watchRpcClient in
apps/web/src/lib/server-rpc.ts, all built by inspectionClient in packages/inspector/src/inspection-client.ts, each
with its own endpoint-health record) — serverRpcClient behind /api/inspect, /badge, /t and its image, and
/api/pulse; approvalsRpcClient behind /api/approvals alone, which reads the allowance, symbol, name and decimals
of whatever token contracts an address has approved; authRpcClient behind /api/auth/verify alone, which checks
smart-wallet signatures; watchRpcClient behind POST /api/watches alone, which checks that the token address a
signed-in wallet adds holds code — and every client of the browser's wagmi config (apps/web/src/providers/wagmi.ts).
With it on, a read that reverts with OffchainLookup makes viem fetch URLs the contract chose: from the server, that is
a blind server-side request forgery. Such a revert is read like any other revert. The Circle App Kit clients behind
Swap and Bridge only read Circle's own contracts.
On a phone, where the browser has no wallet extension, Wallet connects through WalletConnect. The page then talks to
WalletConnect's relay, which carries an end-to-end encrypted session between the page and the wallet on the phone; every
transaction is still signed in that wallet. Its modal also asks Reown's servers for the wallet list and the project's
configuration, sends usage events to WalletConnect and loads the Inter font from Google Fonts, so those services see the
visitor's IP address. The Reown project ID (NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID) is a public client identifier that ships in
the browser bundle; the domain allowlist in the Reown dashboard is what protects it.
The WalletConnect code loads only when a visitor picks WalletConnect, or when a reload restores a live WalletConnect session
they connected earlier. A session that ended or expired costs at most one more load, after which the browser forgets it. It is
a separate chunk, and apps/web/src/providers/lazyWalletConnect.ts stops wagmi from importing it on every page load, which its
own connector would do. CCIP-Read stays off in the wagmi config (apps/web/src/providers/wagmi.ts), with WalletConnect in it too.
Deliberate, documented exceptions to this project's "no high/critical npm audit findings" rule.
- Package:
toml(transitive, pulled in as@coral-xyz/anchor's dependency"toml": "^3.0.0", itself required — not optional — by every@circle-fin/*-kitsub-package@circle-fin/app-kitinstalls, includingswap-kitandbridge-kit, for their Solana-side providers). - Advisories: GHSA-82x6-q7mm-w9cf (Uncontrolled Recursion, CVSS 7.5) and GHSA-v5mp-jgw5-2x6j
(Prototype Pollution via
__proto__key-path desynchronization, CVSS 8.2), bothseverity: highpernpm audit --omit=devand the GitHub Advisory Database, both affectingtoml < 4.2.0. The installed version (3.0.0, the only3.xrelease ever published) has no in-range patch — the first patched release is4.2.0, a different major than what@coral-xyz/anchor@^3.0.0(checked up to its own latest,0.32.1) declares. - Why it's unreachable here: both advisories require parsing attacker-controlled TOML text.
4rc.OS never parses TOML anywhere in its own code, on the client or the server. Inside
@coral-xyz/anchor,tomlis used to loadAnchor.tomlworkspace configuration — a Solana-CLI/Node-only code path for reading a local project's own config file, not something this app's Swap or Bridge windows import or execute; the app only uses@circle-fin/app-kit's EVM/viem adapter path (createViemAdapterFromProvider) against Arc and other EVM chains, never the SDK's Solana providers or their CLI tooling. - Override applied (root
package.json, alongside the existingwsoverride):Resolves to"overrides": { "toml": "^4.2.0" }
toml@4.3.0as of 2026-09-20.@coral-xyz/anchor@0.31.1(the versionapp-kitpulls) was written againsttoml@^3.0.0; this override installs a newer major than it declares support for.npm run build, the full test suite, and a standalonenew AppKit()smoke script all ran clean against the forced version —toml's own public API (parse(string) -> object) is small and unchanged across this jump, and Anchor's usage of it is a single, simple parse call. - Decided by: the project owner, in chat, 2026-09-20 — a deliberate, documented exception, not
a default resolution path. This project's normal default is to keep a forced
overridesbump within the same major version, to avoid exactly this kind of unreviewed breaking change; this entry is a narrow, explicitly-tracked exception to that default (rather than a silent one), justified by there being no in-range3.xpatch and bytoml's tiny, stable public API making the major jump low-risk in practice (see the verification above). - Exit condition: remove the override once
@circle-fin/app-kit(directly or via@coral-xyz/anchor) depends ontoml >= 4.2.0on its own, or drops the CCTP-Solana / Anchor dependency entirely for the providers this app doesn't use. Re-runnpm audit --omit=devafter removing it to confirm the override is no longer needed before deleting this section.