TLDR:
- A modular, permissionless protocol for transparent attribution and ERC20 reward distribution
- Enables any relationship where Users/Publishers drive conversions and get rewarded by Sponsors through various validation mechanisms
- Flexible hooks system supports diverse campaign types: advertising (AdConversion), e-commerce cashback (CashbackRewards), bridge transaction incentives (BridgeReferralFees), and custom rewards (SimpleRewards)
- Permissionless architecture allows third parties to create custom hooks for any use case
- Each hook can customize payout models, fee structures, and attribution logic while leveraging the core Flywheel infrastructure for secure token management
Flywheel Protocol creates a decentralized incentive ecosystem where:
- Sponsors create campaigns and fund them with tokens (advertisers, platforms, DAOs, etc.)
- Publishers (Optional) drive traffic and earn rewards based on performance (only in AdConversion campaigns)
- Attribution Providers track conversions and submit verified data that triggers payouts to earn fees (only in AdConversion campaigns)
- Managers control campaign operations and submit payout data (in CashbackRewards, BridgeReferralFees, and SimpleRewards campaigns)
- Users can receive incentives for completing desired actions across all campaign types
The protocol uses a modular architecture with hooks, allowing for diverse campaign types without modifying the core protocol.
Core Design Principles
- Modular design with hooks for extensibility
- Core
Flywheel.solprotocol handles only essential functions - Campaign-specific logic isolated in hook contracts (i.e.
AdConversion.sol,CashbackRewards.sol, andBridgeReferralFees.solthat are derived fromCampaignHooks.sol) - Clean separation between protocol and implementation
The diagram above illustrates how the modular Flywheel v1.1 architecture works:
- Core Flywheel Contract: Manages campaign lifecycle and payouts
- Campaign: Each campaign has its own isolated address for storing tokens
- Campaign Hooks: Pluggable logic for different campaign types
- BuilderCodes: Optional referral code system, initially used for publisher-based campaigns
- Participants: Shows the flow between sponsors and recipients based on hook-specific validation
Payout: Direct token transfer to a recipient
struct Payout {
address recipient; // Address receiving the payout
uint256 amount; // Amount of tokens to be paid out
bytes extraData; // Extra data for the payout
}Allocation: Reserved payout identified by a key for future distribution
struct Allocation {
bytes32 key; // Key for the allocation
uint256 amount; // Amount of tokens to be allocated
bytes extraData; // Extra data for the allocation
}Distribution: Claiming of allocated tokens identified by key to a recipient
struct Distribution {
address recipient; // Address receiving the distribution
bytes32 key; // Key for the allocation being distributed
uint256 amount; // Amount of tokens to be distributed
bytes extraData; // Extra data for the distribution
}The main contract that manages:
- Campaign lifecycle (Inactive → Active → Finalizing → Finalized)
- Send, allocation, distribution, and deallocation of payouts
- Enhanced fee collection and distribution with
bytes32key support - Campaign deployment for each campaign
- Key-based allocation system for flexible payout tracking
- Holds campaign funds (ERC20 tokens)
- Deployed via clones for gas efficiency
- Controlled by Flywheel contract
- One Campaign per campaign for isolation
Abstract interface that enables:
- Custom campaign logic
- Access control rules
- Attribution calculations
- Metadata management
- Publisher registration and ref code generation
- Relevant to
AdConversion.solfor Spindl/Base Ads and other open-ended use cases - Payout address management
- Multi-chain publisher identity
- Backward compatible with existing publishers
⚠️ Important: Payout Address on Token TransferWhen BuilderCodes tokens (ERC721) are transferred to a new owner, the payout address does not automatically update. This is by design to prevent accidental loss of funds and maintain compatibility with various payout setups.
After receiving a BuilderCodes token, the new owner MUST:
- Call
updatePayoutAddress(code, newPayoutAddress)to set their desired payout address- Verify the update was successful before expecting any payouts
Failure to update the payout address means:
- Payouts will continue to be sent to the previous owner's address
- The new owner will not receive rewards despite owning the token
This design ensures that payout addresses remain stable unless explicitly changed, preventing issues with smart contract wallets or specialized payout configurations.
- Enables permissionless registration of referral codes with auto-generated identifiers
- Generates 8-character codes using pseudo-random algorithm based on nonce and timestamp
- Uses allowed character set from BuilderCodes for compatibility
- Automatically retries if generated code already exists or is invalid
- Provides alternative to custom code registration for users who don't need specific codes
The Flywheel Protocol includes a comprehensive referral code system that enables identity management and attribution tracking across campaigns for any type of participant. While publishers are the primary users in AdConversion campaigns, the registry is designed as a general-purpose identity system that builders, creators, liquidity providers, and other participant types can utilize. Referral codes are implemented as ERC721 tokens, making them tradeable assets with built-in ownership and metadata capabilities.
Referral codes serve as unique identifiers for any campaign participant and can be used across different campaign types for attribution and reward distribution. Publishers are just one type of user - the system is designed to support builders, creators, liquidity providers, and any other participant type that future hooks might need. The system supports both custom-branded codes and auto-generated random codes, providing flexibility for different use cases.
Key Features:
- ERC721 Implementation: Each referral code is a unique NFT with transferable ownership
- Payout Address Management: Codes map to payout addresses for reward distribution
- Multi-Chain Identity: Single code can represent publisher across different chains
- Custom or Random Generation: Support for branded codes and permissionless random codes
- Hook Integration: Extensible system that any campaign hook can utilize
Any participant (publishers, builders, creators, etc.) can register memorable, branded referral codes through the BuilderCodes contract:
// Register custom code "base"
referralCodes.register(
"base", // Custom referral code
0x1234...5678, // Initial owner
0xabcd...efgh // Payout address
);Custom Code Examples:
"base"- Brand/organization name"alice123"- Publisher/builder username"crypto_news"- Content creator category"defi_builder"- Builder specialization"spring2024"- Campaign-specific code
Requirements for Custom Codes:
- Must contain only allowed characters:
0123456789abcdefghijklmnopqrstuvwxyz_ - Cannot be empty
- Must be unique across the entire system
- Requires
REGISTER_ROLEpermission or valid signature
Users can generate random codes through the PseudoRandomRegistrar without requiring permissions:
// Generate random 8-character code
string memory randomCode = pseudoRandomRegistrar.register(
0xabcd...efgh // Payout address
);
// Returns something like: "sdf34433"Random Code Examples:
"sdf34433"- 8 characters, pseudo-random"x9k2m7q1"- Another random variation"pq84nz3v"- Auto-generated unique code
Random Generation Algorithm:
// Simplified version of the generation logic
function computeCode(uint256 nonce) public view returns (string memory) {
uint256 hashNum = uint256(keccak256(abi.encodePacked(nonce, block.timestamp)));
bytes memory codeBytes = new bytes(8);
for (uint256 i = 0; i < 8; i++) {
codeBytes[i] = allowedCharacters[hashNum % allowedCharacters.length];
hashNum /= allowedCharacters.length;
}
return string(codeBytes);
}The AdConversion hook leverages referral codes for publisher attribution and validation. Note that publishers are the specific participant type for AdConversion campaigns, but other hooks could use the same registry for different participant types (like builders):
// Example AdConversion attribution
Conversion memory conversion = Conversion({
eventId: "unique-event-id",
clickId: "click-12345",
configId: 1,
publisherRefCode: "base", // Must exist in BuilderCodes
timestamp: uint32(block.timestamp),
payoutRecipient: address(0), // Use registry lookup
payoutAmount: 10e18
});AdConversion Validation Process:
- Code Existence Check: Verifies
publisherRefCodeexists in BuilderCodes registry - Allowlist Validation: If campaign has allowlist, checks if publisher is approved
- Payout Address Resolution:
- If
payoutRecipient = address(0): Look up viareferralCodes.payoutAddress(publisherRefCode) - If
payoutRecipientspecified: Use direct address (with ownership validation)
- If
- Attribution Fee Calculation: Deduct provider fees from publisher payout
Publisher Allowlist Examples:
// Restricted campaign - only specific publishers
string[] memory allowedRefCodes = ["base", "alice123", "crypto_news"];
// Open campaign - any registered publisher
string[] memory allowedRefCodes = []; // Empty array = no restrictions- hooks must be derived from
CampaignHooks.sol - for v1, we ship
AdConversion.sol,CashbackRewards.sol,BridgeReferralFees.sol, andSimpleRewards.solbut the system enables anyone to create their own hook permissionlessly (whether internal at Base or external). For instance, internally in near future if we choose to support Solana conversion events or Creator Rewards, we can deploy a new Campaign Hook that fits the specific requirements and utilizeFlywheelcore contract for managing payouts
Traditional performance marketing campaigns where publishers drive conversions and earn rewards.
Core Features:
- Publishers earn based on verified conversions
- Supports both onchain and offchain attribution events
- Configurable conversion configs with metadata
- Publisher allowlists for restricted campaigns
- Attribution fee collection for providers
- Attribution deadline duration must be in days precision (0 days for instant finalization, or multiples of 1 day)
- Attribution window must be between 0 and 6 months (180 days maximum)
Campaign Creation:
bytes memory hookData = abi.encode(
attributionProvider, // Who can submit conversions
advertiser, // Campaign sponsor
"https://api.spindl.xyz/metadata/...", // Campaign metadata URI
allowedRefCodes, // Publisher allowlist (empty = no restrictions)
conversionConfigs, // Array of ConversionConfig structs
attributionWindow // Duration for attribution finalization (must be in days precision: 0, 1 day, 2 days, etc.; max 180 days)
);Common Reward Scenarios:
-
Publisher-Only Rewards (Direct Payout)
Conversion memory conversion = Conversion({ eventId: "unique-event-id", clickId: "click-12345", configId: 1, publisherRefCode: "publisher-123", timestamp: uint32(block.timestamp), payoutRecipient: 0x1234...5678, // Direct payout address payoutAmount: 10e18 // 10 tokens });
- Payout goes directly to specified
payoutRecipient - Useful when publisher wants specific address for rewards
- Attribution fee deducted from
payoutAmount
- Payout goes directly to specified
-
BuilderCodes Lookup
Conversion memory conversion = Conversion({ eventId: "unique-event-id", clickId: "click-12345", configId: 1, publisherRefCode: "publisher-123", timestamp: uint32(block.timestamp), payoutRecipient: address(0), // Use registry lookup payoutAmount: 10e18 });
- When
payoutRecipient = address(0), system looks up publisher's payout address - Uses
referralCodeRegistry.getPayoutRecipient(refCode) - Allows publishers to manage payout addresses centrally
- Supports multi-chain publisher identity
- When
-
Onchain vs Offchain Conversions
// Offchain conversion (e.g., email signup, purchase) Attribution memory offchainAttr = Attribution({ conversion: conversion, logBytes: "" // Empty for offchain events }); // Onchain conversion (e.g., DEX swap, NFT mint) Log memory logData = Log({ chainId: 8453, // Base transactionHash: 0xabcd..., index: 2 }); Attribution memory onchainAttr = Attribution({ conversion: conversion, logBytes: abi.encode(logData) // Log data for verification });
-
Conversion Config Validation
conversionConfigId = 0: No validation, accepts any conversion typeconversionConfigId > 0: Must match registered config- Validates
isEventOnchainmatches presence oflogBytes - Config must be active (
isActive = true) - Used to enforce conversion type requirements
- Validates
-
Publisher Allowlist
// Campaign with allowlist (only specific publishers) string[] memory allowedRefCodes = ["publisher-123", "publisher-456"]; // Campaign without allowlist (any registered publisher) string[] memory allowedRefCodes = [];
- Empty allowlist = any registered publisher can earn
- Non-empty allowlist = only specified publishers allowed
- Advertiser can add publishers via
addAllowedPublisherRefCode()
-
Attribution Fee Structure
// Attribution provider sets their fee (0-100%) attributionProvider.setAttributionProviderFee(100); // 1% fee // During payout: uint256 attributionFee = (payoutAmount * feeBps) / 10000; uint256 netPayout = payoutAmount - attributionFee;
- Attribution providers earn fees for verification work
- Fee deducted from publisher payout, not campaign funds
- Currently set to 0% for Base/Spindl campaigns
-
Attribution Deadline Duration
// Instant finalization (no delay between FINALIZING and FINALIZED) uint48 attributionWindow = 0; // 7-day attribution window uint48 attributionWindow = 7 days; // 30-day attribution window uint48 attributionWindow = 30 days; // Maximum allowed: 6 months (180 days) uint48 attributionWindow = 180 days; // Invalid: Exceeds 6-month limit // uint48 attributionWindow = 365 days; // ❌ Reverts // uint48 attributionWindow = 200 days; // ❌ Reverts // Invalid: Not in days precision // uint48 attributionWindow = 3 hours; // ❌ Reverts // uint48 attributionWindow = 2 days + 5 hours; // ❌ Reverts
- Must be in days precision (0, 1 day, 2 days, etc.)
- Must be between 0 and 180 days (6 months maximum)
- 0 means instant finalization when entering FINALIZING state
- Non-zero values create a waiting period before advertiser can finalize
- Prevents UI complexity from inconsistent time formats
- Prevents unreasonably long finalization delays
Validation Rules:
- Publisher ref code must exist in
BuilderCodes - If allowlist exists, publisher must be approved
- Conversion config must be active (if specified)
- Conversion type must match config (onchain/offchain)
- Only attribution provider can submit conversions
- Only advertiser can withdraw remaining funds (when finalized)
State Transition Control:
- Attribution Provider: Can perform INACTIVE→ACTIVE, ACTIVE→FINALIZING, ACTIVE→FINALIZED (direct bypass), and FINALIZING→FINALIZED transitions
- Advertiser: Can perform ACTIVE→FINALIZING, INACTIVE→FINALIZED (fund recovery), and FINALIZING→FINALIZED (after deadline)
- Security Restriction: No party can pause active campaigns (ACTIVE→INACTIVE is blocked for ALL parties)
- Design Rationale: Prevents malicious campaign pausing while maintaining attribution provider operational control and advertiser exit rights
E-commerce cashback campaigns where users receive direct rewards for purchases:
Core Features:
- Direct user rewards for purchases (no publishers involved)
- Uses allocate/distribute model (supports all payout functions including reward)
- Integrates with AuthCaptureEscrow for payment verification
- Tracks rewards per payment with allocation/distribution states
- Manager-controlled campaign operations with separate owner for fund withdrawal
Campaign Creation:
bytes memory hookData = abi.encode(
owner, // Campaign owner (can withdraw funds)
manager, // Campaign manager (handles operations)
"https://api.example.com/metadata/..." // Campaign metadata URI
);Payout Models Supported:
send()- Immediate payout to usersallocate()- Reserve rewards for future distributiondeallocate()- Cancel allocated rewardsdistribute()- Distribute previously allocated rewards
Bridge incentive campaigns where users receive rewards for utilizing builder codes during bridge operations:
Core Features:
- Direct user rewards for bridge transactions using registered builder codes
- Uses send only model - immediate payouts without allocation/distribution complexity
- Integrates with BuilderCodes for fee distribution to code owners
- Perpetual active campaigns (always available once activated)
- Fee sharing between users and builder code owners with configurable basis points (max 2%)
- Native token support (ETH) alongside ERC20 tokens
- Graceful failure handling for unregistered or misconfigured builder codes
Campaign Creation:
// BridgeReferralFees campaigns have fixed initialization - no custom hookData needed
bytes memory hookData = ""; // Empty hookData required
// Create campaign with nonce 0 (only one campaign per BridgeReferralFees hook instance)
address campaign = flywheel.createCampaign(
address(bridgeReferralFeesHook),
0, // Must be 0 - only one campaign allowed
hookData
);Bridge Referral Flow:
// When a user bridges with a builder code
bytes memory hookData = abi.encode(
userAddress, // User receiving the bridged assets
builderCode, // bytes32 builder code used for referral
feeBasisPoints // Fee percentage (in basis points, max 200 = 2%)
);
flywheel.send(campaign, token, hookData);Reward Distribution:
- User Reward: User receives
(unreservedAmount - feeAmount)where unreservedAmount is total campaign balance minus any reserved funds - Builder Fee: Builder code owner receives
feeAmountbased on the fee basis points - Fee Calculation:
feeAmount = (unreservedAmount * feeBps) / 10000
Validation Rules:
- Builder code must be registered in BuilderCodes contract
- Campaign balance must have unreserved funds available
- Fee basis points cannot exceed 200 (2.00%)
- Only supports
send()payout function (no allocate/distribute) - Campaign must be in ACTIVE status (perpetual)
- Gracefully handles unregistered builder codes by setting fee to 0%
- Gracefully handles misconfigured payout addresses by losing builder fee (user still gets reward)
Fund Management:
- Anyone can trigger rewards (no access control on
send) - Accidental tokens can be withdrawn using
withdrawFunds()function - Campaign is designed for atomic: fund → immediate payout flow
Use Cases:
- Allow apps that facilitate bridging to take a fee on bridged assets
Basic rewards hook with minimal logic for flexible campaign types:
Core Features:
- Simple pass-through of payout data with minimal validation
- Uses allocate/distribute model (supports all payout functions)
- Manager-controlled campaign operations
- No complex business logic - pure reward distribution
- Suitable for custom attribution logic handled externally
Campaign Creation:
bytes memory hookData = abi.encode(
manager // Campaign manager address
);Use Cases:
- Custom attribution systems that handle logic externally
- Simple reward programs without complex validation
- Prototyping new campaign types before building specialized hooks
- Backend-controlled reward distribution
Payout Models Supported:
send()- Immediate payout to recipientsallocate()- Reserve payouts for future distributiondeallocate()- Cancel allocated payoutsdistribute()- Distribute previously allocated payouts
Note that fee-on-transfer tokens and rebasing tokens may lead to unexpected behavior and are not recommneded for use in Flywheel.
Flywheel provides four fundamental payout operations that hooks can implement based on their requirements:
Transfers tokens directly to recipients immediately. Used for real-time rewards where no holding period is needed. Payouts must succeed or the entire transaction reverts.
Reserves tokens for future distribution using bytes32 keys for organization. Creates a "pending" state that can be claimed later or reversed. The key-based system allows for flexible tracking of different allocation types.
Cancels previously allocated tokens identified by bytes32 keys, returning them to the campaign treasury. Only works on unclaimed allocations.
Allows recipients to claim previously allocated tokens identified by bytes32 keys. Converts "pending" allocations to actual token transfers. Can also generate fees during distribution.
The protocol uses a fail-fast approach for send operations:
- All token transfers must succeed or the entire transaction reverts
- There is no fallback allocation for failed sends - transactions either complete fully or revert
- This ensures atomic operation success and prevents partial state issues
Comprehensive comparison of hook implementations, including payout functions, access control, and operational characteristics:
| Aspect | AdConversion | CashbackRewards | BridgeReferralFees | SimpleRewards |
|---|---|---|---|---|
| Controller | Attribution Provider | Manager | No access control (anyone) | Manager |
| Use Case | Publisher performance marketing | E-commerce cashback | Bridge transaction incentives | Flexible reward distribution |
| Validation | Complex (ref codes, configs) | Medium (payment verification) | Medium (builder codes, perpetual active, graceful failure) | Minimal (pass-through) |
| Fees | ✅ Attribution provider fees | ❌ No fees | ✅ Builder code owner fees (max 2%) | ❌ No fees |
| Publishers | ✅ Via BuilderCodes | ❌ Direct to users | ✅ Via BuilderCodes (fee recipients) | ❌ Direct to recipients |
| Fund Withdrawal | Advertiser only (FINALIZED) | Owner only | Anyone (withdrawFunds for accidents) | Owner only |
| send() | âś… Immediate publisher payouts Supports attribution fees Fail-fast on errors |
âś… Direct buyer cashback Tracks distributed amounts Fail-fast on errors |
âś… Bridge referrals + builder fees Native token support Fail-fast on errors |
âś… Direct recipient payouts Simple pass-through Fail-fast on errors |
| allocate() | ❌ Not implemented | ✅ Reserve cashback for claims Tracks allocated amounts |
❌ Not implemented | ✅ Reserve payouts for claims |
| distribute() | ❌ Not implemented | ✅ Claim allocated cashback Supports fees on distribution |
❌ Not implemented | ✅ Claim allocated rewards Supports fees on distribution |
| deallocate() | ❌ Not implemented | ✅ Cancel unclaimed cashback Returns to campaign funds |
❌ Not implemented | ✅ Cancel unclaimed rewards |
The modular architecture supports diverse incentive programs:
- Sponsor: Brand or Advertiser
- Attribution Provider: Spindl or similar analytics service
- Flow: Publishers drive traffic → Users convert → Attribution provider verifies → Publishers/users earn
- Sponsor: E-commerce platform (e.g., Shopify or Base)
- Manager: Payment processor or platform itself
- Flow: Users make purchases → Payment confirmed → Payouts issued → Users receive cashback
- Sponsor: User bridging their assets
- Builder Code Owners: Builders who registered codes in BuilderCodes contract
- Flow: Users bridge with builder codes → Immediate payout → Users receive bridged assets, builders earn fees
- Sponsor: Any entity wanting to distribute rewards
- Manager: Backend service or trusted controller
- Flow: Actions tracked externally → Manager submits payout data → Recipients claim rewards
The permissionless architecture enables anyone to create custom hooks for new use cases:
- Creator Rewards: Social platforms rewarding content creators based on engagement metrics
- DeFi Incentives: Protocols rewarding users for specific onchain actions (swaps, liquidity provision, etc.)
- Builder Rewards: DAOs rewarding developers for milestone completion or contribution metrics
- Gaming Rewards: Game studios rewarding players for achievements or referrals
- Community Governance: Token-based voting rewards and participation incentives
- Core protocol separated from campaign logic
- New campaign types via hooks without protocol changes
- Clean separation of concerns
- Campaign uses clone pattern (not full deployment) saving ~90% deployment gas
- Batch attribution submissions supported via arrays
- Pull-based payout distribution prevents reentrancy
- Optimized storage patterns
- Support for any ERC20 token
- Custom attribution logic per campaign
- Extensible metadata system
- Plugin-based architecture
- Minimal core protocol surface area
- Campaign isolation through separate Campaigns
- Hook-based access control
- Reduced attack vectors
# Clone with submodules
git clone --recurse-submodules https://github.com/spindl-xyz/flywheel.git
# Install Foundry
curl -L https://foundry.paradigm.xyz | bash
foundryup# Build
forge build
# Run tests
forge test -vv
# Run specific test
forge test --match-test testName -vvv
# Gas report
forge test --gas-report
# Coverage
forge coverage --ir-minimum --report lcov
# Get HTML report
genhtml lcov.info -o coverage-report --rc derive_function_end_line=0 genhtml --ignore-errors missing,missing,category,corrupt,inconsistent
Clear Separation of Concerns: Core protocol functionality is tested separately from hook-specific behavior to eliminate redundancy and ensure comprehensive coverage.
Core Protocol Tests (Flywheel.t.sol):
- Campaign lifecycle management (create, status transitions, finalize)
- Core payout functions (allocate, distribute, deallocate, send) using SimpleRewards for testing
- Multi-token support and Campaign functionality
- Fee collection and fund withdrawal mechanisms
- Cross-hook state transition validation
- Campaign address prediction and uniqueness
Hook-Specific Tests (HookName.t.sol):
- Hook-specific business logic (e.g., payment verification in CashbackRewards, attribution in AdConversion)
- Hook-specific access control and authorization
- Hook-specific data validation and edge cases
- Hook-specific event emissions and state changes
- End-to-end workflows unique to each hook type
Security Tests (ContractName.security.t.sol):
- Attack scenario simulations (reentrancy, privilege escalation)
- Economic attack vectors (flash loans, manipulation)
- Unauthorized access attempts by malicious actors
- Cross-function attack patterns
- Vulnerability-specific testing
Cross-Hook Integration (CrossHook.security.t.sol):
- Multi-hook interaction scenarios and isolation validation
- Cross-campaign security attack vectors
- Hook interoperability and data confusion attacks
- Economic manipulation across multiple campaign types
- Cross-system integration validation
- Scalability and stress testing
// Deploy hook contract (or use existing)
AdConversion hook = new AdConversion(flywheel);
// Prepare campaign data
bytes memory hookData = abi.encode(
payoutProvider, // Who can submit payouts
msg.sender, // Sponsor
"ipfs://metadata" // Campaign details
);
// Create campaign
address campaign = flywheel.createCampaign(
address(hook),
nonce,
hookData
);
// Fund campaign
IERC20(token).transfer(campaign, 100_000e18);
// Activate campaign for payouts
flywheel.updateStatus(campaign, CampaignStatus.ACTIVE, "");Campaign Creation Safety: The createCampaign function includes front-running protection by checking if a campaign already exists at the predicted address and returning it safely, preventing duplicate deployments or reverts from concurrent creation attempts.
The Flywheel protocol supports four main payout operations:
// Immediate payout to recipients
bytes memory hookData = abi.encode(
recipients,
amounts,
// hook-specific data
);
flywheel.send(campaign, token, hookData);// Reserve payouts for future distribution using bytes32 keys
bytes memory hookData = abi.encode(
keys, // bytes32[] - Keys to identify allocations
amounts, // uint256[] - Amounts to allocate
// hook-specific data
);
flywheel.allocate(campaign, token, hookData);// Remove allocated payouts (cancel allocations) by key
bytes memory hookData = abi.encode(
keys, // bytes32[] - Keys identifying allocations to cancel
amounts, // uint256[] - Amounts to deallocate
// hook-specific data
);
flywheel.deallocate(campaign, token, hookData);// Distribute previously allocated payouts to recipients
bytes memory hookData = abi.encode(
recipients, // address[] - Recipients to receive distributions
keys, // bytes32[] - Keys identifying allocations to distribute
amounts, // uint256[] - Amounts to distribute
// hook-specific data
);
flywheel.distribute(campaign, token, hookData);The enhanced fee system supports multiple fee streams identified by bytes32 keys, allowing for granular fee tracking and collection:
// Distribute accumulated fees to recipients
bytes memory hookData = abi.encode(
recipients, // address[] - Fee recipients
keys, // bytes32[] - Keys identifying fee allocations
amounts, // uint256[] - Fee amounts to distribute
// hook-specific data
);
flywheel.distributeFees(campaign, token, hookData);Enhanced Fee Features:
- Multiple Fee Streams: Support for different fee types using
bytes32keys - Flexible Fee Collection: Fees can be collected on both
sendanddistributeoperations - Granular Tracking: Each fee stream is tracked separately for better accounting
- Batch Distribution: Multiple fees can be distributed in a single transaction
| State | Next Valid States | Payout Functions Available |
|---|---|---|
| INACTIVE (default) | ACTIVE, FINALIZING, FINALIZED | None |
| ACTIVE | INACTIVE, FINALIZING, FINALIZED | send, allocate, deallocate, distribute |
| FINALIZING | FINALIZED | send, allocate, deallocate, distribute |
| FINALIZED | None | None |
Campaign states and their permissions vary significantly by hook type. The table below shows who can perform state transitions and what actions are available in each state:
Each hook type has different access control patterns for state transitions and operations:
| State | Who Can Transition To | Available Functions | Special Behaviors |
|---|---|---|---|
| INACTIVE | • ACTIVE: Attribution Provider only • FINALIZED: Advertiser only (fund recovery) |
None | 🔒 Security: No party can pause active campaigns (ACTIVE→INACTIVE blocked) |
| ACTIVE | • FINALIZING: Attribution Provider or Advertiser • FINALIZED: Attribution Provider only (bypass) |
send only |
🔒 Security: ACTIVE→FINALIZED blocked for Advertiser only (prevents attribution bypass) |
| FINALIZING | • FINALIZED: Attribution Provider (any time), Advertiser (after deadline) | send only |
Sets attribution deadline based on campaign's configured duration (max 180 days) |
| FINALIZED | None (terminal state) | None | Only Advertiser can withdraw remaining funds |
| State | Who Can Transition To | Available Functions | Special Behaviors |
|---|---|---|---|
| INACTIVE | Manager only | None | None |
| ACTIVE | Manager only | send, allocate, deallocate, distribute |
None |
| FINALIZING | Manager only | send, allocate, deallocate, distribute |
None |
| FINALIZED | Manager only | None | None |
- AdConversion: Attribution Provider has operational control, Advertiser has exit rights
- CashbackRewards: Owner creates campaign, Manager operates it, payments must be verified via AuthCaptureEscrow
- SimpleRewards: Manager has full control over all operations with minimal validation
- Advertiser: Campaign sponsor who funds the campaign
- Attribution Provider: Authorized to submit conversion data and earn fees
- Publishers: Earn rewards based on conversions (managed via BuilderCodes)
- Owner: Campaign sponsor who funds the campaign and can withdraw remaining funds
- Manager: Controls campaign lifecycle and processes payment-based rewards
- Users: Receive cashback rewards directly (no publishers involved)
- Owner: Campaign sponsor who funds the campaign and can withdraw remaining funds
- Manager: Controls all campaign operations and payout submissions
- Recipients: Receive rewards based on manager-submitted payout data
Implement the CampaignHooks interface:
contract MyCustomHook is CampaignHooks {
constructor(address flywheel) CampaignHooks(flywheel) {}
function createCampaign(address campaign, bytes calldata data)
external override onlyFlywheel {
// Initialize campaign state
// Set attribution provider(s)
}
function onReward(
address sender,
address campaign,
address token,
bytes calldata data
) external override onlyFlywheel
returns (Payout[] memory, uint256 fee) {
// Verify sender is authorized attribution provider
// Validate payout data
// Calculate payouts and fees
// Return results
}
function onAllocate(
address sender,
address campaign,
address token,
bytes calldata data
) external override onlyFlywheel
returns (Payout[] memory, uint256 fee) {
// Similar implementation for allocation
}
function onDistribute(
address sender,
address campaign,
address token,
bytes calldata data
) external override onlyFlywheel
returns (Payout[] memory, uint256 fee) {
// Implementation for distribution
}
function onDeallocate(
address sender,
address campaign,
address token,
bytes calldata data
) external override onlyFlywheel
returns (Payout[] memory) {
// Implementation for deallocation
}
// Implement other required functions...
}- Fund campaigns (advertisers, platforms, DAOs, protocols)
- Configure campaign rules via hooks
- Set trusted controllers (attribution providers for AdConversion, managers for other hooks)
- Monitor campaign performance
- Withdraw unused funds
- Register via BuilderCodes
- Drive traffic using ref codes
- Claim accumulated rewards
- View earnings across campaigns
- Track conversion events (onchain/offchain)
- Verify event authenticity
- Submit payout operations in batches
- Earn fees for accurate attribution
- Maintain reputation for reliability
- Complete desired actions
- Receive direct incentives (if configured)
- Transparent reward tracking
- Campaign Isolation: Each campaign has its own Campaign
- Immutable Hooks: Campaign logic cannot be changed after creation
- Minimal Core: Reduced attack surface in core protocol
- Access Control: Hook-based permissions for each operation
- No Reentrancy: Pull-based reward distribution
- Attribution Trust: Sponsors choose their attribution providers
The modular architecture is currently undergoing audit.
The protocol is designed for deployment on Ethereum L2s and can technically be deployed on any EVM. Primary focus will be to have attribution and payouts to happen on Base for now although we can attribute data from other EVMs (Opt, Arb, etc.)
Foundry deployment scripts are available in the scripts/ directory for deploying all protocol contracts:
DeployFlywheel.s.sol- Deploys the core Flywheel contractDeployPublisherRegistry.s.sol- Deploys the upgradeable BuilderCodes with proxyDeployAdConversion.s.sol- Deploys the AdConversion hookDeployAll.s.sol- Orchestrates deployment of all contracts in the correct order
All deployment scripts require an owner address that will have administrative control over the deployed contracts. This address will be able to:
- Upgrade the BuilderCodes contract (via UUPS proxy)
- Configure protocol parameters
- Manage contract permissions
The target chain is specified via the --rpc-url parameter. Examples:
- Base Mainnet:
https://mainnet.base.org - Base Sepolia:
https://sepolia.base.org
Contract verification uses the ETHERSCAN_API_KEY from your .env file:
- Base networks: Use your Basescan API key
- Other networks: Use the appropriate explorer API key
Create a .env file in the project root:
PRIVATE_KEY=your_private_key_here
ETHERSCAN_API_KEY=your_basescan_api_key_hereImportant: Load your environment variables before running commands:
source .envThe BuilderCodes supports an optional "signer" address that can:
- Register publishers with custom ref codes (instead of auto-generated ones)
- Register publishers on behalf of others
- Enable backend integration for programmatic publisher management
When to use:
- Set to
address(0)for simple deployments (self-registration only) - Set to your backend service address for advanced publisher management
# Base Sepolia - Replace OWNER_ADDRESS with your desired owner address
forge script scripts/DeployAll.s.sol --sig "run(address)" OWNER_ADDRESS --rpc-url https://sepolia.base.org --private-key $PRIVATE_KEY --broadcast --verify
# Example with specific owner
forge script scripts/DeployAll.s.sol --sig "run(address)" 0x7116F87D6ff2ECa5e3b2D5C5224fc457978194B2 --rpc-url https://sepolia.base.org --private-key $PRIVATE_KEY --broadcast --verify# With both owner and signer addresses for custom publisher registration
forge script scripts/DeployAll.s.sol --sig "run(address,address)" OWNER_ADDRESS SIGNER_ADDRESS --rpc-url https://sepolia.base.org --private-key $PRIVATE_KEY --broadcast --verify
# Example with specific addresses
forge script scripts/DeployAll.s.sol --sig "run(address,address)" 0x7116F87D6ff2ECa5e3b2D5C5224fc457978194B2 0x1234567890123456789012345678901234567890 --rpc-url https://sepolia.base.org --private-key $PRIVATE_KEY --broadcast --verify# Deploy only Flywheel (no owner parameter needed)
forge script scripts/DeployFlywheel.s.sol --rpc-url https://sepolia.base.org --private-key $PRIVATE_KEY --broadcast --verify
# Deploy only BuilderCodes with owner
forge script scripts/DeployPublisherRegistry.s.sol --sig "run(address)" OWNER_ADDRESS --rpc-url https://sepolia.base.org --private-key $PRIVATE_KEY --broadcast --verify
# Deploy only BuilderCodes with owner and signer
forge script scripts/DeployPublisherRegistry.s.sol --sig "run(address,address)" OWNER_ADDRESS SIGNER_ADDRESS --rpc-url https://sepolia.base.org --private-key $PRIVATE_KEY --broadcast --verifyThe scripts handle dependencies automatically, but the deployment order is:
- Flywheel (independent)
- BuilderCodes (independent, upgradeable via UUPS proxy)
- AdConversion (requires Flywheel and BuilderCodes addresses)
The owner address is specified during deployment and will have administrative control over:
- BuilderCodes: Can upgrade the contract via UUPS proxy pattern
- AdConversion: Can configure protocol parameters and manage permissions
Important: Choose your owner address carefully as it will have significant control over the protocol. Consider using a multisig wallet for production deployments.
After deployment, you'll receive addresses for:
- Flywheel: Core protocol contract
- BuilderCodes: Publisher management (proxy address)
- AdConversion: Hook for ad campaigns
- Campaign Implementation: Template for campaign treasuries (auto-deployed by Flywheel)
