On-chain architecture
The contract suite, the upgrade posture, and how you can verify it independently.
Strimz's smart contracts are deployed on Arc, Circle's stablecoin-native L1 where USDC is the gas token. The full Foundry workspace is open source at github.com/StrimzLab/strimz/tree/main/packages/contracts.
The contracts
| Contract | Job | Upgradeable? |
|---|---|---|
StrimzRegistry | Off-chain merchants register here to get an onchainMerchantId. Holds payout addresses, fee bps, active flags, and parentMerchantId for sub-tenancy. | UUPS |
StrimzPayments | One-shot payments. Exposes pay() (classic approve-then-pay) and payWithAuthorization() (EIP-3009 token auth plus Strimz PayIntent, two signatures). Splits the fee to FeeCollector and the net to the payout. | No, immutable. |
StrimzSubscriptions | Subscription enrolment via createSubscription() or permitAndCreateSubscription() (EIP-2612). The scheduler calls batchCharge() for renewals. Owns the usedAttempts ledger. | No, immutable. |
StrimzAgentEscrow | ERC-8183 escrow used by the AutoPay Agent's commerce capability. | UUPS |
FeeCollector | Per-token, per-merchant fee accounting. Withdrawals are gated by TREASURY_ROLE. | UUPS |
TokenWhitelist | Which ERC-20s are accepted (USDC, EURC, USYC) and which signing standards each implements (EIP-3009, EIP-2612). | UUPS |
Why some contracts are immutable
StrimzPayments and StrimzSubscriptions are the only two contracts
in the suite that move customer funds. Once deployed, their logic
cannot be changed. A stolen admin key cannot rewrite the code that
pulls USDC from a payer's wallet, because there is no upgrade path to
rewrite.
If we ever ship a fix to either of these, we deploy a fresh address and rotate the Registry's dependency pointers to it. Old transactions keep referring to the old address; new ones use the new one.
The Registry, FeeCollector, and TokenWhitelist stay UUPS because they hold policy: which merchants exist, what the fee schedule looks like, which tokens we accept. Policy changes often. Value-moving code should not.
The two payment paths
Each value-moving contract exposes a classic entrypoint and a meta-tx entrypoint. The classic path is there for integrators who want to drive the wallet themselves. The meta-tx path is what the hosted checkout uses.
| Flow | Classic | Meta-tx (default) |
|---|---|---|
| One-shot payment | pay(). Payer approves USDC, then calls pay() themselves. | payWithAuthorization(). Payer signs an EIP-3009 authorization plus a Strimz PayIntent. The relayer broadcasts. |
| Subscription enrolment | createSubscription(). Payer approves, then calls it themselves. | permitAndCreateSubscription(). Payer signs an EIP-2612 permit plus a Strimz SubscriptionIntent. The relayer creates the sub for them. |
| Wallet prompts | Two on-chain transactions | Two off-chain typed-data signatures, one on-chain transaction |
| Who pays gas | The payer | The Strimz relayer |
The second signature (PayIntent or SubscriptionIntent) binds the
merchant, amount, and routing fields the token never sees. Without it,
an intercepted EIP-3009 or permit signature could be replayed against
a different merchant.
Both paths land in the same Transaction row, emit the same
PaymentExecuted event, and pay the same fees. The full security
model lives on the
meta-tx flow page.
Deterministic charge idempotency
The single most security-relevant call in the suite is
StrimzSubscriptions.batchCharge. The scheduler hands it an array of
chargeAttemptId values, each one a keccak256(subscriptionId, periodEndAt).
The contract keeps a mapping(bytes32 => bool) usedAttempts and skips
any entry it has already seen. The skipped subscription emits a
SubscriptionChargeSkipped(subId, attemptId, ChargeOutcome.None)
event; the rest of the batch keeps going.
In practical terms: the scheduler can resubmit the same batchCharge
call as many times as it wants and never charge a customer twice. We
prove this with an invariant test that drives both submission paths
through 128,000 fuzz calls and checks that the FeeCollector balance
never exceeds the fees it has accrued. Neither path can credit a fee
twice.
ERC-7201 namespaced storage
Every upgradeable contract puts its state under a deterministic slot
via ERC-7201. Adding fields
in V2 cannot collide with V1's layout. The Upgradeability.t.sol
suite asserts V1-to-V2 storage preservation through OpenZeppelin's
Foundry Upgrades plugin.
StrimzPayments and StrimzSubscriptions use the same namespace
pattern even though they aren't upgradeable. The reason is mundane:
their dependency pointers (Registry address, FeeCollector address)
can still be rotated by ADMIN_ROLE after deploy, so the storage
discipline buys us a clean migration path on the fields that do
change.
Reading the contracts yourself
Deployment addresses for testnet and mainnet live in
@strimz/shared-config. Every Strimz process reads the same
constants at boot. There are no private deployments. What's in the
repo is what runs in production.
Verifying a transaction
Every Strimz Transaction row carries an onchainTxHash. Drop it
into Arc's block explorer and you can check, end to end:
- The signer recovered from the EIP-3009 or EIP-2612 signature is the payer your dashboard claims it was.
- The fee split is consistent:
amount == feeAmount + netAmount. - The destination address matches the merchant's
payoutAddress. (For the classic path that's thetofield; for the single-signature path it's the contract's payout call.) - The
reffield (one-shot) orchargeAttemptId(subscription) is the value we recorded off-chain.
The chain is the source of truth. Strimz's database is a view the indexer maintains over the event log. If you ever want to rebuild it from scratch, you can.
