Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 4 additions & 22 deletions components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useEffect } from 'react';
import { useState } from 'react';
import Link from 'next/link';
import { Menu, X } from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
Expand All @@ -16,27 +16,9 @@ export default function Header() {
const { isAuthenticated } = useAuthStore();
const { address: walletAddress, connect: connectWallet } = useWalletStore();

// Periodically fetch balance when wallet is connected
useEffect(() => {
if (walletAddress) {
const fetchBalance = async () => {
try {
const res = await fetch(`https://horizon-testnet.stellar.org/accounts/${walletAddress}`);
if (res.ok) {
const data = await res.json();
const native = data.balances.find((b: any) => b.asset_type === 'native');
useWalletStore.setState({ balance: native ? native.balance : '0.0000000' });
}
} catch (err) {
console.error('Error fetching balance:', err);
}
};

fetchBalance();
const interval = setInterval(fetchBalance, 15000);
return () => clearInterval(interval);
}
}, [walletAddress]);
// Balance fetching and periodic refresh live in the wallet store: it
// resolves the Horizon URL from the configured network and refreshes on
// connect + on an interval, so it is not duplicated here.

const handleWalletConnect = async (walletType: string) => {
try {
Expand Down
14 changes: 11 additions & 3 deletions components/WalletDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default function WalletDropdown() {
const [isOpen, setIsOpen] = useState(false);
const [copied, setCopied] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { address, balance, disconnect } = useWalletStore();
const { address, balance, balanceError, disconnect } = useWalletStore();

const handleDisconnect = async () => {
setIsOpen(false);
Expand Down Expand Up @@ -98,7 +98,11 @@ export default function WalletDropdown() {
{truncateAddress(address)}
</span>
<span className="text-[10px] text-muted-foreground font-medium">
{balance ? `${parseFloat(balance).toFixed(2)} XLM` : '0.00 XLM'}
{balanceError
? 'Balance unavailable'
: balance
? `${parseFloat(balance).toFixed(2)} XLM`
: '0.00 XLM'}
</span>
</div>
<ChevronDown
Expand Down Expand Up @@ -129,7 +133,11 @@ export default function WalletDropdown() {
{address}
</p>
<p className="text-xs text-muted-foreground font-medium mt-0.5">
{balance ? `${parseFloat(balance).toFixed(4)} XLM` : '0.0000 XLM'}
{balanceError
? 'Balance unavailable'
: balance
? `${parseFloat(balance).toFixed(4)} XLM`
: '0.0000 XLM'}
</p>
</div>
</div>
Expand Down
28 changes: 21 additions & 7 deletions lib/server/adminStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,9 @@ export function createUser(data: Partial<AdminUser>): AdminUser {
export function updateUserById(id: string, patch: Partial<AdminUser>): AdminUser | undefined {
const index = users.findIndex((u) => u.id === id);
if (index === -1) return undefined;
users[index] = { ...users[index], ...patch };
const current = users[index];
if (!current) return undefined;
users[index] = { ...current, ...patch };
return users[index];
}

Expand All @@ -217,21 +219,27 @@ export function deleteUserById(id: string): boolean {
export function setUserRole(id: string, role: AdminUserRole): AdminUser | undefined {
const index = users.findIndex((u) => u.id === id);
if (index === -1) return undefined;
users[index] = { ...users[index], role };
const current = users[index];
if (!current) return undefined;
users[index] = { ...current, role };
return users[index];
}

export function setUserKycStatus(id: string, status: AdminKycStatus): AdminUser | undefined {
const index = users.findIndex((u) => u.id === id);
if (index === -1) return undefined;
users[index] = { ...users[index], kycStatus: status };
const current = users[index];
if (!current) return undefined;
users[index] = { ...current, kycStatus: status };
return users[index];
}

export function toggleUserSuspension(id: string): AdminUser | undefined {
const index = users.findIndex((u) => u.id === id);
if (index === -1) return undefined;
users[index] = { ...users[index], isSuspended: !users[index].isSuspended };
const current = users[index];
if (!current) return undefined;
users[index] = { ...current, isSuspended: !current.isSuspended };
return users[index];
}

Expand Down Expand Up @@ -272,8 +280,10 @@ export function deleteWithdrawalById(id: string): boolean {
export function approveWithdrawal(id: string): AdminWithdrawal | undefined {
const index = withdrawals.findIndex((w) => w.id === id);
if (index === -1) return undefined;
const current = withdrawals[index];
if (!current) return undefined;
withdrawals[index] = {
...withdrawals[index],
...current,
status: 'APPROVED',
processedDate: new Date().toISOString(),
};
Expand All @@ -283,8 +293,10 @@ export function approveWithdrawal(id: string): AdminWithdrawal | undefined {
export function rejectWithdrawal(id: string, reason: string): AdminWithdrawal | undefined {
const index = withdrawals.findIndex((w) => w.id === id);
if (index === -1) return undefined;
const current = withdrawals[index];
if (!current) return undefined;
withdrawals[index] = {
...withdrawals[index],
...current,
status: 'REJECTED',
processedDate: new Date().toISOString(),
rejectionReason: reason,
Expand All @@ -295,8 +307,10 @@ export function rejectWithdrawal(id: string, reason: string): AdminWithdrawal |
export function completeWithdrawal(id: string, transactionHash: string): AdminWithdrawal | undefined {
const index = withdrawals.findIndex((w) => w.id === id);
if (index === -1) return undefined;
const current = withdrawals[index];
if (!current) return undefined;
withdrawals[index] = {
...withdrawals[index],
...current,
status: 'COMPLETED',
processedDate: new Date().toISOString(),
transactionHash,
Expand Down
74 changes: 48 additions & 26 deletions lib/server/campaignDeployer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ import {
Address,
Keypair,
Operation,
SorobanRpc,
TransactionBuilder,
scval,
nativeToScVal,
rpc,
xdr,
} from '@stellar/stellar-sdk';
import { env } from '@/lib/env';

Expand Down Expand Up @@ -76,6 +77,26 @@ function descriptionHash(title: string): Buffer {
.digest();
}

/**
* Builds a Soroban map scval with symbol keys from [key, value] entries.
* Symbol keys match the serialization that `#[derive(Serialize)]` contract
* structs expect on-chain; plain string keys would not deserialize. Keys are
* sorted, matching the SDK's own map conversion (the Soroban runtime expects
* sorted map keys).
*/
function scMap(entries: Array<[string, xdr.ScVal]>): xdr.ScVal {
const sorted = [...entries].sort(([a], [b]) => a.localeCompare(b));
return xdr.ScVal.scvMap(
sorted.map(
([key, value]) =>
new xdr.ScMapEntry({
key: nativeToScVal(key, { type: 'symbol' }),
val: value,
}),
),
);
}

function requireConfig(value: string | undefined, name: string): string {
const trimmed = value?.trim() ?? '';
if (!trimmed) {
Expand Down Expand Up @@ -140,7 +161,7 @@ export async function deployCampaign(
const minDonationStroops = stroops(input.minDonationAmount ?? 0.001);

// ── Build contract arguments (per the canonical campaign contract) ───────
const acceptedAssetsScVal = scval.toVec(
const acceptedAssetsScVal = nativeToScVal(
input.acceptedAssets.map((asset) => {
const code = asset.code.trim().toUpperCase();
if (code !== 'XLM' && !asset.contractId) {
Expand All @@ -150,39 +171,39 @@ export async function deployCampaign(
`Only native XLM has no issuer.`,
);
}
return scval.toMap([
[scval.toSymbol('asset_code'), scval.toString(code)],
return scMap([
['asset_code', nativeToScVal(code)],
[
scval.toSymbol('issuer'),
'issuer',
asset.contractId
? scval.toAddress(new Address(asset.contractId))
: scval.toVoid(),
? nativeToScVal(new Address(asset.contractId), { type: 'address' })
: nativeToScVal(undefined),
],
]);
}),
);

const milestoneScVal = scval.toMap([
[scval.toSymbol('index'), scval.toU32(0)],
[scval.toSymbol('target_amount'), scval.toI128(goalStroops)],
[scval.toSymbol('released_amount'), scval.toI128(0n)],
[scval.toSymbol('description_hash'), scval.toBytes(descriptionHash(input.title))],
[scval.toSymbol('status'), scval.toU32(0)], // MilestoneStatus::Locked
[scval.toSymbol('released_at'), scval.toVoid()],
const milestoneScVal = scMap([
['index', nativeToScVal(0, { type: 'u32' })],
['target_amount', nativeToScVal(goalStroops, { type: 'i128' })],
['released_amount', nativeToScVal(0n, { type: 'i128' })],
['description_hash', nativeToScVal(descriptionHash(input.title))],
['status', nativeToScVal(0, { type: 'u32' })], // MilestoneStatus::Locked
['released_at', nativeToScVal(undefined)],
]);
const milestonesScVal = scval.toVec([milestoneScVal]);
const milestonesScVal = nativeToScVal([milestoneScVal]);

const invokeArgs = [
scval.toAddress(new Address(creatorAddress)),
scval.toI128(goalStroops),
scval.toU64(BigInt(endTime)),
nativeToScVal(new Address(creatorAddress), { type: 'address' }),
nativeToScVal(goalStroops, { type: 'i128' }),
nativeToScVal(BigInt(endTime), { type: 'u64' }),
acceptedAssetsScVal,
milestonesScVal,
scval.toI128(minDonationStroops),
nativeToScVal(minDonationStroops, { type: 'i128' }),
];

// ── Submit to Soroban RPC ────────────────────────────────────────────────
const server = new SorobanRpc.Server(sorobanRpcUrl);
const server = new rpc.Server(sorobanRpcUrl);

let account;
try {
Expand All @@ -209,14 +230,15 @@ export async function deployCampaign(
.build();

const simulation = await server.simulateTransaction(transaction);
if (SorobanRpc.isSimulationError(simulation)) {
if (rpc.Api.isSimulationError(simulation)) {
throw new Error(`Soroban simulation rejected the deployment: ${simulation.error}`);
}

const assembled = SorobanRpc.assembleTransaction(transaction, simulation).sign(adminKeypair);
const assembled = rpc.assembleTransaction(transaction, simulation).build();
assembled.sign(adminKeypair);
const sendResult = await server.sendTransaction(assembled);
if (sendResult.status === 'ERROR' || sendResult.status === 'FAILED') {
throw new Error(`Soroban rejected the deployment transaction: ${sendResult.errorResult?.resultXdr ?? sendResult.status}`);
if (sendResult.status === 'ERROR') {
throw new Error(`Soroban rejected the deployment transaction: ${sendResult.errorResult?.toXDR('base64') ?? sendResult.status}`);
}

const txHash = sendResult.hash;
Expand All @@ -242,7 +264,7 @@ export async function deployCampaign(
}

// ── Register with the backend so the returned campaign id is real ───────
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000/api';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
let campaignId: string;

try {
Expand Down
6 changes: 5 additions & 1 deletion lib/server/draftStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,12 @@ export async function saveDraft(
let saved: ProjectDraft;

if (existingIndex >= 0) {
const existing = drafts[existingIndex];
if (!existing) {
throw new Error('Draft store inconsistency: index resolved but draft missing');
}
saved = {
...drafts[existingIndex],
...existing,
title: payload.title,
formData: payload.formData,
currentStep: payload.currentStep,
Expand Down
72 changes: 72 additions & 0 deletions lib/stellar/balance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* lib/stellar/balance.ts
*
* Native-balance lookup against a Horizon server. Kept free of app imports so
* it can be unit-tested directly with Node's built-in test runner and mocked
* fetch responses.
*
* Failure semantics matter here:
* - Horizon 404 (account does not exist yet) is a VALID zero balance — a
* brand-new account must show 0 without an error.
* - Any other HTTP error or network failure is a real lookup failure and is
* surfaced as an error instead of a silently wrong balance.
*/

export const ZERO_BALANCE = '0.0000000';

export interface BalanceFetchResult {
/** Native balance when the lookup succeeded; null when it failed. */
balance: string | null;
/** Human-readable error when the lookup failed; null otherwise (404 included). */
error: string | null;
}

interface HorizonAccountResponse {
balances?: Array<{ asset_type?: string; balance?: string }>;
}

/** Extracts the native (XLM) balance from a Horizon account response. */
export function parseNativeBalance(data: HorizonAccountResponse): string {
const native = data.balances?.find((b) => b.asset_type === 'native');
return native?.balance ?? ZERO_BALANCE;
}

/**
* Fetches the native balance for `address` from the given Horizon base URL.
*
* @param address Stellar account address (G...).
* @param horizonUrl Horizon base URL for the configured network (no trailing slash needed).
*/
export async function fetchNativeBalance(
address: string,
horizonUrl: string,
): Promise<BalanceFetchResult> {
const baseUrl = horizonUrl.replace(/\/+$/, '');
const url = `${baseUrl}/accounts/${encodeURIComponent(address)}`;

let res: Response;
try {
res = await fetch(url);
} catch (err) {
return {
balance: null,
error: err instanceof Error ? err.message : 'Failed to reach Horizon',
};
}

if (res.status === 404) {
// Account not found on the ledger — a new/empty account, not an error.
return { balance: ZERO_BALANCE, error: null };
}

if (!res.ok) {
return { balance: null, error: `Horizon returned HTTP ${res.status}` };
}

try {
const data = (await res.json()) as HorizonAccountResponse;
return { balance: parseNativeBalance(data), error: null };
} catch {
return { balance: null, error: 'Horizon returned an unparseable response' };
}
}
17 changes: 17 additions & 0 deletions lib/stellar/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,23 @@ export const NETWORK_PASSPHRASES: Record<StellarNetwork, string> = {
futurenet: 'Test SDF Future Network ; October 2022',
};

/**
* Resolves the Horizon base URL used for account/balance lookups.
*
* Honors an explicit `NEXT_PUBLIC_STELLAR_HORIZON_URL` override; otherwise
* maps the configured `NEXT_PUBLIC_STELLAR_NETWORK` ('testnet' | 'mainnet' |
* 'futurenet') to this module's well-known URLs, where the env's 'mainnet'
* corresponds to this module's 'public' network.
*/
export function resolveHorizonUrl(): string {
const override = process.env.NEXT_PUBLIC_STELLAR_HORIZON_URL?.trim();
if (override) return override.replace(/\/+$/, '');

const network = process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'testnet';
const key: StellarNetwork = network === 'mainnet' ? 'public' : (network as StellarNetwork);
return HORIZON_URLS[key] ?? HORIZON_URLS.testnet;
}

/**
* Get Stellar configuration for a specific network
* @param network - The Stellar network to configure
Expand Down
Loading