Security
We never hold your money
Enforced by contracts, not policy.
Wallet path. SpecieRouter.pay() splits the value and forwards both legs in the same transaction. Its balance is always zero — there is no window in which it holds anything.
Address path. The deposit address is the CREATE2 address of a forwarder whose constructor arguments are the merchant, the fee and the fee collector. Those destinations are baked into the address itself.
Settlement is permissionless — anyone may trigger it. That sounds alarming and is the opposite: because a different merchant or fee collector derives a different address holding nothing, funds can only ever reach the intended merchant. Nobody, including us, can redirect a payment.
It also means you are not dependent on us being online. If our relayer vanished, you could settle your own payments.
The fee cannot be raised on you
MAX_FEE_BPS is an immutable constant of 1%. Not a setting, not upgradeable — a fully compromised owner key cannot exceed it. Changing it needs a new deployment you would have to opt into.
Each payment freezes its fee at creation, so a later change never applies retroactively.
Keys
| Key | Can do | If leaked |
|---|---|---|
| Relayer | Pay gas to settle deposits | Gas drain only. Cannot move or redirect funds — destinations are fixed by the derivation. |
| Owner | Adjust the fee within 1%, rotate the fee collector | Fees diverted until rotated. Cannot touch merchant funds or exceed the ceiling. |
| API key | Create payments on your account | Revoke it in the dashboard. Stored only as a SHA-256 hash. |
Payment ids
256-bit CSPRNG values. They double as the CREATE2 salt and gate the router's replay guard, so a predictable id would let someone enumerate pending deposit addresses.
Safe to show a payer. Never generate them yourself.
Amounts are verified on-chain
The contracts do not enforce the invoiced amount — doing so would need a merchant transaction per payment. Instead the amount is read from the chain and compared to what was invoiced.
A payment id is never proof of payment. Only payment.confirmed, with an amount_received that matches, means you have been paid.
Webhook delivery
Merchants supply URLs we fetch, which is textbook SSRF. Defences:
httpsonly, no credentials, restricted ports- Private, loopback, link-local, CGNAT and reserved ranges refused — including cloud metadata at
169.254.169.254, and IPv4 smuggled inside IPv6 notation
- DNS resolved once and the connection pinned to that address, so it cannot be re-resolved to an
internal one after the check
- Redirects not followed; a 302 to metadata would undo everything above
- 10s timeout, capped response size
Checked at registration for usability, and again at delivery, because DNS changes in between.
Database
Connections use TLS with the provider's CA pinned and verification on. The common workarounds — disabling TLS, or rejectUnauthorized: false — leave credentials and payment data readable on the wire.
Sessions and passwords
Sessions store only a hash of the token, so a database leak does not hand over live sessions. Logout deletes the row rather than clearing a cookie. Cookies are httpOnly with sameSite=strict, which is also the CSRF defence for dashboard mutations.
Passwords use scrypt. API keys use SHA-256 — they are already 256-bit random, so a slow KDF buys nothing and only adds latency.
Before real money
Arc is testnet-only, so this is a testnet deployment. Before mainnet:
- Contract audit. Non-negotiable.
- Owner key on a multisig, not a single deployer key.
- A fee collector that is not the deployer.
- Merchant payout addresses checked against Circle's blocklist at onboarding — Arc rejects transfers
to blocked addresses at the protocol level, so an unchecked merchant would have every settlement revert.
Report anything you find privately rather than opening an issue.