Smart Contract

CampaignEscrowV4

A.14aca78d100d2001.CampaignEscrowV4

Valid From

130,919,729

Deployed

6d ago
Feb 21, 2026, 03:19:53 PM UTC

Dependents

7 imports
1/**
2 * CampaignEscrowV4 Contract
3 * 
4 * Manages campaign escrow with FLOW tokens and automated payouts
5 * Supports both CLOSED (curated) and OPEN campaigns
6 * CLOSED: Brand specifies allowlist of creators upfront
7 * OPEN: Any creator can join dynamically via oracle-signed transaction
8 */
9
10import FungibleToken from 0xf233dcee88fe0abe
11import FlowToken from 0x1654653399040a61
12
13access(all) contract CampaignEscrowV4 {
14    
15    // Campaign type enum
16    access(all) enum CampaignType: UInt8 {
17        access(all) case closed
18        access(all) case open
19    }
20    
21    // Campaign data structure
22    access(all) struct Campaign {
23        access(all) let id: String
24        access(all) let brand: Address
25        access(all) let threshold: UFix64
26        access(all) let payout: UFix64
27        access(all) let deadline: UFix64
28        access(all) let createdAt: UFix64
29        access(all) let scheduledTxId: String?
30        access(all) let campaignType: CampaignType
31        access(all) let title: String
32        access(self) var totalScore: UFix64
33        access(all) var creatorScores: {Address: UFix64}
34        access(all) var allowlist: [Address]  // Dynamic allowlist for creators
35        access(self) var paidOut: Bool
36        
37        init(
38            id: String,
39            brand: Address,
40            threshold: UFix64,
41            payout: UFix64,
42            deadline: UFix64,
43            createdAt: UFix64,
44            scheduledTxId: String?,
45            campaignType: CampaignType,
46            title: String,
47            allowlist: [Address]
48        ) {
49            self.id = id
50            self.brand = brand
51            self.threshold = threshold
52            self.payout = payout
53            self.deadline = deadline
54            self.createdAt = createdAt
55            self.scheduledTxId = scheduledTxId
56            self.campaignType = campaignType
57            self.title = title
58            self.totalScore = 0.0
59            self.creatorScores = {}
60            self.allowlist = allowlist
61            self.paidOut = false
62        }
63        
64        // Setter function to add to total score
65        access(all) fun addToTotalScore(_ amount: UFix64) {
66            self.totalScore = self.totalScore + amount
67        }
68        
69        // Getter for totalScore
70        access(all) fun getTotalScore(): UFix64 {
71            return self.totalScore
72        }
73        
74        // Add creator to allowlist
75        access(all) fun addToAllowlist(_ creator: Address) {
76            if !self.allowlist.contains(creator) {
77                self.allowlist.append(creator)
78            }
79        }
80        
81        // Check if creator is in allowlist
82        access(all) fun isCreatorAllowed(_ creator: Address): Bool {
83            return self.allowlist.contains(creator)
84        }
85        
86        // Setter for paidOut
87        access(all) fun setPaidOut(_ value: Bool) {
88            self.paidOut = value
89        }
90        
91        // Getter for paidOut
92        access(all) fun isPaidOut(): Bool {
93            return self.paidOut
94        }
95    }
96    
97    // Oracle account for triggering payouts
98    access(all) let oracle: Address
99    
100    // Storage for campaigns and FLOW vault
101    access(all) var campaigns: {String: Campaign}
102    access(all) var vault: @FlowToken.Vault
103    
104    // Create a new campaign with FLOW deposit
105    access(all) fun createCampaign(
106        id: String,
107        brand: Address,
108        threshold: UFix64,
109        payout: UFix64,
110        deadline: UFix64,
111        campaignType: CampaignType,
112        title: String,
113        allowlist: [Address],
114        from: @FlowToken.Vault
115    ): Bool {
116        // Validate inputs
117        pre {
118            threshold > 0.0: "Threshold must be positive"
119            payout > 0.0: "Payout must be positive"
120            deadline > getCurrentBlock().timestamp: "Deadline must be in the future"
121            !self.campaigns.containsKey(id): "Campaign ID already exists"
122        }
123        
124        // Create campaign
125        let campaign = Campaign(
126            id: id,
127            brand: brand,
128            threshold: threshold,
129            payout: payout,
130            deadline: deadline,
131            createdAt: getCurrentBlock().timestamp,
132            scheduledTxId: nil,
133            campaignType: campaignType,
134            title: title,
135            allowlist: allowlist
136        )
137        
138        // Store campaign
139        self.campaigns[id] = campaign
140        
141        // Deposit FLOW into vault
142        self.vault.deposit(from: <- from)
143        
144        // Emit event
145        emit CampaignCreated(
146            id: id, 
147            brand: brand, 
148            threshold: threshold, 
149            payout: payout, 
150            campaignType: campaignType.rawValue,
151            title: title
152        )
153        
154        return true
155    }
156    
157    // Join an open campaign (oracle-signed to verify creator wallet)
158    access(all) fun joinCampaign(
159        campaignId: String,
160        creator: Address,
161        signer: Address
162    ): Bool {
163        pre {
164            signer == self.oracle: "Only oracle can add creators to campaigns"
165            self.campaigns.containsKey(campaignId): "Campaign does not exist"
166        }
167        
168        let campaign = self.campaigns[campaignId]!
169        
170        // Check if campaign is open
171        assert(campaign.campaignType == CampaignType.open, message: "Only open campaigns allow joining")
172        
173        // Add creator to allowlist
174        campaign.addToAllowlist(creator)
175        
176        // Write back the mutated struct to storage
177        self.campaigns[campaignId] = campaign
178        
179        // Emit event
180        emit CreatorJoinedCampaign(campaignId: campaignId, creator: creator)
181        
182        return true
183    }
184    
185    // Update creator performance score (oracle only)
186    access(all) fun updateCreatorScore(
187        campaignId: String,
188        creator: Address,
189        score: UFix64,
190        signer: Address
191    ): Bool {
192        pre {
193            signer == self.oracle: "Only oracle can update scores"
194            self.campaigns.containsKey(campaignId): "Campaign does not exist"
195        }
196        
197        let campaign = self.campaigns[campaignId]!
198        
199        // Check if campaign deadline has passed
200        assert(getCurrentBlock().timestamp <= campaign.deadline, message: "Campaign deadline has passed")
201        
202        // Check if creator is in allowlist
203        assert(campaign.isCreatorAllowed(creator), message: "Creator not in campaign allowlist")
204        
205        // Update creator score
206        campaign.creatorScores[creator] = score
207        
208        // Update total campaign score using setter
209        campaign.addToTotalScore(score)
210        
211        // Write back the mutated struct to storage
212        self.campaigns[campaignId] = campaign
213        
214        // Emit event
215        emit CreatorScoreUpdated(campaignId: campaignId, creator: creator, score: score)
216        
217        return true
218    }
219    
220    // Verify campaign performance and trigger payout or refund
221    access(all) fun verifyAndPayout(
222        campaignId: String,
223        signer: Address
224    ): String {
225        pre {
226            signer == self.oracle: "Only oracle can verify and payout"
227            self.campaigns.containsKey(campaignId): "Campaign does not exist"
228        }
229        
230        let campaign = self.campaigns[campaignId]!
231        
232        // Check if deadline has passed
233        if getCurrentBlock().timestamp > campaign.deadline {
234            // Campaign expired - check if threshold was met
235            if campaign.getTotalScore() >= campaign.threshold {
236                return "payout"
237            } else {
238                return "refund"
239            }
240        } else {
241            // Campaign still active - check if threshold already met
242            if campaign.getTotalScore() >= campaign.threshold {
243                return "payout"
244            } else {
245                return "pending"
246            }
247        }
248    }
249    
250    // Trigger payout if KPI is met (oracle only)
251    access(all) fun triggerPayout(
252        campaignId: String,
253        signer: Address
254    ): Bool {
255        pre {
256            signer == self.oracle: "Only oracle can trigger payouts"
257            self.campaigns.containsKey(campaignId): "Campaign does not exist"
258        }
259        
260        let campaign = self.campaigns[campaignId]!
261        
262        // Check if already paid out
263        assert(!campaign.isPaidOut(), message: "Campaign already paid out")
264        
265        // Check if KPI is met
266        if campaign.getTotalScore() >= campaign.threshold {
267            // Calculate creator shares and distribute payouts
268            let totalScore = campaign.getTotalScore()
269            let payoutAmount = campaign.payout
270            
271            // Guard against division by zero
272            if totalScore > 0.0 {
273                // Withdraw from vault
274                let vault <- self.vault.withdraw(amount: payoutAmount) as! @FlowToken.Vault
275                
276                // Distribute to each creator based on their score share
277                for creator in campaign.creatorScores.keys {
278                    let creatorScore = campaign.creatorScores[creator]!
279                    let creatorShare = (creatorScore / totalScore) * payoutAmount
280                    
281                    // Get creator's FLOW receiver capability
282                    let creatorAccount = getAccount(creator)
283                    let receiverCap = creatorAccount.capabilities.get<&{FungibleToken.Receiver}>(/public/flowTokenReceiver)
284                    
285                    if receiverCap.check() {
286                        let receiver = receiverCap.borrow()!
287                        let payment <- vault.withdraw(amount: creatorShare)
288                        receiver.deposit(from: <- payment)
289                    } else {
290                        // If creator doesn't have a receiver, destroy the tokens (shouldn't happen in production)
291                        destroy vault.withdraw(amount: creatorShare)
292                    }
293                }
294                
295                // Destroy any remaining dust
296                destroy vault
297            }
298            
299            // Mark as paid out
300            campaign.setPaidOut(true)
301            self.campaigns[campaignId] = campaign
302            
303            // Emit event
304            emit PayoutTriggered(campaignId: campaignId, totalScore: campaign.getTotalScore(), payout: payoutAmount)
305            
306            return true
307        }
308        
309        return false
310    }
311    
312    // Trigger refund if campaign failed (oracle only)
313    access(all) fun triggerRefund(
314        campaignId: String,
315        signer: Address
316    ): Bool {
317        pre {
318            signer == self.oracle: "Only oracle can trigger refunds"
319            self.campaigns.containsKey(campaignId): "Campaign does not exist"
320        }
321        
322        let campaign = self.campaigns[campaignId]!
323        
324        // Check if already paid out
325        assert(!campaign.isPaidOut(), message: "Campaign already processed")
326        
327        // Check if deadline has passed and KPI not met
328        if getCurrentBlock().timestamp > campaign.deadline && campaign.getTotalScore() < campaign.threshold {
329            // Refund FLOW to brand
330            let refundAmount = campaign.payout
331            let vault <- self.vault.withdraw(amount: refundAmount) as! @FlowToken.Vault
332            
333            // Get brand's FLOW receiver capability
334            let brandAccount = getAccount(campaign.brand)
335            let receiverCap = brandAccount.capabilities.get<&{FungibleToken.Receiver}>(/public/flowTokenReceiver)
336            
337            if receiverCap.check() {
338                let receiver = receiverCap.borrow()!
339                receiver.deposit(from: <- vault)
340            } else {
341                // If brand doesn't have a receiver, keep the tokens in contract vault
342                self.vault.deposit(from: <- vault)
343            }
344            
345            // Mark as paid out (processed)
346            campaign.setPaidOut(true)
347            self.campaigns[campaignId] = campaign
348            
349            // Emit event
350            emit CampaignRefunded(campaignId: campaignId, brand: campaign.brand, amount: refundAmount)
351            
352            return true
353        }
354        
355        return false
356    }
357    
358    // Get campaign details
359    access(all) fun getCampaign(id: String): Campaign? {
360        return self.campaigns[id]
361    }
362    
363    // Get all campaigns
364    access(all) fun getAllCampaigns(): [Campaign] {
365        let campaigns: [Campaign] = []
366        for campaign in self.campaigns.values {
367            campaigns.append(campaign)
368        }
369        return campaigns
370    }
371    
372    // Get campaigns by creator (where creator is in allowlist)
373    access(all) fun getCampaignsByCreator(creator: Address): [Campaign] {
374        let creatorCampaigns: [Campaign] = []
375        for campaign in self.campaigns.values {
376            if campaign.isCreatorAllowed(creator) {
377                creatorCampaigns.append(campaign)
378            }
379        }
380        return creatorCampaigns
381    }
382    
383    // Get open campaigns that a creator hasn't joined yet
384    access(all) fun getOpenCampaigns(excludeCreator: Address?): [Campaign] {
385        let openCampaigns: [Campaign] = []
386        for campaign in self.campaigns.values {
387            if campaign.campaignType == CampaignType.open && !campaign.isPaidOut() {
388                // If excludeCreator provided, filter out campaigns they're already in
389                if excludeCreator != nil && campaign.isCreatorAllowed(excludeCreator!) {
390                    continue
391                }
392                openCampaigns.append(campaign)
393            }
394        }
395        return openCampaigns
396    }
397    
398    // Events
399    access(all) event CampaignCreated(id: String, brand: Address, threshold: UFix64, payout: UFix64, campaignType: UInt8, title: String)
400    access(all) event CreatorJoinedCampaign(campaignId: String, creator: Address)
401    access(all) event CreatorScoreUpdated(campaignId: String, creator: Address, score: UFix64)
402    access(all) event PayoutTriggered(campaignId: String, totalScore: UFix64, payout: UFix64)
403    access(all) event CampaignRefunded(campaignId: String, brand: Address, amount: UFix64)
404    
405    init(oracle: Address) {
406        self.oracle = oracle
407        self.campaigns = {}
408        self.vault <- FlowToken.createEmptyVault(vaultType: Type<@FlowToken.Vault>())
409    }
410}
411
412