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

KeyCan doIf leaked
RelayerPay gas to settle depositsGas drain only. Cannot move or redirect funds — destinations are fixed by the derivation.
OwnerAdjust the fee within 1%, rotate the fee collectorFees diverted until rotated. Cannot touch merchant funds or exceed the ceiling.
API keyCreate payments on your accountRevoke 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:

  • https only, 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.