Contract reference
This page is the engineering reference for the on-chain pieces of the non-custodial stack. Addresses and audit reports are published below per chain as phases ship.
Address conventions (EVM)
All four EVM contracts share the same deployer (a deterministic CREATE2 deployer, e.g. 0x4e59b44847b379578588920cA78FbF26c0B4956C) and the same init-code hash per version, so the factory and hub addresses are the same on every EVM chain. That simplifies client code and docs.
| Chain | chainId | PaymentLinkFactory | SubscriptionHub | FeeSplitter |
|---|---|---|---|---|
| Base | 8453 | published on deploy | published on deploy | published on deploy |
| Ethereum | 1 | same | same | same |
| Arbitrum One | 42161 | same | same | same |
| Optimism | 10 | same | same | same |
| Polygon PoS | 137 | same | same | same |
PaymentLinkReceiver addresses are per-link: factory.computeAddress(keccak256(linkId)).
EVM: PaymentLinkFactory
Functions
computeAddress(bytes32 linkId) external view returns (address)— deterministic CREATE2 address. Can be called before any deploy.deployAndPay(bytes32 linkId, Split calldata split, address token, uint256 amount) external payable— deploys the clone on first pay and forwards funds.isDeployed(bytes32 linkId) external view returns (bool).setSplit(bytes32 linkId, Split calldata split) external— callable only before first pay; sets the immutable split that the clone will be initialized with.pause() / unpause()— multisig-only.
Events
ReceiverDeployed(bytes32 indexed linkId, address receiver)SplitRegistered(bytes32 indexed linkId, Split split)
Split struct
struct Split {
address merchant;
address platform;
address referral;
address bridgeFee;
uint16 platformBps;
uint16 referralBps;
uint16 bridgeFeeBps;
}
Sum of bps fields must be < 10000. Remainder goes to the merchant.
EVM: PaymentLinkReceiver (minimal clone)
Functions
pay(address token, uint256 amount) external payable— main entrypoint.token == address(0)means native.refund(bytes32 txId, address to, uint256 amount) external— restricted to the keeper EOA or deployer multisig.split() external view returns (Split memory).
Events
Paid(bytes32 indexed linkId, address indexed payer, address indexed token, uint256 amount, bytes32 txId)Refunded(bytes32 indexed linkId, bytes32 indexed txId, address to, uint256 amount)
Reverts
InvalidToken,InvalidAmount,SplitFailed,AlreadyRefunded.
EVM: FeeSplitter
Functions
split(Split calldata s, address token, uint256 amount) external payable— internal call fromPaymentLinkReceiverandSubscriptionHub. Not typically called directly.
Events
SplitExecuted(bytes32 indexed txId, address merchant, uint256 merchantAmount, address platform, uint256 platformAmount, address referral, uint256 referralAmount, address bridgeFee, uint256 bridgeFeeAmount)
EVM: SubscriptionHub
Functions
createSubscription(bytes32 id, address payer, IERC20 token, uint256 amount, uint64 interval, uint256 cap, Split calldata split) external— registers the subscription on-chain.charge(bytes32 id) external— public and permissionless; enforces timing, cap, allowance, and balance.charge(bytes32[] ids) external— batch version; each id independently validated.cancel(bytes32 id) external— callable by the payer.subscription(bytes32 id) external view returns (Subscription memory).isDue(bytes32 id) external view returns (bool)— helper for keepers.
Events
SubscriptionCreated(bytes32 indexed id, address indexed payer, IERC20 token, uint256 amount, uint64 interval, uint256 cap)Charged(bytes32 indexed id, uint256 amount, uint64 nextChargeAt)ChargeSkipped(bytes32 indexed id, bytes32 reason)— reasons:NotDue,InsufficientAllowance,InsufficientBalance,AlreadyChargedThisPeriod,CapExceeded,Paused.Canceled(bytes32 indexed id)
Invariants enforced in charge
block.timestamp >= subscription.nextChargeAtblock.timestamp > subscription.lastChargedAt(monotonic)subscription.amountCharged + subscription.amount <= subscription.captoken.allowance(subscription.payer, address(this)) >= subscription.amounttoken.balanceOf(subscription.payer) >= subscription.amount
Example payer flow
// 1. Payer signs an allowance to the hub
await usdc.approve(subscriptionHub.address, cap)
// 2. Merchant (via OrcaRail) creates the subscription on-chain
await subscriptionHub.createSubscription(
id,
payer,
usdc,
amount,
interval,
cap,
split
)
// 3. Anyone calls charge when due
await subscriptionHub.charge(id)
Solana: payment_link program
Accounts
#[account]
pub struct PaymentLink {
pub link_id: [u8; 32],
pub expected_mint: Pubkey,
pub expected_amount: u64,
pub split: Split,
pub created_at: i64,
pub status: PaymentStatus,
}
PDA seeds: [b"pl", link_id].
Instructions
initialize(link_id, expected_mint, expected_amount, split)— lazy init on first use.pay(link_id, amount)— CPI-transfers from payer's ATA into the PDA's ATA, then CPI-splits.refund(link_id)— multisig-gated.
Events
PaymentReceived { link_id, payer, amount }PaymentRefunded { link_id, to, amount }
Solana: subscription program
Accounts
#[account]
pub struct Subscription {
pub id: [u8; 32],
pub payer: Pubkey,
pub mint: Pubkey,
pub amount: u64,
pub interval: i64,
pub cap: u64,
pub amount_charged: u64,
pub next_charge_at: i64,
pub last_charged_at: i64,
pub split: Split,
}
Instructions
create(id, payer, mint, amount, interval, cap, split)— registers subscription.charge(id)— public; validates clock, cap, delegate authority, then CPItransfer_checkedas delegate.cancel(id)— payer only.
Events (via program logs)
SubscriptionCreated,Charged { amount, next_charge_at },ChargeSkipped { reason },Canceled.
Example payer flow
// 1. Payer approves program PDA as delegate on their USDC ATA
await token.approve(payerUsdcAta, programPda, capAmount)
// 2. create() registers the subscription PDA
// 3. charge() can be called by anyone once due
Bitcoin: per-link P2WSH multisig
Descriptor template
wsh(multi(2, <merchant_pubkey>, <platform_pubkey>))
Taproot equivalent (v2, once MuSig2 tooling matures):
tr(<NUMS>, multi_a(2, <merchant_pubkey>, <platform_pubkey>))
Derivation
- Merchant key: derived from the merchant's onboarded xpub, one index per payment link.
- Platform key: derived from the platform HSM xpub, one index per payment link.
Refund PSBT
At deposit time, an unsigned PSBT is assembled that returns deposit minus fee to the payer. Both signers sign it immediately; the result is stored encrypted and can be broadcast at any time without further signing.
Audit reports
Published here as they complete. Entries include: vendor, scope, commit, date, report link, fix commit.
(Reports land as phases ship.)