Smart Contract
CampaignEscrowV4
A.14aca78d100d2001.CampaignEscrowV4
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