Appearance
PayoutManager
Handles the payout request lifecycle including eligibility validation, fund transfer to a receiver multisig, and Gnosis Safe transaction preparation for user payouts.
High-Level Design
Two-Chain Architecture
Payouts span two chains with a multisig on each side:
Master Account (HL L1) ──usdSend──> Receiver Multisig (HL L1)
│
(manual bridge)
│
Sender Multisig (Arb L2) ──ERC20 transfer──> User Wallet- Hyperliquid L1: Funds move from the master trading account to a receiver multisig via
usdSend(signed by the master account's KMS key) - Arbitrum L2: A Gnosis Safe transaction sends USDC from the sender multisig to the user's payout address
The L1 transfer is automated; the L2 transfer requires admin approval through the Safe workflow.
Payout Lifecycle
User requests payout
│
▼
validatePayout() ── fail ──> return error
│ pass
▼
Check payoutAddress is set
│
▼
Transfer funds to receiver multisig (L1)
│
▼
Record created: REQUESTED
│
├──> Admin: approveAndExecutePayout() ──────────> COMPLETED
│
├──> Admin: createSafeTransaction() ──> PENDING_APPROVAL
│ │
│ └──> Admin: approveAndExecutePayout() ──> COMPLETED
│
├──> Admin: rejectPayout() ──> REJECTED (only from REQUESTED)
│
└──> User: cancelPayoutRequest() ──> CANCELLED (parent DO; see edge cases)Pending payout guard: hasPendingPayoutRequest() checks for REQUESTED status only. A payout advanced to PENDING_APPROVAL no longer blocks new requests.
Validation Gate
Five checks before any funds move:
- Account status — must be
ACTIVEorACTIVE_TRADING - Minimum amount — requested amount >= plan's
minPayoutAmount - Winning days —
monitoring.propWinningDays>= plan'sminWinningDays - Live balance — real-time query to Hyperliquid clearinghouse for
withdrawablebalance;payoutBalance = max(0, withdrawable - safetyNet)must cover the request - Payout address —
masterAccount.payoutAddressmust be set
The live balance check ensures the master account retains at least the safetyNet amount (e.g., $300–$1,400 depending on plan) after the payout.
Transfer-Before-Record Pattern
Funds are transferred to the receiver multisig before the payout record is created in SQLite. This means:
- If transfer fails → no record, no side effects (clean failure)
- If DO crashes after transfer but before insert → funds transferred with no record (requires manual reconciliation)
This ordering prioritizes avoiding phantom records (records without actual fund movement) over the rarer crash-between-steps scenario. There is no idempotency key on the usdSend call, so a retry after crash could result in a duplicate transfer.
User/Platform Split
Total payout is split by the plan's userPayoutRatio:
| Plan | User Share | Platform Share |
|---|---|---|
| STARTER | 80% | 20% |
| STANDARD, PRO | 80% | 20% |
| STANDARD_MEME, PRO_MEME | 90% | 10% |
All amounts are stored in cents (integer) to avoid floating-point precision issues. Conversion: Math.round(amount * 100).
KMS Signing Chain
Master account transfers are signed using AWS KMS keys, not raw private keys:
PayoutManager.getKms() → KMSClient (lazy, cached for DO lifetime)
│
▼
KmsSigner(keyId, kms).toViemAccount() → viem LocalAccount
│
▼
usdSend(wallet, receiverAddress, amount) → Hyperliquid ExchangeClientThe KMS backend is determined by getKmsClient() — the manager itself is agnostic to the implementation.
Gnosis Safe Transaction Flow
The Safe transaction for the L2 USDC transfer is built manually:
- ERC20 encoding: Manual ABI encoding of
transfer(address,uint256)with function selector0xa9059cbb - Nonce: Derived locally from
MAX(safe_tx_nonce) + 1in thepayout_requeststable (not queried from the Safe contract) - Transaction hash: Simplified JSON-based hash (not EIP-712)
- Execution: Only
approveAndExecutePayout()is gated by environment — it throws in production requiring@safe-global/safe-core-sdkintegration.createSafeTransaction()runs in all environments.
USDC address is hardcoded for Arbitrum (0xaf88d065e77c8cC2239327C5EDb3A432268e5831, 6 decimals).
Edge Cases & Error Handling
- Cancellation status mismatch: The parent DO's
cancelPayoutRequest()checks for status"pending", butcreatePayoutRequest()sets status to"requested". This means user cancellation currently never matches — effectively a bug. - No fund return on cancel/reject: Funds already transferred to the receiver multisig are not returned. There is a TODO in the codebase for this.
- No duplicate-transfer guard: The
usdSendcall has no idempotency key. A crash-and-retry between transfer and record insert could send funds twice. parseFloatfor amounts: User-provided amount strings are parsed viaparseFloatbefore cents conversion, which can introduce floating-point imprecision at the boundary.
See Also
- MasterAccountManager — Master account and agent management
- MonitoringManager — Health checks that determine payout eligibility
- AdminReviewManager — Admin remarks on payout stage
- Parent DO: TradeFundAccountRegistry