⛓️Technical Details

Overall Flow

  1. Campaign Managers create an on-chain campaign, and deposit ETH or ERC20s towards the totalBudget of the campaign. This is currently accessible through Spindl's Web App (where a trusted worker can do certain administrative actions on behalf of the campaign manager)

  2. Relevant Recipients (or Publishers) are added to each campaign (using addRecipients). They generate traffic to fulfill the campaign's criteria. For example a recipient can be an influencer that drives traffic for certain purchases such as an NFT

  3. Recipients can also be implicitly created via updateBalances which is explained in detail further down

  4. The Spindl server updates the latest balances for the given campaign and its recipients. Spindl will do this on a recurring, periodic basis.

  5. Recipients can then withdraw their earned funds

Technical Details

Architecture Design

This Architecture follows the Beacon proxy & Factory patterns.

The specification contains 4 types of contracts:

  1. Many Proxy Contracts (One Per Campaign Manager)

  2. One Beacon Contract

  3. One Implementation Contract

  4. One Factory Contract that creates proxy contracts

A cron job periodically triggers the proxy contracts to update the balances for each campaign manager.


Owner - administrative Multi-Sig wallet that is the owner of important contracts such as Factory and Proxy and has owner privileges in case of issues.

Worker - an address that will be assigned to server to be able to make earnings updates on behalf of Spindl or the end user to the smart contract. Access can be revoked by the owner.

Manager - An advertiser running campaigns, who is responsible for funding campaigns.

Recipient - in most cases this will be the referrer or referent for the referral campaign. In the context of the smart contract, they are recipients of earnings.

Campaign - Similar to other marketing platforms, we want to distinguish different campaigns by the end goals. Multiple campaigns can live in the same smart contract.

Backend Flow

This is the typical scenario we should be following:

  1. When a new campaign manager (Advertiser) is onboarded, Factory creates a new proxy campaign manager contract using deployProxy.

  2. When campaign manager is ready, a campaign is created using createCampaign and recipients are added using addRecipients

  3. Campaign Manager adds funds to campaign using fundCampaign to the campaign. Campaign Manager can continue to add more funds later while campaign is ACTIVE.

  4. Campaign is set to status ACTIVE by server.

  5. When server calculates attributions, it can update earnings balances for campaigns using updateBalances. We can also implicitly create recipient records with this method if a recipient does not exist

  6. Once a recipient has new earnings, we can check their balance using getRecipient to see the amount. They can then withdraw those using withdrawRecipientEarnings. If a user has pending balances from multiple campaigns, they can use withdrawManyRecipientEarnings and pass in the campaigns.

  7. Once campaign is complete, Campaign is set to status of COMPLETED. The Campaign Manager cannot add funds to campaign at this point.

    • If the entire budget of campaign is not spent, then campaign can be set to status COMPLETED by the server. The Campaign Manager will then be able to withdraw (using withdrawRemainingCampaignBalance) the difference between totalBalance and totalEarned so we only want to update status to COMPLETED when attribution of earnings is complete.

    • Campaign can be re-started (i.e changed back from COMPLETED to ACTIVE) & be funded by campaign manager again. The reason is that the general desire in this contract is to keep features flexible.

Proxy Contract

This contract shall act as a proxy for the Implementation Contract. It will request the address of the Implementation from Beacon Contract. Once retrieved, it will forward function calls and transactions to the Implementation Contract using delegateCall, while keeping track of state within itself per campaign manager. Also as a result of this, the transaction will use the state of storage variables present in the Proxy Contract, not the Implementation Contract.

Beacon Contract

Refer to OZ's documentation for the Beacon pattern. This contract holds reference to which implementation contract the proxies should point to. The reason for this approach is that when we want to update 100 proxy contracts to v2 of implementation, we have to only update the beacon contract to point to the v2 implementation & all the proxies get this reference from beacon. The alternative approach we considered was a standard UUPS approach, but in that case we would have to update all the 100 proxies to point to the v2 implementation.

Implementation Contract

The Implementation Contract is where the bulk of the business logic resides. It is the contract that will be upgraded, and it is the contract that will be called by the proxy contract. The Implementation Contract will be deployed once and then upgraded as needed.

It is responsible for having methods that proxy needs to keep track of and update state

Every future Implementation Contract must have the methods to upgrade itself since we are using Upgradeable Beacon pattern. Open Zeppelin upgradable packages should take care of this by default, but please add tests as needed.

Factory Contract

  • Will have ability to deploy new Proxy contract per Campaign Manager

  • It is useful to have a centralized contract that deploys so we can index the arbitrary proxies that are created

UX for end-users from Front-end

Advertiser UI Flow

  • Campaign Manager logins into Spindl App and creates Campaign, specifying the "conversion" events and associated payout information

  • Spindl's backend creates a new Proxy Contract on-chain, if one that doesn't already exist

  • Spindl's backend creates a new campaign (createCampaign) on-chain with the specified currency (ETH or pre-approved ERC20's)

  • The Campaign Manager funds the campaign

  • Spindl updates the balances accordingly

Recipient UI Flow

  • Gets a link from campaign manager if they are invited to be part of referral campaign

  • Starts advertisting the product & wait for Spindl do add attribution

  • Can log in with their wallet to recipient dashboard, check their balances & withdraw. (they will pay gas for this)


How to remove a recipient?

You cannot remove the mapping of an existing refer. An owner or worker can set the recipient.status = PAUSED with setRecipientStatus & they will not be able to withdraw money

How to refund an campaign manager if they didn't spend their campaign funds?

At the moment this has to be done manually by owner or front-end using a few steps.

  1. Campaign should be set to status COMPLETED so that people cannot add recipients, etc. The attribution of all earnings should be done at this point.

  2. Campaign Manager can withdraw the difference between totalEarned and totalBalance using withdrawRemainingCampaignBalance

What validation is in place to ensure balances are correct for recipients & campaigns?

  1. You cannot over-attribute a campaign. For example, in campaign ABC an campaign manager added a totalBalance of $100 to campaign & 2 recipients are added. First campaign manager earned $60 from ABC, then the contract doesn't allow the earned amount of 2nd campaign manager to be more than $40. This prevent over attribution on campaign level.

  2. Also recipient cannot withdraw more than they earned. This prevents recipients withdrawing more than the totalBalance of the campaign since #1 is in place.

  3. If the campaign becomes stale, and recipients don't earn entire balance of campaign, then campaign can be completed & campaign manager can withdraw the difference between totalBalance & totalEarned in campaign. The totalBalance is then updated to reflect this, & campaign manager cannot withdraw again

How can the owner pause the proxy contract?

  1. Owner can set worker address to address(0) to prevent server from making any updates

  2. They then can pause all the campaigns within the campaign manager proxy. This will prevent recipients from withdrawing money or from any balances being updated although campaign manager will still technically be able to add more funds to the campaign

If recipients' address get compromised, during an active campaign, how do we resolve this?

If a recipient loses their private key they will be unable to withdraw any earnings from the contract. The recipient will need to contact the owner to resolve the issue. They will need to first create a new referral account using a different wallet address and have the old account paused. Then they will need to contact the owner to transfer the remaining earnings from the old recipient to the new recipient. The owner will need to verify that the new recipient is the same person as the old recipient.

Note there are no plans to implement a way to transfer earnings from one recipient to another in the contract. This is because of the additional security risks that would be introduced by allowing this. Instead, the owner will need to modify the state of the ledger on the backend servers and wait for another updateBalances to be called to update the state of the contract.

How do recipients withdraw their earnings?

  • They should use withdrawRecipientEarnings or withdrawManyRecipientEarnings

  • Recipient should be in status ACTIVE

  • If Campaign status is PAUSED, then recipient cannot withdraw

  • cannot individually withdraw more than difference between earned & already withdrawn

  • cannot individually withdraw more than total budget of campaign.

How is the server wallet worker kept secure

We are using MPC wallet technology to prevent and limit surface are of attack.

What are the different Campaign States & what do they do?



Can balances be updated?

Can recipient be added?

Can Campaign be funded?

Can recipient withdraw?

Can campaign state be updated in current state?

Can campaign manager withdraw unspent campaign balance?


null state. campaign hasn't been created yet.





YES (only via createCampaign)



campaign is created during createCampaign.








campaign can be set active via setCampaignState








campaign is paused to prevent withdrawals and funding








campaign is successfully completed.







What are the different Recipient States & what do they do?

StateDescriptionCan recipient withdraw?


null state. recipient is not created yet



default state after recipient is created

Only if Campaign is ACTIVE, COMPLETED


paused to prevent recipient from withdrawing & to potentially sunset them


Last updated