# Subscriptions

Non-custodial recurring payments as **bounded pulls**. The industry default for crypto subscriptions is an unbounded ERC-20 allowance to an operator contract -- "you may spend my USDC" -- which means trusting the operator (and everyone who ever compromises it) with the full approved balance. Peltier replaces that with a payer-signed mandate: the payer signs a `Subscription` struct that pins **at most `maxPerPeriod` of `token` per `period`, only to `recipient`, platform fee at most `maxFeeBps`, plus the app's own `appFeeBps` to `appFeeRecipient`, cancellable at any time**. The `PeltierSubscriptions` singleton enforces every bound on-chain -- the contract *physically cannot* pull more, regardless of what the backend, the merchant, or a leaked signature tries.

The contract is a **strictly immutable singleton**: no owner, no admin, no proxy, no upgrade hook, no pause, no settable parameter. The constructor takes exactly one argument (the immutable **platform-fee** `treasury`), and it is deployed to one well-known address on every chain. The backend's database is bookkeeping; the contract is the security boundary. Subscriptions are EVM-only by design -- the mandate is an EIP-712 signature and the pull is an ERC-20 `transferFrom`, and there is no Solana counterpart.

## The Signed Mandate (EIP-712)

Domain: `name = "PeltierSubscriptions"`, `version = "1"`, chain-bound (`chainId` + `verifyingContract` = the singleton -- never SansChainId). The struct and typehash, verbatim from the contract:

```solidity
struct Subscription {
    address payer;        // who pays; must sign; ERC-20 allowance grantor
    address token;        // charged ERC-20 (any token the payer chooses)
    address recipient;    // pinned merchant address — the ONLY principal destination
    uint256 maxPerPeriod; // hard cap per period, token base units, > 0
    uint256 period;       // seconds, > 0 (e.g. 2592000 = 30 days)
    uint256 start;        // unix seconds; first period starts here
    uint256 end;          // unix seconds; 0 = open-ended (until cancel)
    uint256 maxFeeBps;       // payer-signed PLATFORM fee ceiling (effective cap: min with MAX_FEE_BPS)
    uint256 appFeeBps;       // app's own cut -- NO on-chain cap; the net floor governs
    address appFeeRecipient; // app fee sink (payer-signed; 0 requires appFeeBps == 0)
    bytes32 salt;         // distinguishes otherwise-identical subscriptions
}

bytes32 constant SUBSCRIPTION_TYPEHASH = keccak256(
    "Subscription(address payer,address token,address recipient,uint256 maxPerPeriod,uint256 period,uint256 start,uint256 end,uint256 maxFeeBps,uint256 appFeeBps,address appFeeRecipient,bytes32 salt)"
);
bytes32 constant CANCEL_TYPEHASH = keccak256("CancelSubscription(bytes32 subId)");
```

The subscription id **is** the full EIP-712 digest: `subId = keccak256("\x19\x01" ‖ domainSeparator ‖ structHash)`. Ids are therefore chain- and contract-bound by construction, and the contract verifies the payer's signature against the raw `subId` digest directly (it is never wrapped again).

## charge() Trust Rules

`charge(sub, sig, amount, feeBps)` is the **only** external function that moves funds -- up to three direct `safeTransferFrom` calls: payer → treasury (platform fee, if any), payer → `appFeeRecipient` (app fee, if any) and payer → recipient (the rest). The contract never holds balances: no `receive()`, no `fallback()`, no sweep/rescue function.

- **Permissionless.** The caller is irrelevant -- authorization rests solely on the payer's signature over `sub` itself (Solady `SignatureCheckerLib`: EOAs via ecrecover, smart wallets via ERC-1271). Possession of `(sub, sig)` lets *anyone* realize exactly the signed exposure -- at most `maxPerPeriod` per period, within `[start, end]`, funds flowing only to the pinned `recipient`, the payer-signed `appFeeRecipient`, and the immutable `treasury` -- and nothing more. A leaked signature is a scheduling nuisance (charges may fire early in a period), never a theft.
- **Cap enforced before any external call.** Per-period accounting is written *before* the token transfers and `charge` is `nonReentrant`, so even a reentrant token callback observes the already-debited period state. The cap is a per-period budget: a new period rolls spent back to 0.
- **Platform fee double-clamped.** `feeBps <= sub.maxFeeBps` (the payer-signed ceiling) AND `feeBps <= MAX_FEE_BPS` (the immutable 1% anchor). The treasury is immutable -- a repointable fee sink would be principal theft wearing a "fee" costume.
- **App fee is payer-signed and uncapped.** `sub.appFeeBps` → `sub.appFeeRecipient` is the integrating app's own cut, pinned in the mandate the payer signs, so the operator can neither redirect nor inflate it. It has **no** on-chain cap -- only `platformFee + appFee <= amount` bounds it, and a non-zero `appFeeBps` with a zero `appFeeRecipient` reverts `BadAppFee`.
- **Cancel is unconditional for the payer.** `cancel(sub)` is gated only on `msg.sender == sub.payer` -- never blockable by operator, recipient, or contract state (works before start, mid-period, after end). `cancelBySig(sub, cancelSig)` lets anyone relay a payer-signed cancel, gasless for the payer. Cancel is irreversible: the tombstone kills the signature forever.

## Payer Flow

Two signatures, zero transactions (the merchant/relayer pays all gas):

1. **Sign the typed data.** The SDK builds the exact viem payload: `subscriptionTypedData(chainId, singletonAddress, sub)` from `@peltier/node` (primaryType `"Subscription"`). `subscriptionId(...)` computes the `subId` digest client-side.
2. **Approve the singleton** on the token: `token.approve(PeltierSubscriptions, ...)`. Allowance size is UX freedom, per the contract natspec: **even an unlimited allowance is safe**, because the contract -- not the allowance -- enforces the per-period cap and the pinned recipient (that is the whole point of the design). There is no allowance pre-check in `charge()`; a shortfall simply reverts the transfer, and the failed charge leaves no spent accounting behind.

To cancel, the payer either calls `cancel(sub)` on-chain themselves, or signs `CancelSubscription(subId)` and posts it to the public gasless-cancel endpoint below -- cancel must never depend on the payer holding gas money.

## Merchant Flow

| Method | Path | Auth | Description |
| --- | --- | --- | --- |
| **POST** | `/v1/subscriptions` | `sk_` | Validate + store a payer-signed mandate. 409 if the same digest is already registered. |
| **GET** | `/v1/subscriptions` | `sk_` | List, newest first: `{ data: [row…], total }`. Query: `limit` (default 20, max 100), `offset`, `status`. |
| **GET** | `/v1/subscriptions/:id` | `sk_` | One row + its 10 most recent charges. |
| **POST** | `/v1/subscriptions/:id/cancel` | `sk_` | Merchant-side cancel: stops the scheduler. Does **not** touch the chain -- only the payer can kill the mandate itself. |
| **POST** | `/v1/subscriptions/:id/charges` | `sk_` | Enqueue an off-schedule charge for the *current* period (409 if one already exists for it). `202` -- the worker executes it. Body: `{ "amount": "…" }`, optional (defaults to `amount_per_charge`). |
| **GET** | `/v1/subscriptions/:id/charges` | `sk_` | All charges, newest first: `{ data: [charge…] }`. |
| **POST** | `/subscriptions/:sub_id/cancel` | public | Payer-side gasless cancel (below). Keyed by the on-chain `sub_id` digest, not the internal uuid. |

Create request (`POST /v1/subscriptions`) -- the payer-signed struct fields verbatim, plus the operator's schedule:

```json
{
  "chain_id": 8453,
  "subscription": {
    "payer": "0x1111111111111111111111111111111111111111",
    "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
    "recipient": "0x2222222222222222222222222222222222222222",
    "max_per_period": "25000000",
    "period": 2592000,
    "start": 1750000000,
    "end": 0,
    "max_fee_bps": 100,
    "app_fee_bps": 250,
    "app_fee_recipient": "0x3333333333333333333333333333333333333333",
    "salt": "0x00000000000000000000000000000000000000000000000000000000000000a1"
  },
  "signature": "0x…",
  "amount_per_charge": "10000000",
  "order_id": "sub_42",
  "metadata": { "plan": "pro" },
  "smart_wallet": false
}
```

`amount_per_charge` is **optional and defaults to `max_per_period`**. `smart_wallet: true` skips the backend's EOA recovery precheck for ERC-1271 payers (the contract validates the signature on-chain at first charge). Validation: the `recipient` must match one of the merchant's pinned `/v1/destinations` (so an operator typo -- or a hijacked `sk_` -- cannot bind a payer mandate to an unowned address), and `amount_per_charge <= max_per_period`.

Response -- the wire row (`salt`/`signature` are never echoed):

```json
{
  "id": "a1b2c3d4-…",
  "sub_id": "0xa3d15b0007e9d9e25a0f173b827d8dd15a22421423d123604577f09f3ff0ddae",
  "chain_id": 8453,
  "payer_address": "0x1111…",
  "token_address": "0x8335…",
  "recipient_address": "0x2222…",
  "max_per_period": "25000000",
  "period_seconds": 2592000,
  "start_at": 1750000000,
  "end_at": null,
  "max_fee_bps": 100,
  "app_fee_bps": 250,
  "app_fee_recipient": "0x3333333333333333333333333333333333333333",
  "amount_per_charge": "10000000",
  "status": "active",
  "next_charge_at": "2025-06-15T15:06:40Z",
  "last_charge_at": null,
  "order_id": "sub_42",
  "metadata": { "plan": "pro" },
  "created_at": "…"
}
```

`status` is `active` | `canceled` | `ended` (past the signed `end` window). Charge rows carry `{ id, period_index, amount, fee_bps, tx_hash, status, error, created_at }` with `status` `pending` | `submitted` | `confirmed` | `failed`.

> **Exposure honesty.** Because `charge()` is permissionless, the payer's *true* per-period exposure is the signed `max_per_period` -- `amount_per_charge` is merely the operator's scheduling default, not a bound the payer can rely on. **Merchant UIs must display `max_per_period` as the authorized amount**, not the per-charge figure. This is why it defaults the schedule amount and is carried prominently on every wire row.

The SDK wraps all of these: `createSubscription`, `listSubscriptions`, `getSubscription`, `cancelSubscription`, `triggerSubscriptionCharge`, `listSubscriptionCharges`, `relaySubscriptionCancel` -- all on `@peltier/node` (server-side, `sk_`; the cancel relay is the lone public exception). Each accepts a trailing `RequestOptions` (`timeoutMs` / `signal` / `fetch`) and throws `PeltierApiError` on failure ([Errors & Troubleshooting](/docs/errors.md)). The payer-side EIP-712 builders (`subscriptionTypedData`, `cancelSubscriptionTypedData`, `subscriptionId`) plus `relaySubscriptionCancel` also ship in the browser package `@peltier/react`; the `sk_`-keyed management fns deliberately do not.

## Charge Scheduling Worker

Charges execute in a dedicated worker process, one per chain:

```bash
cargo run -- subscriptions --chain-id 8453 --rpc-url https://...
```

Requires `SUBSCRIPTIONS_ADDRESS` (fails fast without it), plus the usual `DATABASE_URL` / `RELAYER_PRIVATE_KEY`. Two passes per 15-second tick:

- **Scheduler** -- for active subscriptions on this chain with `next_charge_at <= now()`: compute the on-chain period index `p = (now − start) / period`, insert the pending charge row for that period (dup-safe via a per-period UNIQUE key), advance the cursor to `start + (p+1)·period`. Subscriptions past their signed `end` flip to `ended` and drop out.
- **Executor** -- claim executable charges (`FOR UPDATE SKIP LOCKED`) and submit `charge(sub, sig, amount, feeBps)`. The **platform** fee is `min(merchant commission, payer-signed max_fee_bps, MAX_FEE_BPS)`; the app fee rides inside the signed `sub` (`appFeeBps` → `appFeeRecipient`) and needs no clamp, so the `charge` args are unchanged. `eth_estimateGas` simulates first, so a would-revert charge (cap exhausted, allowance short, canceled in the same block) fails without burning relayer gas; the broadcast tx hash is recorded *before* waiting for the receipt (crash recovery). Failed charges retry each tick up to **3 attempts**, then stay failed until the next period's schedule inserts a fresh row. Before every attempt the worker pre-flights the contract's `canceled` mapping -- a payer who canceled on-chain without telling anyone is mirrored into the row immediately.

The contract re-verifies everything: a mis-scheduled charge reverts, it can never overdraw. The relayer only pays gas; funds move payer → treasury / `appFeeRecipient` / recipient inside the contract's `safeTransferFrom` calls (up to three: platform fee, app fee, and the remainder).

## Gasless Payer Cancel

**POST** `/subscriptions/:sub_id/cancel` `public` -- body `{ "cancel_signature": "0x…" }`.

The payer proves control by signing `CancelSubscription(subId)` (the SDK's `cancelSubscriptionTypedData`); the signature is the only credential on this route. The backend relays `cancelBySig` on-chain **synchronously** -- the relayer pays gas, because cancel must never depend on the payer holding gas money -- and only then marks the row canceled. If the relay fails, the row stays untouched and the caller gets a `502`: claiming "canceled" while the mandate is still live on-chain would be a lie about the payer's exposure. Response: `{ "status": "canceled", "tx_hash": "0x…" }` (`tx_hash` is `null` when the mandate was already canceled -- the call is idempotent).

## Webhooks

Subscription events ride the existing webhook queue -- same worker, same `Peltier-Signature` HMAC envelope, same retries (see [Webhooks](/docs/webhooks.md)); `await verifyWebhookSignature(…)` covers both families. Base payload: `{ subscription_id, sub_id, merchant_id, event_type, order_id, chain_id, payer_address, token_address, amount_per_charge, timestamp }`.

| Event | Fires when | Extra fields | Idempotency-Key |
| --- | --- | --- | --- |
| `subscription.created` | Mandate registered. | -- | `sub:{uuid}:created` |
| `subscription.charged` | Charge confirmed on-chain. | `amount`, `fee_bps`, `tx_hash`, `period_index` | `sub:{uuid}:charged:{period}` |
| `subscription.charge_failed` | A charge attempt failed. | `error_message`, `period_index` | `sub:{uuid}:charge_failed:{period}:{attempt}` |
| `subscription.canceled` | Merchant cancel, payer relay, or on-chain cancel mirrored by the worker. | -- | `sub:{uuid}:canceled` |

The `:canceled` key is shared across all three cancel paths, so racing them still delivers exactly one webhook.

## Cross-Layer Digest Parity

Solidity, the Rust backend, and the TS SDK each compute the same EIP-712 encoding independently -- byte-identical digests are the acceptance bar, pinned by a shared test vector at `docs/subscription-test-vectors.json` (a known domain/struct with its expected `subId` and cancel digest). Each layer asserts it in its own test suite: `contracts/test/Subscriptions.t.sol`, the tests in `backend/src/subscriptions/eip712.rs`, and `sdk/packages/core/src/subscriptions.test.ts`. Regenerate only if the pinned typehash/domain changes -- and update all three together.
