Skip to content

Commit 64efa07

Browse files
feat: implement issues #184, #185, #191, #192
#184 — Minimum contribution configurable at deploy time - initialize() now accepts min_contribution: i128 (default 100 stroops) - Stored in DataKey::MinContribution; contribute() reads from storage - get_min_contribution() getter added - Boundary tests: 99 stroops rejected, 100 accepted; custom min tests - README documents deployment param and contract rules #185 — update_metadata for campaign creators - update_metadata(campaign_id, creator, new_metadata) added to contract - Only original creator, before deadline, on active campaigns - Emits MetadataUpdated event with old + new values - Backend eventIndexer recognises Goal:MetaUpd topic and calls updateCampaignMetadata() to sync local DB - CampaignEventType extended with metadata_updated #191 — Multi-token campaign support (frontend + backend) - CampaignRecord gains tokenBalances: Record<string, number> - getCampaignTokenBalances() queries pledges table per asset_code - getCampaign() now populates tokenBalances on every read - Campaign type in frontend extended with tokenBalances - CampaignCard shows per-token progress bars for multi-token campaigns - CampaignDetailPanel adds token selector dropdown in pledge form #192 — Deadline extension governance - Campaign struct gains created_at: u64 (set on create_campaign) - DataKey::ExtensionRequest(u64) and DataKey::ExtensionVote(u64, Address) - ExtensionRequest struct stores new_deadline, requested_by, approval_count - request_deadline_extension(): contributor-only, auto-approves requester, emits ExtensionRequested event, validates MAX_CAMPAIGN_DURATION_SECONDS - approve_extension(): contributor-only, prevents double-vote, applies deadline when approval_count * 2 > contributor_count - get_extension_request() getter for pending requests - Full test suite: 38 tests passing (cargo test)
1 parent c7434c2 commit 64efa07

46 files changed

Lines changed: 36693 additions & 35 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,12 @@ Each campaign stores:
5757
- `creator`
5858
- `title`
5959
- `description`
60-
- `assetCode`
60+
- `acceptedTokens` — one or more Stellar asset codes the campaign accepts
61+
- `assetCode` — first accepted token (backward-compatibility alias)
6162
- `targetAmount`
6263
- `pledgedAmount`
6364
- `deadline`
65+
- `tokenBalances` — per-token pledge totals (`Record<assetCode, amount>`)
6466

6567
Campaign states:
6668

@@ -69,6 +71,59 @@ Campaign states:
6971
- `claimed` when the creator has claimed a funded vault
7072
- `failed` when deadline has passed without reaching the target
7173

74+
## Contract rules
75+
76+
### Minimum contribution (issue #184)
77+
78+
The Soroban contract enforces a minimum contribution per pledge. The default is **100 stroops**. This is configurable at deploy time via `initialize(admin, min_contribution)`.
79+
80+
```bash
81+
# Deploy with a custom minimum (e.g. 500 stroops)
82+
stellar contract invoke --id $CONTRACT_ID -- initialize \
83+
--admin $ADMIN_ADDRESS \
84+
--min_contribution 500
85+
```
86+
87+
Contributions below the minimum are rejected with `"contribution below minimum"`.
88+
89+
### Metadata updates (issue #185)
90+
91+
A campaign creator can update the campaign metadata before the deadline:
92+
93+
```bash
94+
stellar contract invoke --id $CONTRACT_ID -- update_metadata \
95+
--campaign_id 1 \
96+
--creator $CREATOR_ADDRESS \
97+
--new_metadata "Updated description"
98+
```
99+
100+
The contract emits a `MetadataUpdated` event containing both the old and new metadata values. The backend event indexer processes this event and updates local state automatically.
101+
102+
### Deadline extension governance (issue #192)
103+
104+
Any existing contributor can request a deadline extension:
105+
106+
```bash
107+
stellar contract invoke --id $CONTRACT_ID -- request_deadline_extension \
108+
--campaign_id 1 \
109+
--caller $CONTRIBUTOR_ADDRESS \
110+
--new_deadline <unix_timestamp>
111+
```
112+
113+
Other contributors can vote to approve:
114+
115+
```bash
116+
stellar contract invoke --id $CONTRACT_ID -- approve_extension \
117+
--campaign_id 1 \
118+
--caller $OTHER_CONTRIBUTOR
119+
```
120+
121+
The extension is applied once **more than 50%** of unique contributors have approved. Constraints:
122+
123+
- New deadline must be later than the current deadline
124+
- New deadline cannot exceed `MAX_CAMPAIGN_DURATION_SECONDS` (180 days) from the campaign creation timestamp
125+
- Reverts on claimed or canceled campaigns
126+
72127
## Mutation testing
73128

74129
Mutation testing is used to verify that the test suite is effective at catching real bugs, not just achieving line coverage.

backend/src/services/campaignStore.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export interface CampaignRecord {
4848
externalLink?: string;
4949
};
5050
maxPerContributor?: number;
51+
tokenBalances?: Record<string, number>;
5152
}
5253

5354
export interface CampaignProgress {
@@ -479,7 +480,9 @@ export function getCampaign(campaignId: string): CampaignRecord | undefined {
479480
row.failed_at = row.deadline;
480481
db.prepare(`UPDATE campaigns SET failed_at = ? WHERE id = ?`).run(row.deadline, row.id);
481482
}
482-
return rowToCampaign(row);
483+
const campaign = rowToCampaign(row);
484+
campaign.tokenBalances = getCampaignTokenBalances(campaignId);
485+
return campaign;
483486
}
484487
return undefined;
485488
}
@@ -1097,6 +1100,83 @@ export function refundContributor(
10971100
* @param limit - Maximum number of top contributors to return (default: 10).
10981101
* @returns An array of {@link LeaderboardEntry} objects sorted by total pledged amount (descending).
10991102
*/
1103+
/**
1104+
* Returns per-token pledged balances for a campaign (non-refunded only).
1105+
* Maps asset_code → total pledged amount for that token.
1106+
*/
1107+
export function getCampaignTokenBalances(campaignId: string): Record<string, number> {
1108+
const db = getDb();
1109+
const rows = db
1110+
.prepare(
1111+
`SELECT asset_code, COALESCE(SUM(amount), 0) AS balance
1112+
FROM pledges
1113+
WHERE campaign_id = ? AND refunded_at IS NULL
1114+
GROUP BY asset_code`,
1115+
)
1116+
.all(campaignId) as Array<{ asset_code: string; balance: number }>;
1117+
1118+
const result: Record<string, number> = {};
1119+
for (const row of rows) {
1120+
result[row.asset_code] = round(row.balance);
1121+
}
1122+
return result;
1123+
}
1124+
1125+
/**
1126+
* Partially updates a campaign record (title, description, metadata).
1127+
* Used internally and by the event indexer to apply on-chain metadata changes.
1128+
*/
1129+
export function updateCampaign(
1130+
campaignId: string,
1131+
patch: Partial<Pick<CampaignRecord, 'title' | 'description' | 'metadata'>>,
1132+
): CampaignRecord {
1133+
const db = getDb();
1134+
const campaign = getCampaign(campaignId);
1135+
if (!campaign) {
1136+
throw Object.assign(new Error(`Campaign ${campaignId} not found`), { code: 'CAMPAIGN_NOT_FOUND' });
1137+
}
1138+
1139+
const updates: string[] = [];
1140+
const params: unknown[] = [];
1141+
1142+
if (patch.title !== undefined) {
1143+
updates.push('title = ?');
1144+
params.push(patch.title);
1145+
}
1146+
if (patch.description !== undefined) {
1147+
updates.push('description = ?');
1148+
params.push(patch.description);
1149+
}
1150+
if (patch.metadata !== undefined) {
1151+
updates.push('metadata_json = ?');
1152+
params.push(JSON.stringify(patch.metadata));
1153+
}
1154+
1155+
if (updates.length > 0) {
1156+
params.push(campaignId);
1157+
db.prepare(`UPDATE campaigns SET ${updates.join(', ')} WHERE id = ?`).run(...params);
1158+
}
1159+
1160+
return getCampaign(campaignId)!;
1161+
}
1162+
1163+
/**
1164+
* Updates the campaign's metadata string as recorded on-chain.
1165+
* Called by the event indexer when a MetadataUpdated event is received.
1166+
*/
1167+
export function updateCampaignMetadata(campaignId: string, newMetadata: string): void {
1168+
const db = getDb();
1169+
const campaign = getCampaign(campaignId);
1170+
if (!campaign) return;
1171+
1172+
const existing = campaign.metadata ?? {};
1173+
const updated = { ...existing, onChainMetadata: newMetadata };
1174+
db.prepare(`UPDATE campaigns SET metadata_json = ? WHERE id = ?`).run(
1175+
JSON.stringify(updated),
1176+
campaignId,
1177+
);
1178+
}
1179+
11001180
export function getTopContributors(limit: number = 10): LeaderboardEntry[] {
11011181
const db = getDb();
11021182
const rows = db

backend/src/services/eventHistory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getDb } from './db';
22

3-
export type CampaignEventType = 'created' | 'pledged' | 'claimed' | 'refunded' | 'updated';
3+
export type CampaignEventType = 'created' | 'pledged' | 'claimed' | 'refunded' | 'updated' | 'metadata_updated';
44

55
export interface BlockchainMetadata {
66
txHash?: string;

backend/src/services/eventIndexer.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import axios from 'axios';
22
import { getDb } from './db';
33
import { recordEvent, BlockchainMetadata, CampaignEventType } from './eventHistory';
4-
import { reconcileOnChainPledge, createCampaign, getCampaign } from './campaignStore';
4+
import { reconcileOnChainPledge, createCampaign, getCampaign, updateCampaignMetadata } from './campaignStore';
55
import dotenv from 'dotenv';
66
import { config } from '../config';
77
import { logError, logInfo } from '../logger';
@@ -150,11 +150,13 @@ const TOPIC_TO_EVENT: Record<string, CampaignEventType> = {
150150
CampaignPledged: 'pledged',
151151
CampaignClaimed: 'claimed',
152152
CampaignRefunded: 'refunded',
153+
MetadataUpdated: 'metadata_updated',
153154
// Alternative spellings from some contract versions
154155
'Goal:Create': 'created',
155156
'Goal:Pledge': 'pledged',
156157
'Goal:Claim': 'claimed',
157158
'Goal:Refund': 'refunded',
159+
'Goal:MetaUpd': 'metadata_updated',
158160
};
159161

160162
interface ParsedEvent {
@@ -247,6 +249,17 @@ function parseSorobanEvent(event: any): ParsedEvent | null {
247249

248250
function handleParsedEvent(parsed: ParsedEvent): void {
249251
try {
252+
if (parsed.eventType === 'metadata_updated') {
253+
const newMetadata = String((parsed.metadata as any)?.new_metadata ?? '');
254+
if (newMetadata && parsed.campaignId) {
255+
try {
256+
updateCampaignMetadata(parsed.campaignId, newMetadata);
257+
} catch (err) {
258+
logError(err, { event: 'soroban_metadata_update_error', campaignId: parsed.campaignId }, config.logLevel);
259+
}
260+
}
261+
}
262+
250263
if (parsed.eventType === 'pledged' && parsed.actor && parsed.amount != null) {
251264
// Check if campaign exists before reconciling
252265
const exists = getCampaign(parsed.campaignId);

0 commit comments

Comments
 (0)