OpenSub is a small, auditable subscription primitive:
- Merchants create plans
(token, price, interval, collectorFeeBps) - Subscribers authorize recurring charges via ERC20 allowance
- Anyone can execute renewals via
collect()(optionally earning a collector fee) - No custody during collection: tokens move directly from subscriber → merchant/collector
This repo is organized as a set of milestones:
- Milestone 1 (market research + product definition):
docs/MILESTONE1.md - Milestone 2 (protocol implementation):
src/OpenSub.sol - Milestone 3 (Foundry tests: unit + fuzz + invariants):
test/+docs/SPEC.md+docs/THREAT_MODEL.md - Milestone 4 (frontend handoff / demo deploy UX):
docs/FRONTEND_HANDOFF.md+frontend/+script/DeployDemo.s.sol - Milestone 5 (keeper bot / automation):
keeper-rs/(Rust) - Milestone 6A (ERC-4337 AA subscribe CLI):
aa-rs/(Rust) - Milestone 6B (gasless subscribe via paymaster):
docs/MILESTONE6B.md+aa-rs/
createPlan(token, price, interval, collectorFeeBps)setPlanActive(planId, active)subscribe(planId)(charges immediately for first period)cancel(subscriptionId, atPeriodEnd)unscheduleCancel(subscriptionId)collect(subscriptionId)(anyone can call; earns optional collector fee)hasAccess(subscriptionId)view helper
paidThrough= end timestamp of the currently-paid access period.- If
status == Active, the subscription is due whenblock.timestamp >= paidThrough. - If
status == NonRenewing, auto-renew is disabled but access remains valid untilpaidThrough.- Pattern A cancellation: no on-chain “finalize cancel” transaction is required later.
See docs/SPEC.md for the frozen behavior that Milestone 3 tests enforce.
./script/install_deps.shNotes:
- This uses
forge install --no-gitso it works even if you downloaded a zip snapshot. - You still need
gitinstalled becauseforge installclones dependencies.
forge build
forge testThis repo includes demo deployment scripts that are useful for frontend developers.
- Deploys
MockERC20as mUSDC (6 decimals) - Deploys
OpenSub - Creates a default plan
- Mints demo tokens to the plan merchant
- Optionally mints demo tokens to a second wallet address (set
SUBSCRIBER=0x...) - Prints contract addresses + a paste-ready snippet for:
frontend/config/addresses.tsfrontend/config/tokens.ts
Local Anvil:
anvil
./script/install_deps.sh
SUBSCRIBER=0xYourSubscriberAddressHere \
forge script script/DeployDemo.s.sol \
--rpc-url http://127.0.0.1:8545 \
--private-key <ANVIL_MERCHANT_PRIVATE_KEY> \
--broadcast -vvvBase testnet:
./script/install_deps.sh
forge script script/DeployDemo.s.sol \
--rpc-url <BASE_TESTNET_RPC_URL> \
--private-key <YOUR_PRIVATE_KEY> \
--broadcast -vvvDemoScenario is especially useful because it creates real on-chain events (PlanCreated, Subscribed, Charged) that the UI can query via logs.
Local Anvil (seeded scenario):
anvil
export ETH_RPC_URL=http://127.0.0.1:8545
./script/install_deps.sh
# required to perform approve+subscribe
export SUBSCRIBER_PK=<ANVIL_SUBSCRIBER_PRIVATE_KEY>
# optional: auto-advance time + mine on Anvil (requires --ffi)
export USE_FFI=1
forge script script/DemoScenario.s.sol \
--rpc-url $ETH_RPC_URL \
--private-key <ANVIL_MERCHANT_PRIVATE_KEY> \
--broadcast --ffi -vvvBase testnet (deploy + subscribe only):
./script/install_deps.sh
export SUBSCRIBER_PK=<FUNDED_SUBSCRIBER_PRIVATE_KEY>
forge script script/DemoScenario.s.sol \
--rpc-url <BASE_TESTNET_RPC_URL> \
--private-key <MERCHANT_PRIVATE_KEY> \
--broadcast -vvvOn public testnets you can’t warp time. If you want to demo renewals quickly, override the default plan interval.
Both DeployDemo and DemoScenario accept optional env overrides:
PLAN_PRICE(uint256)PLAN_INTERVAL_SECONDS(uint40)PLAN_COLLECTOR_FEE_BPS(uint16)
Example (5 minute interval):
PLAN_INTERVAL_SECONDS=300 \
forge script script/DeployDemo.s.sol --rpc-url ... --private-key ... --broadcast -vvvFrontend handoff docs + ABI/config templates live in:
docs/FRONTEND_HANDOFF.mddocs/MILESTONE4_REQUIREMENTS.mddocs/UI_STATE_MACHINE.mddocs/ALLOWANCE_POLICY.mdfrontend/abi/*andfrontend/config/*
There is also a minimal demo frontend (Next.js + wagmi) under frontend/.
Quick start:
cd frontend
npm i
npm run devSee frontend/README.md for local Anvil + Base Sepolia notes and the optional gasless (AA) demo page.
Milestone 5 adds a backend keeper that scans Subscribed logs and calls collect() when subscriptions are due.
Milestone 5.1 hardens the keeper with:
- allowance/balance/plan-active prechecks (no gas wasted on obvious reverts)
- optional
eth_callsimulation ofcollect()(enabled by default) - persisted per-subscription backoff (so paused plans / unpaid users don’t get spammed)
See:
docs/MILESTONE5.mddocs/MILESTONE5_1.mdkeeper-rs/README.md
Quick run (Base Sepolia):
export KEEPER_PRIVATE_KEY="<funded EOA key>"
export OPENSUB_KEEPER_RPC_URL="https://sepolia.base.org"
cargo run --release --manifest-path keeper-rs/Cargo.toml -- \
--deployment deployments/base-sepolia.json \
--poll-seconds 30 \
--confirmations 2 \
--log-chunk 2000foundry.toml includes:
remappings = [
"@openzeppelin/=lib/openzeppelin-contracts/",
"forge-std/=lib/forge-std/src/"
]Unit + fuzz + invariant tests live in test/:
test/OpenSubPlan.t.sol(plan creation / pause semantics)test/OpenSubSubscribe.t.sol(subscribe semantics + OpenSub event ordering)test/OpenSubCollect.t.sol(renewals, fee logic, late renewal policy)test/OpenSubCancel.t.sol(Pattern A cancellation + unschedule)test/OpenSubTokenFailures.t.sol(rollback on token failures)test/OpenSubReentrancy.t.sol(reentrancy attempt blocked)test/invariant/OpenSubInvariant.t.sol(stateful invariants)test/OpenSubSmoke.t.sol(simple end-to-end smoke test)
Mocks in src/mocks/:
MockERC20.sol(mintable ERC20)ToggleFailERC20.sol(can revert / return false after toggling)ReentrantERC20.sol(attempts to re-enter OpenSub during transferFrom)ReturnsFalseERC20.sol(always returns false)RevertingERC20.sol(always reverts)
Run:
make demo-localThis will:
- start a local Anvil node
- deploy + seed a subscription via
DemoScenario - warp time so it becomes due
- run the Rust keeper once so it calls
collect()and emits a renewalChargedevent
Artifacts are written under ./.secrets/ (gitignored).
Run:
make keeper-self-testThis is an automated proof that the keeper:
- Does not send a reverting
collect()tx when allowance is insufficient (it records backoff instead). - Retries after backoff and successfully collects once allowance is restored.
All temporary artifacts are written under ./.secrets/ (gitignored).