Overview
The RecallablePayment contract distributes ERC-20 payments to recipients using explicit, on-chain per-recipient allocations. Unlike the dividend functionality in InterestPayment, amounts are arbitrary (not pro-rata to token holdings), and recipients claim directly from the contract without supplying any proof data.
A distribution moves through a two-phase lifecycle — createDistribution → fundDividend — before recipients can claim. Allocations must be recorded first; funding is then deposited in one or more calls. Chunked createDistribution calls can build a large recipient set across many transactions before claiming ever opens; fundDividend is the only path that auto-starts a distribution once it is fully funded.
It intentionally mirrors the dividend ABI of InterestPayment (claimDividend, batchClaimDividend, unclaimedBalanceAt, claimedBalanceAt, tokensAt, fundsAt) for familiar integration.
Version compatibility
RecallablePayment is a standalone contract — it is not tied to a security token version. It
needs only snapshots (balanceOfAt / totalSupplyAt) and grantRole / hasRole / revokeRole, so
it operates against EVM Security Token v2 and up, and the Upside platform can deploy it for
v4 and up (v2 and v3 tokens support the contract, but it has to be deployed elsewhere). Role
checks route to its own AccessControl instance, which in most production setups is deliberately
separate from the security token's, so admin authority over the payment token (USDC and similar)
stays isolated — see Deployment.
Key Features
- Arbitrary amounts, not pro-rata: each recipient is assigned an explicit amount.
- Two-phase lifecycle: allocations first (
createDistribution), then funding with auto-start (fundDividend) keyed by(token, timestamp). Funding before any allocation reverts. - Chunked allocation:
createDistributioncan be called repeatedly for the same(token, timestamp)to add recipients in batches, so the total recipient count is not bounded by a single block's gas limit. - Correctable until claiming actually opens: allocations are overwrite-on-change. Re-submitting a recipient replaces its amount (not additive), an amount of
0removes the recipient, and re-submitting an unchanged amount reverts. Admins fix mistakes by re-uploading only the changed rows (or a small per-wallet batch) while claiming is not yet live — that is, while the distribution is not started, before its unlock time, or still under-funded (totalFunds < totalAllocated). Allocations freeze only once claiming actually opens, i.e. started andblock.timestamp >= unlockedAtTsand fully funded (the same condition asisClaimable). This keeps the correction window open when a post-start allocation raise pushes the distribution under-funded past its unlock time — an admin can lower the allocation back to coverage instead of being forced to deposit the shortfall. - Proof-free claims: allocations are stored on-chain keyed by
(timestamp, token, recipient). Recipients callclaimDividend(token, timestamp, amount)with no extra calldata. - Scheduled unlock: the 4-arg
fundDividend(token, amount, timestamp, unlockedAtTs)pins the claim-open time on the first deposit that supplies it, and that value is honored at auto-start even if a later deposit completes the funding. The 3-arg overload carries no unlock — it funds freely and either honors a pinned time or, if none was pinned, opens claiming atblock.timestamp(immediately claimable). A later pre-start 4-arg deposit with a different unlock reverts (RecallablePayment_UnlockTimeConflict). The time is adjustable after start viasetUnlockTime; passing0means "open immediately" and is stored asblock.timestamp. - Reclaimable: an admin can reclaim allocations recipients have not claimed, and recover any over-funded surplus.
- Familiar interface: same claim/view signatures as the current payment contract for easy integration.
- ERC2771 meta-transactions: requires a non-zero trusted forwarder at deploy for gasless claims/funding (the forwarder is immutable thereafter).
- TA lifecycle / record keeping: emits
AllocationUpdated,DividendFunded,DistributionStarted,DistributionUnlockTimeUpdated,DividendClaimed, andDividendReclaimedevents consumed by the off-chain Transfer Agent indexer.
Why not a Merkle tree?
A Merkle-root approach would require claimers to provide the Merkle proof inputs (the leaf data and path) when claiming. By storing allocations directly in on-chain mappings, claimers do not need to supply any additional data — they call the same claimDividend(token, timestamp, amount) interface used elsewhere. The trade-off is one storage write per recipient at allocation time, which is the standard and accepted cost for proof-free claims on EVM.
The timestamp argument is reused purely as an arbitrary distribution identifier (it is not a historical snapshot id), so the existing claim signatures are preserved exactly.
Lifecycle: create → fund
A distribution is built and activated by the transfer admin in two explicit phases. Each phase is keyed by (token, timestamp).
| Phase | Function | Effect | Claiming | Allocations |
|---|---|---|---|---|
| 1 | createDistribution(token, timestamp, recipients, amounts) | Sets per-recipient allocations (overwrite-on-change; amount 0 removes). totalAllocated tracks the net. No funds pulled. Never auto-starts. | locked | open |
| 2 | fundDividend(token, amount, timestamp) or fundDividend(..., unlockedAtTs) | Pulls amount of token into the contract (totalFunds += amount). Requires totalAllocated > 0. Auto-starts when totalFunds >= totalAllocated. | OPEN at unlock time | editable until claiming opens |
- Phase 1 is repeatable for the same
(token, timestamp)— call it several times to add recipients in chunks, each call its own transaction under the block gas limit. Allocations are overwrite-on-change: re-submitting a recipient replaces its amount (totalAllocatedis adjusted by the signed delta), an amount of0removes the recipient, and re-submitting an unchanged amount reverts withRecallablePayment_AllocationUnchanged. Each changed recipient emits oneAllocationUpdatedevent (amount0= removed). The zero address and the distribution contract itself are rejected as recipients (RecallablePayment_InvalidRecipientAddress) — a self-allocation would be unclaimable dead weight. It reverts withRecallablePayment_DistributionClaimingOpenonly once claiming has actually opened (started && block.timestamp >= unlockedAtTs && totalFunds >= totalAllocated— the same condition asisClaimable). After auto-start allocations remain editable before the unlock time, and also after it while the distribution is still under-funded — so an over-raised allocation can be lowered back to coverage past the unlock time. - Phase 2 requires at least one allocation from Phase 1 (
RecallablePayment_NoAllocationsToFundotherwise). It may run incrementally until fully funded, and as a top-up after start. The claim-unlock time is pinned across pre-start deposits: the first 4-arg deposit pinsunlockedAtTsfor the distribution (accepted as-is, no validation), and every later pre-start 4-arg deposit must present the same value or revert withRecallablePayment_UnlockTimeConflict. The 3-arg overload carries no unlock — it funds freely and honors whatever was pinned, so a schedule set on a partial deposit is never overwritten by a later top-up. When the deposit completes funding (totalFunds >= totalAllocatedwithtotalAllocated > 0), the distribution auto-starts and commits the pinned unlock; if no 4-arg deposit pinned a value, it commitsblock.timestamp(immediately claimable). After start, a 4-arg top-up is ignored unless itsunlockedAtTsmatches the current value — a mismatch reverts withRecallablePayment_UnlockTimeNotApplicableAfterStart(usesetUnlockTimeto reschedule). If an allocation decrease later bringstotalAllocateddown to the already-funded amount, the distribution stays unstarted until a follow-upfundDividendcall (anyamount > 0) triggers auto-start. Funds deposited beyondtotalAllocatedform a surplus recoverable viareclaimTotalDividend. A fee-on-transfer guard reverts (RecallablePayment_InvalidFeeApplied) if the received amount differs fromamount. An admin can move the unlock time earlier (open sooner) or later (postpone) after start viasetUnlockTime(token, timestamp, unlockedAtTs), but only while claiming has not yet opened; onceblock.timestamp >= unlockedAtTsthe unlock time is frozen andsetUnlockTimereverts withRecallablePayment_DistributionClaimingOpen. Passing0means "open immediately": it is stored and emitted asblock.timestamp(same normalization as auto-start), so a started distribution never reads back an ambiguous0fromunlockTimeAt. Reverts withRecallablePayment_DistributionNotStartedbefore start.setUnlockTimeis not blocked by global pause, so admins can reschedule while the contract is paused as long as claiming has not yet opened; recipients still cannot claim until global pause is lifted.
For the full state machine and recipient-ceiling guidance, see the lifecycle deep dive (docs/recallable-payment/lifecycle.md in the comakery-security-token repo).
Claim flow
Claiming requires both gates to pass: the contract must not be globally paused and the distribution must be started with block.timestamp >= unlockedAtTs. Recipients claim proof-free once both are satisfied:
claimDividend(token, timestamp, amount)— transfersamountoftokento the caller.amount = 0claims the full remaining unclaimed balance. The call isnonReentrantand respects global pause.batchClaimDividend(token, timestamps, amounts)— loopsclaimDividendacross multiple distributions of the same token (timestampsandamountsmust be equal length).
Use isClaimable(token, timestamp) to check whether claiming is open right now (started and totalAllocated > 0 and block.timestamp >= unlockedAtTs and totalFunds >= totalAllocated); unlockTimeAt(token, timestamp) returns the configured unlock time (0 only before start / unlock not yet pinned; a started distribution always reports the committed claim-open time).
Reverts:
RecallablePayment_DistributionNotStarted— the distribution has not been started yet.RecallablePayment_DistributionNotUnlocked— started, but the unlock time (unlockedAtTs) has not been reached.RecallablePayment_NoRemainingUnclaimedBalance— the caller has nothing left to claim.RecallablePayment_NotEnoughFundsToClaim— requestedamountexceeds the caller's unclaimed balance.
The unlock-time gate applies to recipient claims (claimDividend / batchClaimDividend) and to reclaimDividend — admin reclaim is symmetric with recipient claiming. reclaimSurplus (surplus recovery) and reclaimTotalDividend (full-pool recall) are not gated on unlockedAtTs and remain available throughout the lifecycle.
Accounting model
Each (timestamp, token) distribution tracks:
| Field | Meaning |
|---|---|
totalFunds | ERC-20 deposited for the distribution (minus any reclaimed surplus) |
totalAllocated | total assigned to recipients |
totalClaimed | total claimed by recipients |
totalReclaimed | total reclaimed from recipients by admins |
unlockedAtTs | unix time at/after which recipients may claim. 0 only before start (unlock not yet pinned); start paths and setUnlockTime normalize a 0 input to block.timestamp, so a started distribution always holds a real claim-open time |
started | whether the distribution has been auto-started via fundDividend (allocations stay editable until claiming opens) |
Per recipient, the contract tracks allocated, claimed, and reclaimed. Once claiming is open (isClaimable — started, unlock time reached, fully funded), a recipient's claimable balance is:
unclaimedBalanceAt = allocated - claimed - reclaimed
unclaimedBalanceAt is gated on isClaimable: it reports 0 while the distribution is pending, still time-locked, or under-funded, rather than the raw formula above. This keeps it aligned with the IDividends doc ("amount of tokens that can be claimed") so a plain-IDividends consumer never sees a non-zero balance for a claim that would actually revert. Use totalAwardedBalanceAt / allocatedBalanceAt to read the raw entitlement for a pending or locked distribution.
Funding follows allocation (fundDividend reverts with RecallablePayment_NoAllocationsToFund when totalAllocated == 0), so over-funding produces a real surplus (totalFunds - totalAllocated). The full-funding gate enforced at auto-start (totalFunds >= totalAllocated) guarantees that every owed allocation is fully backed once claiming opens, preserving the coverage invariant:
tokensAt = totalFunds - totalClaimed - totalReclaimed >= remaining owed
Reclaiming
reclaimDividend(token, recipient, timestamp, amount)— claws back a specific recipient's unclaimed allocation and sends it toreclaimerAddress.amount = 0reclaims all of that recipient's remaining unclaimed balance. After a reclaim the recipient can no longer claim the reclaimed portion. Requires the same gates asclaimDividend: started, unlock time reached, and fully funded. To recover funds while claiming is not open — before start, before the unlock time, or while under-funded (allocations stay editable in all three cases) — reduce or zero the allocation viacreateDistribution, then callreclaimSurplusto drain the resulting surplus.reclaimSurplus(token, amount, timestamp)— reclaims deposited surplus (totalFunds - totalAllocated) toreclaimerAddress. With funding decoupled from allocation this is a reachable path; it can never touch amounts still owed to recipients. Not gated onstartedorunlockedAtTs.reclaimTotalDividend(token, amount, timestamp)— theIDividends-mandated full-pool recall: reclaims the distribution's entire remainingtotalFundstoreclaimerAddress, but only while nothing has been claimed or reclaimed for it (totalClaimed == 0 && totalReclaimed == 0); reverts withRecallablePayment_DividendsAlreadyClaimedotherwise. UnlikereclaimSurplus, this can pull funds still owed to recipients — safe only because none has been claimed yet. DrainingtotalFundsbelowtotalAllocatedmakes the distribution under-funded (claims blocked until it is topped up again). Not gated onstartedorunlockedAtTs.
All three reclaim functions require a non-zero reclaimerAddress (set via setReclaimerAddress) and Transfer Admin privileges. setReclaimerAddress rejects the zero address and the distribution contract itself (RecallablePayment_InvalidReclaimerAddress) — a self-reclaimer would strand tokens with no sweep path — and rejects a no-op that leaves the current value unchanged (RecallablePayment_ReclaimerAddressUnchanged).
Wallet recovery
forceTransferDividend(token, timestamp, oldWallet, newWallet) migrates a recipient's full position for one (token, timestamp) distribution — allocated, claimed, and reclaimed — from a lost wallet to a new wallet. If newWallet already has a position in the same distribution, the values are merged. newWallet may not be the zero address or the distribution contract itself (the contract cannot claim from itself, so a self-position would be dead weight). Callable only by Contract Admin. Not gated on global pause, started, or unlockedAtTs, so recovery can proceed even while the contract is paused or before claiming opens. Must be called once per distribution when the lost wallet has multiple distributions.
Operational considerations
The Contract Admin that gates forceTransferDividend should be sufficiently decentralized in production, since RecallablePayment holds and distributes payment tokens such as USDC. There may be regulatory reasons to rely on recovery wallets being non-custodial and isolated from other wallet controls. In practice the Contract Admin is likely to be a Gnosis Safe (or similar multisig) rather than a single EOA. Gating wallet recovery on Contract Admin — alongside setReclaimerAddress and pause — is intended to accommodate that operational setup; flag this for deployment runbooks and operator onboarding.
Supported payment tokens
RecallablePayment supports standard, fixed-balance ERC-20 tokens only. Fee-on-transfer (deflationary), rebasing / elastic-supply, and ERC777-callback tokens are not supported as a distribution token.
This is enforced at funding: fundDividend compares the contract's balance before and after the
inbound transferFrom and reverts with RecallablePayment_InvalidFeeApplied if the received amount
differs from the requested amount. The check is strict equality, so it rejects both a transfer fee
and an unexpected balance increase (positive rebase, or a third-party push during an ERC777 hook).
The payout path is deliberately not guarded. claimDividend, reclaimDividend,
reclaimSurplus and reclaimTotalDividend settle the nominal amount held in the accounting model
described under Accounting model and never reconcile against the live token
balance. If a non-standard token is used in spite of the constraint, behaviour on that path is
undefined: an outbound transfer fee would silently underpay recipients while claimed/reclaimed
record the full amount, and a negative rebase would drop the real balance below Σ owed, so the
last claimants revert while unclaimedBalanceAt still reports a balance. There is no sweep path to
recover from either state.
Because accounting is counter-based, operators should compare
IERC20(token).balanceOf(<RecallablePayment>) against the outstanding obligation across live
distributions to detect drift before claimants hit a revert. USDC, DAI and similar fixed-balance
stablecoins satisfy the constraint.
Roles
Role checks are routed to the external AccessControl contract (the same model used by InterestPayment):
| Action | Required role |
|---|---|
createDistribution, fundDividend, reclaimDividend, reclaimSurplus, reclaimTotalDividend | Transfer Admin |
forceClaimDividend / batchForceClaimDividend | Transfer Admin |
setUnlockTime | Transfer Admin (not blocked by global pause) |
setReclaimerAddress | Contract Admin |
forceTransferDividend | Contract Admin (not blocked by global pause or claimable gates) |
pause(bool) | Contract Admin or Transfer Admin |
claimDividend / batchClaimDividend | any recipient (self) |
Events
Consumed by the off-chain Transfer Agent indexer (see contracts/interfaces/IRecallablePayment.sol):
| Event | Emitted when |
|---|---|
AllocationUpdated(token, recipient, timestamp, amount, admin) | a recipient's allocation is set, updated, or removed in createDistribution. amount is the recipient's new allocation (not a delta): amount == 0 means removed, amount > 0 means created/updated. Index consumers can key on (timestamp, token, recipient) and upsert when amount > 0 / delete when amount == 0. The resulting distribution total is readable via totalAllocatedAt. |
DividendFunded(payer, token, amount, timestamp) | funds deposited via fundDividend |
DistributionStarted(admin, token, timestamp, totalAllocated, totalFunds, unlockedAtTs) | distribution auto-started via fundDividend when fully funded. Carries activation-time totals and the scheduled unlock; does not freeze the recipient set — keep processing AllocationUpdated until claiming opens |
DistributionUnlockTimeUpdated(admin, token, timestamp, unlockedAtTs) | the unlock time is changed after start via setUnlockTime. Carries the normalized value: setUnlockTime(..., 0) emits block.timestamp, not 0 |
DividendClaimed(payee, token, amount, timestamp) | a recipient claims |
DividendReclaimed(payee, target, token, amount, timestamp) | an admin reclaims. payee is the transfer admin that initiated the reclaim — not the fund destination; funds go to the current reclaimerAddress (recoverable from the ReclaimerAddressChanged history, which always has at least one entry before any reclaim). target is the recipient clawed back from (reclaimDividend), or the contract address for pool-level reclaims (reclaimSurplus / reclaimTotalDividend) |
ReclaimerAddressChanged(admin, previousReclaimer, newReclaimer) | setReclaimerAddress changes the reclaimer (previousReclaimer is zero on first assignment) |
DividendForceTransferred(token, timestamp, oldWallet, newWallet, allocated, claimed, reclaimed, admin) | a recipient's position is migrated via forceTransferDividend |
Indexer allocation snapshot rules
Do not snapshot the recipient set on DistributionStarted. Allocations stay mutable until claiming actually opens — started && block.timestamp >= unlockedAtTs && totalFunds >= totalAllocated (the same condition as isClaimable, and the condition that makes createDistribution revert with RecallablePayment_DistributionClaimingOpen):
- Continue upserting/deleting from
AllocationUpdatedwhile the distribution is not claimable — including when the unlock time has passed buttotalFunds < totalAllocated(a post-start raise can still be lowered back to coverage). - Treat allocations as frozen only once
started && block.timestamp >= unlockedAtTs && totalFunds >= totalAllocated(furthercreateDistributioncalls revert). Do not freeze purely onblock.timestamp >= unlockedAtTs— an under-funded distribution past its unlock time is still editable. - Prefer on-chain
totalAllocatedAt/allocatedBalanceAtas source of truth over event snapshots.
Errors
| Error | Reverts when |
|---|---|
RecallablePayment_InvalidTimestamp() | timestamp == 0 |
RecallablePayment_InvalidTokenAddress() | token == address(0) |
RecallablePayment_InvalidTrustedForwarder() | constructor called with a zero trustedForwarder |
RecallablePayment_InvalidRecipientAddress(index) | a recipient at index is the zero address or the distribution contract itself |
RecallablePayment_InvalidAmount(index) | the funded amount (in fundDividend) is zero |
RecallablePayment_NoAllocationsToFund() | fundDividend called before any allocation exists for (token, timestamp) |
RecallablePayment_AllocationUnchanged(index) | the amount at index equals the recipient's current allocation (incl. 0 for a recipient that has none) — a stale/duplicate no-op |
RecallablePayment_InvalidArrayLengths() | recipients/amounts (or timestamps/amounts) lengths differ |
RecallablePayment_EmptyRecipients() | createDistribution called with no recipients |
RecallablePayment_InvalidFeeApplied() | fee-on-transfer detected during fundDividend |
RecallablePayment_UnlockTimeConflict(provided, scheduled) | a pre-start 4-arg fundDividend presents an unlockedAtTs that differs from the value already pinned by an earlier deposit |
RecallablePayment_UnlockTimeNotApplicableAfterStart(requested, current) | 4-arg fundDividend after start with an unlockedAtTs that differs from the stored value |
RecallablePayment_DistributionClaimingOpen() | createDistribution once claiming has actually opened (started && block.timestamp >= unlockedAtTs && totalFunds >= totalAllocated), or setUnlockTime once the unlock time has been reached (started && block.timestamp >= unlockedAtTs). Note the difference: allocation edits stay open while under-funded past the unlock time, but rescheduling the unlock time does not |
RecallablePayment_DistributionNotStarted() | claiming before auto-start, or setUnlockTime / reclaimDividend before start |
RecallablePayment_DistributionNotUnlocked() | claiming or reclaimDividend after start but before unlockedAtTs is reached |
RecallablePayment_NoRemainingUnclaimedBalance() | claim/reclaim when the recipient has nothing left |
RecallablePayment_NotEnoughFundsToClaim() | requested amount exceeds the unclaimed, surplus, or total-funds balance |
RecallablePayment_NoFundsToClaim() | reclaimSurplus with zero surplus, or reclaimTotalDividend with zero totalFunds |
RecallablePayment_DividendsAlreadyClaimed() | reclaimTotalDividend after any claim or reclaim has occurred for the distribution |
RecallablePayment_InvalidReclaimerAddress() | reclaim attempted while reclaimerAddress == address(0), or setReclaimerAddress called with the zero address or the distribution contract itself |
RecallablePayment_ReclaimerAddressUnchanged() | setReclaimerAddress called with the current reclaimer address |
RecallablePayment_InvalidWalletAddress() | forceTransferDividend called with a zero oldWallet, or a newWallet that is zero or the distribution contract itself, or forceClaimDividend called with a zero wallet or the distribution contract itself |
RecallablePayment_SameWallet() | forceTransferDividend called with oldWallet == newWallet |
RecallablePayment_NoPositionToTransfer() | forceTransferDividend called when oldWallet has no position in the distribution |
Interface summary
State-changing:
createDistribution(address token, uint256 timestamp, address[] recipients, uint256[] amounts)fundDividend(address token, uint256 amount, uint256 timestamp)fundDividend(address token, uint256 amount, uint256 timestamp, uint256 unlockedAtTs)setUnlockTime(address token, uint256 timestamp, uint256 unlockedAtTs)—0means open now (stored/emitted asblock.timestamp)claimDividend(address token, uint256 timestamp, uint256 amount)batchClaimDividend(address token, uint256[] timestamps, uint256[] amounts)forceClaimDividend(address token, address wallet, uint256 timestamp, uint256 amount)batchForceClaimDividend(address token, address wallet, uint256[] timestamps, uint256[] amounts)reclaimDividend(address token, address recipient, uint256 timestamp, uint256 amount)reclaimSurplus(address token, uint256 amount, uint256 timestamp)reclaimTotalDividend(address token, uint256 amount, uint256 timestamp)setReclaimerAddress(address newReclaimer)forceTransferDividend(address token, uint256 timestamp, address oldWallet, address newWallet)pause(bool shouldPause)
Views:
fundsAt(token, timestamp)— total fundedisStarted(token, timestamp)— whether the distribution is started (rawstartedflag; ignores the unlock time)unlockTimeAt(token, timestamp)— the configured unlock time (unlockedAtTs);0only before start, otherwise the committed claim-open timeisClaimable(token, timestamp)— whether claiming is open right now (started andblock.timestamp >= unlockedAtTsandtotalFunds >= totalAllocated)tokensAt(token, timestamp)— remaining (unclaimed and not reclaimed) funds heldtotalAllocatedAt(token, timestamp)— total currently allocated across all recipientsallocatedBalanceAt(token, recipient, timestamp)totalAwardedBalanceAt(token, recipient, timestamp)— originally allocated amountclaimedBalanceAt(token, recipient, timestamp)reclaimedBalanceAt(token, recipient, timestamp)unclaimedBalanceAt(token, recipient, timestamp)— currently-claimable amount;0unlessisClaimableholds (diverges fromIDividends' raw-entitlement description; see above)
Deep dives
These live in the comakery-security-token repo rather than on this site:
- Distribution lifecycle (
docs/recallable-payment/lifecycle.md) — the full create → fund state machine, claiming, reclaiming, and how to raise the recipient ceiling via chunked allocation. - Gas measurements (
docs/recallable-payment/gas-measurements.md) — per-function gas usage, scaling behaviour, and block-gas-limit batch-size guidance.
Deployment
See deploy/12_recallable_payment.js. The constructor takes:
constructor(address accessControl, address trustedForwarder, address restrictedLockupToken)
accessControl(required, non-zero) — externalAccessControlcontract used for role checks. The deployer chooses any implementation: the security token's sharedAccessControl, a dedicated payment-tokenAccessControl, or another compatible contract. In most production setups, a separateAccessControlis preferred so Transfer Admin and Contract Admin authority over USDC (or other payment tokens) stays isolated from the security token's admin set. Grant roles on whicheverAccessControlis wired here.trustedForwarder(required, non-zero) — ERC2771 forwarder. The constructor reverts withRecallablePayment_InvalidTrustedForwarderif zero; the forwarder is immutable in ERC2771Context, so an accidental zero could never be corrected without redeploying. Seedocs/recallable-payment/deploy.mdin the comakery-security-token repo for config resolution.restrictedLockupToken— optionalRestrictedLockupTokenreference for discoverability and integration. Zero = standalone distributor. Independent ofaccessControl: you may set the related security token address while wiring a dedicated paymentAccessControl.