Skip to content

Commit 0cffbc7

Browse files
authored
Merge pull request #173 from sudo-robi/multi-token-campaign-support
Add multi-token campaign support and backend model updates
2 parents 599cdb2 + 4f59f09 commit 0cffbc7

29 files changed

Lines changed: 9367 additions & 306 deletions

MULTI_TOKEN_DESIGN_DECISION.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Multi-Token Support Design Decision
2+
3+
## Problem Statement
4+
5+
Each campaign is currently tied to a single token at creation time. There is no option to accept contributions in secondary tokens, limiting the flexibility of crowdfunding campaigns on Stellar.
6+
7+
## Proposed Solutions
8+
9+
### Option 1: Extend Campaign Model to Support Multiple Accepted Tokens
10+
11+
**Implementation Approach:**
12+
- Modify the `Campaign` struct in the Soroban contract to include `accepted_tokens: Vec<Address>` instead of a single `token: Address`
13+
- Update `create_campaign` to accept a list of accepted tokens
14+
- Modify `contribute` to accept a `token: Address` parameter and validate it against `accepted_tokens`
15+
- Track pledged amounts per token separately
16+
- Define how the target amount is evaluated (sum of all contributions? conversion to base value?)
17+
18+
**Challenges:**
19+
1. **Valuation Complexity**: How to determine if the campaign target is met when contributions are in different tokens?
20+
- Option A: Sum all contributions in their native tokens (requires target to be per-token)
21+
- Option B: Convert all contributions to a base value using price feeds (requires oracle integration)
22+
- Option C: Primary token target, secondary tokens as bonus contributions
23+
24+
2. **Contract Complexity**: Need to track contributions per token, modify storage keys, update invariants
25+
26+
3. **Claim Logic**: How to transfer funds when claiming? Transfer all tokens to creator?
27+
28+
4. **Refund Logic**: Refunds need to return the correct token
29+
30+
5. **UI Complexity**: Contributors need to choose which token to contribute with
31+
32+
### Option 2: Single Token Per Campaign (Current Approach)
33+
34+
**Pros:**
35+
- Simple contract logic
36+
- Clear valuation (all amounts in same token)
37+
- Straightforward claiming and refunding
38+
- Easy to understand for users
39+
40+
**Cons:**
41+
- Limited flexibility for creators
42+
- Contributors must hold the specific token
43+
- May reduce participation if token is illiquid
44+
45+
### Option 3: Token Conversion at Contribution Time
46+
47+
**Implementation:**
48+
- Campaign specifies primary token and accepted secondary tokens
49+
- At contribution time, automatically convert secondary token contributions to primary token using an oracle
50+
- All accounting in primary token
51+
52+
**Challenges:**
53+
- Requires reliable price feeds/oracles
54+
- Introduces slippage and conversion fees
55+
- Additional complexity in contribution flow
56+
- Oracle dependency for core functionality
57+
58+
## Final Decision: Implement Option 1 (Multi-Token Support)
59+
60+
**Rationale:**
61+
1. **Flexibility**: Allowing multiple tokens increases the potential for campaign success by letting contributors use their preferred assets.
62+
2. **Standardization**: Implementing this at the contract level ensures consistent behavior across different frontends.
63+
3. **Future-Proofing**: While simple now, this architecture allows for future integration with price oracles for more complex valuation.
64+
65+
## Implementation Details
66+
67+
### Contract Architecture
68+
- **Storage Keys**:
69+
- `Contribution(u64, Address, Address)`: Tracks contributions per campaign, contributor, and token.
70+
- `CampaignTokenBalance(u64, Address)`: Tracks total pledged amount for each token in a campaign.
71+
- **Valuation Strategy**:
72+
- `pledged_amount` in the `Campaign` struct is a raw sum of all token amounts.
73+
- **Tradeoff**: This assumes a 1:1 value ratio for all accepted tokens in terms of meeting the `target_amount`. Creators should only accept tokens of similar value (e.g., various USD stablecoins) or understand that the target is a sum of units.
74+
- **Claim Flow**: The `claim` function iterates over all `accepted_tokens` and transfers the full balance of each token to the creator.
75+
- **Refund Flow**: The `refund` function iterates over all `accepted_tokens` and returns the specific tokens contributed by the user.
76+
77+
### API for Integrators
78+
79+
#### Campaign Creation
80+
```json
81+
{
82+
"creator": "G...",
83+
"accepted_tokens": ["USDC:GA...", "PYUSD:GA..."],
84+
"target_amount": 1000,
85+
"deadline": 1234567890,
86+
"metadata": "..."
87+
}
88+
```
89+
90+
#### Contribution
91+
```json
92+
{
93+
"campaign_id": 1,
94+
"contributor": "G...",
95+
"token": "USDC:GA...",
96+
"amount": 100
97+
}
98+
```
99+
100+
#### Querying
101+
- `get_contribution(campaign_id, contributor, token)`: Returns the amount of a specific token pledged by a contributor.
102+
- `get_campaign_token_balance(campaign_id, token)`: Returns the total amount of a specific token pledged to the campaign.
103+
104+
## Conclusion
105+
106+
Multi-token support has been implemented to provide maximum flexibility for crowdfunding on Stellar. The current valuation strategy is simple (1:1 unit sum), which is suitable for campaigns using similar-value assets. For campaigns requiring diverse assets (e.g., XLM and USDC), integrators should be aware that the `target_amount` is calculated as a raw sum of units.
107+
</content>
108+
<parameter name="filePath">/home/robi/Desktop/stellar-goal-vault/MULTI_TOKEN_DESIGN_DECISION.md

backend/src/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ export const config = {
3131
contractId: process.env.CONTRACT_ID ?? "",
3232
sorobanNetworkPassphrase:
3333
process.env.SOROBAN_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015",
34+
assetAddresses: (process.env.ASSET_ADDRESSES ?? "XLM:CDLZFC3SYJYDZT7K3SSTH3YCUY6AFMCO3Y6S3G7FEYZNVNREK7Y6CYN5,USDC:CA6WSTPZ7RRCUC6H37CQFODG763XG2HXP2G6F367VCOGGVDP32P7665E")
35+
.split(",")
36+
.reduce((acc, pair) => {
37+
const [code, addr] = pair.split(":");
38+
if (code && addr) acc[code.trim().toUpperCase()] = addr.trim();
39+
return acc;
40+
}, {} as Record<string, string>),
3441
defaultMaxPerContributor: parseInteger(process.env.DEFAULT_MAX_PER_CONTRIBUTOR, 0),
3542
};
3643

backend/src/index.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,13 @@ const CONTRIBUTOR = `G${"B".repeat(55)}`;
2525
beforeAll(async () => {
2626
fs.rmSync(TEST_DB_PATH, { force: true });
2727
({ parseCampaignListFilters } = await import("./index"));
28-
28+
({
29+
listCampaigns,
30+
createCampaign,
31+
addPledge,
32+
calculateProgress,
33+
initCampaignStore,
34+
} = await import("./services/campaignStore"));
2935
({ getDb } = await import("./services/db"));
3036
initCampaignStore();
3137
}, 20000);

backend/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,7 @@ app.get("/api/config", (_req: Request, res: Response) => {
487487
networkPassphrase: config.sorobanNetworkPassphrase,
488488
contractAmountDecimals: CONTRACT_AMOUNT_DECIMALS,
489489
walletIntegrationReady,
490+
assetAddresses: config.assetAddresses,
490491
},
491492
});
492493
});

backend/src/services/__tests__/eventMetadata.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,15 @@ describe("Event Metadata Support", () => {
2929
getDb()
3030
.prepare(
3131
`INSERT INTO campaigns (
32-
id, creator, title, description, asset_code, target_amount, pledged_amount, deadline, created_at, claimed_at, metadata_json
32+
id, creator, title, description, accepted_tokens_json, target_amount, pledged_amount, deadline, created_at, claimed_at, metadata_json
3333
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
3434
)
3535
.run(
3636
campaignId,
3737
`G${"A".repeat(55)}`,
3838
`Campaign ${campaignId}`,
3939
"Synthetic campaign record for event metadata tests.",
40-
"USDC",
40+
JSON.stringify(["USDC"]),
4141
100,
4242
0,
4343
Math.floor(Date.now() / 1000) + 3600,

backend/src/services/campaignStore.ts

Lines changed: 61 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ export interface CampaignInput {
77
creator: string;
88
title: string;
99
description: string;
10-
assetCode: string;
10+
acceptedTokens?: string[];
11+
assetCode?: string; // Backward compatibility
1112
targetAmount: number;
1213
deadline: number;
1314
metadata?: {
@@ -20,6 +21,7 @@ export interface CampaignInput {
2021
export interface PledgeInput {
2122
contributor: string;
2223
amount: number;
24+
assetCode?: string; // Optional for backward compatibility if only one token
2325
}
2426

2527
export interface ReconciledPledgeInput extends PledgeInput {
@@ -32,7 +34,8 @@ export interface CampaignRecord {
3234
creator: string;
3335
title: string;
3436
description: string;
35-
assetCode: string;
37+
acceptedTokens: string[];
38+
assetCode: string; // Backward compatibility (first token)
3639
targetAmount: number;
3740
pledgedAmount: number;
3841
deadline: number;
@@ -62,6 +65,7 @@ export interface PledgeRecord {
6265
campaignId: string;
6366
contributor: string;
6467
amount: number;
68+
assetCode: string;
6569
createdAt: number;
6670
refundedAt?: number;
6771
transactionHash?: string;
@@ -84,7 +88,7 @@ interface CampaignRow {
8488
creator: string;
8589
title: string;
8690
description: string;
87-
asset_code: string;
91+
accepted_tokens_json: string; // JSON array of strings
8892
target_amount: number;
8993
pledged_amount: number;
9094
deadline: number;
@@ -100,6 +104,7 @@ interface PledgeRow {
100104
campaign_id: string;
101105
contributor: string;
102106
amount: number;
107+
asset_code: string;
103108
created_at: number;
104109
refunded_at: number | null;
105110
transaction_hash: string | null;
@@ -126,12 +131,14 @@ function round(value: number): number {
126131
}
127132

128133
function rowToCampaign(row: CampaignRow): CampaignRecord {
134+
const acceptedTokens = JSON.parse(row.accepted_tokens_json);
129135
return {
130136
id: row.id,
131137
creator: row.creator,
132138
title: row.title,
133139
description: row.description,
134-
assetCode: row.asset_code,
140+
acceptedTokens: acceptedTokens,
141+
assetCode: acceptedTokens[0] || "",
135142
targetAmount: row.target_amount,
136143
pledgedAmount: row.pledged_amount,
137144
deadline: row.deadline,
@@ -149,6 +156,7 @@ function rowToPledge(row: PledgeRow): PledgeRecord {
149156
campaignId: row.campaign_id,
150157
contributor: row.contributor,
151158
amount: row.amount,
159+
assetCode: row.asset_code,
152160
createdAt: row.created_at,
153161
refundedAt: row.refunded_at ?? undefined,
154162
transactionHash: row.transaction_hash ?? undefined,
@@ -291,8 +299,8 @@ export function listCampaigns(options?: ListCampaignsOptions): ListCampaignsResu
291299
}
292300

293301
if (options?.assetCode) {
294-
whereClauses.push(`asset_code = ?`);
295-
params.push(options.assetCode.toUpperCase());
302+
whereClauses.push(`accepted_tokens_json LIKE ?`);
303+
params.push(`%${options.assetCode.toUpperCase()}%`);
296304
}
297305

298306
if (options?.status) {
@@ -419,12 +427,20 @@ export function createCampaign(input: CampaignInput): CampaignRecord {
419427
);
420428
}
421429

430+
const acceptedTokens = input.acceptedTokens
431+
? input.acceptedTokens.map(code => code.trim().toUpperCase())
432+
: (input.assetCode ? [input.assetCode.trim().toUpperCase()] : []);
433+
434+
if (acceptedTokens.length === 0) {
435+
throw toServiceError("At least one accepted token is required.", 400, "INVALID_INPUT");
436+
}
437+
422438
const campaign: CampaignRecord = {
423439
id: nextCampaignId(),
424440
creator: input.creator,
425441
title: input.title.trim(),
426442
description: input.description.trim(),
427-
assetCode: input.assetCode.trim().toUpperCase(),
443+
acceptedTokens,
428444
targetAmount: round(input.targetAmount),
429445
pledgedAmount: 0,
430446
deadline: input.deadline,
@@ -435,16 +451,24 @@ export function createCampaign(input: CampaignInput): CampaignRecord {
435451

436452
db.prepare(
437453
`INSERT INTO campaigns (
438-
id, creator, title, description, asset_code, target_amount, pledged_amount, deadline, created_at, claimed_at, metadata_json, max_per_contributor
454+
id, creator, title, description, accepted_tokens_json, target_amount, pledged_amount, deadline, created_at, claimed_at, metadata_json, max_per_contributor
439455
) VALUES (
440-
@id, @creator, @title, @description, @assetCode, @targetAmount, @pledgedAmount, @deadline, @createdAt, @claimedAt, @metadataJson, @maxPerContributor
456+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
441457
)`,
442-
).run({
443-
...campaign,
444-
claimedAt: null,
445-
metadataJson: campaign.metadata ? JSON.stringify(campaign.metadata) : null,
446-
maxPerContributor: campaign.maxPerContributor ?? null,
447-
});
458+
).run(
459+
campaign.id,
460+
campaign.creator,
461+
campaign.title,
462+
campaign.description,
463+
JSON.stringify(campaign.acceptedTokens),
464+
campaign.targetAmount,
465+
campaign.pledgedAmount,
466+
campaign.deadline,
467+
campaign.createdAt,
468+
null,
469+
campaign.metadata ? JSON.stringify(campaign.metadata) : null,
470+
campaign.maxPerContributor ?? null,
471+
);
448472

449473
recordEvent(
450474
campaign.id,
@@ -454,7 +478,7 @@ export function createCampaign(input: CampaignInput): CampaignRecord {
454478
undefined,
455479
{
456480
title: campaign.title,
457-
assetCode: campaign.assetCode,
481+
acceptedTokens: campaign.acceptedTokens,
458482
targetAmount: campaign.targetAmount,
459483
deadline: campaign.deadline,
460484
},
@@ -474,6 +498,16 @@ export function addPledge(campaignId: string, input: PledgeInput): CampaignRecor
474498
throw toServiceError("Campaign not found.", 404, "NOT_FOUND");
475499
}
476500

501+
const assetCode = (input.assetCode || campaign.assetCode).toUpperCase();
502+
503+
if (!campaign.acceptedTokens.includes(assetCode)) {
504+
throw toServiceError(
505+
`Asset ${assetCode} is not accepted by this campaign.`,
506+
400,
507+
"INVALID_ASSET",
508+
);
509+
}
510+
477511
const progress = calculateProgress(campaign);
478512
if (!progress.canPledge) {
479513
throw toServiceError(
@@ -496,9 +530,9 @@ export function addPledge(campaignId: string, input: PledgeInput): CampaignRecor
496530
);
497531
}
498532
db.prepare(
499-
`INSERT INTO pledges (campaign_id, contributor, amount, created_at, refunded_at, transaction_hash)
500-
VALUES (?, ?, ?, ?, NULL, NULL)`,
501-
).run(campaignId, input.contributor, roundedAmount, createdAt);
533+
`INSERT INTO pledges (campaign_id, contributor, amount, asset_code, created_at, refunded_at, transaction_hash)
534+
VALUES (?, ?, ?, ?, ?, NULL, NULL)`,
535+
).run(campaignId, input.contributor, roundedAmount, assetCode, createdAt);
502536

503537
db.prepare(`UPDATE campaigns SET pledged_amount = pledged_amount + ? WHERE id = ?`).run(
504538
roundedAmount,
@@ -512,7 +546,8 @@ export function addPledge(campaignId: string, input: PledgeInput): CampaignRecor
512546
input.contributor,
513547
roundedAmount,
514548
{
515-
newTotalPledged: nextPledgedAmount,
549+
newTotalPledged: nextPledgedAmount,
550+
assetCode,
516551
source: "backend-mvp",
517552
},
518553
{ source: "local" } as BlockchainMetadata,
@@ -557,7 +592,9 @@ export function reconcileOnChainPledge(
557592
const db = getDb();
558593
const createdAt = input.confirmedAt ?? nowInSeconds();
559594
const roundedAmount = round(input.amount);
595+
const assetCode = (input.assetCode || campaign.assetCode).toUpperCase();
560596
const nextPledgedAmount = round(campaign.pledgedAmount + roundedAmount);
597+
561598
if (nextPledgedAmount > campaign.targetAmount) {
562599
throw toServiceError(
563600
"Pledge exceeds campaign funding cap.",
@@ -569,9 +606,9 @@ export function reconcileOnChainPledge(
569606
const reconcile = db.transaction(() => {
570607
db.prepare(
571608
`INSERT INTO pledges (
572-
campaign_id, contributor, amount, created_at, refunded_at, transaction_hash
573-
) VALUES (?, ?, ?, ?, NULL, ?)`,
574-
).run(campaignId, input.contributor, roundedAmount, createdAt, input.transactionHash);
609+
campaign_id, contributor, amount, asset_code, created_at, refunded_at, transaction_hash
610+
) VALUES (?, ?, ?, ?, ?, NULL, ?)`,
611+
).run(campaignId, input.contributor, roundedAmount, assetCode, createdAt, input.transactionHash);
575612

576613
db.prepare(`UPDATE campaigns SET pledged_amount = pledged_amount + ? WHERE id = ?`).run(
577614
roundedAmount,
@@ -586,6 +623,7 @@ export function reconcileOnChainPledge(
586623
roundedAmount,
587624
{
588625
newTotalPledged: nextPledgedAmount,
626+
assetCode: assetCode,
589627
onChain: true,
590628
reconciled: true,
591629
},

0 commit comments

Comments
 (0)