From 0723ff89652f9f8caa620d1f585af36c290ed6f7 Mon Sep 17 00:00:00 2001 From: Nuruddeen Abubakar Abubakar Date: Sun, 30 Aug 2026 21:41:04 +0100 Subject: [PATCH 1/3] feat: add rawAmount to Proposal type Added rawAmount field to Proposal type and updated amount comment. --- frontend/src/types/accord.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/types/accord.ts b/frontend/src/types/accord.ts index ff271688..4426e61f 100644 --- a/frontend/src/types/accord.ts +++ b/frontend/src/types/accord.ts @@ -15,7 +15,8 @@ export type Proposal = { id: number; kind: ProposalKind; to: string; - amount: string; + amount: string; // formatted: "5,000" + rawAmount: string; // <-- SABO: "50000000000" raw stroops token: string; description: string; approvals: number; @@ -128,4 +129,3 @@ export type OwnerDelegations = { outgoing: Delegation | null; incoming: Delegation[]; }; - From 834351788fe9adfaceb6982ee5a884a707163ca0 Mon Sep 17 00:00:00 2001 From: Nuruddeen Abubakar Abubakar Date: Sun, 30 Aug 2026 22:02:24 +0100 Subject: [PATCH 2/3] feat: add rawAmount to proposals for exact stroops display --- frontend/src/lib/contract.ts | 989 ++--------------------------------- 1 file changed, 31 insertions(+), 958 deletions(-) diff --git a/frontend/src/lib/contract.ts b/frontend/src/lib/contract.ts index ead892c6..24ef41ae 100644 --- a/frontend/src/lib/contract.ts +++ b/frontend/src/lib/contract.ts @@ -41,14 +41,14 @@ async function simulateView( fee: "100", networkPassphrase: NETWORK_PASSPHRASE, }) - .addOperation(contract.call(fn, ...args)) - .setTimeout(30) - .build(); + .addOperation(contract.call(fn,...args)) + .setTimeout(30) + .build(); const sim = await server.simulateTransaction(tx); if (!rpc.Api.isSimulationSuccess(sim)) { const err = sim as rpc.Api.SimulateTransactionErrorResponse; - throw new Error(`${fn}: ${err.error ?? "simulation failed"}`); + throw new Error(`${fn}: ${err.error?? "simulation failed"}`); } return (sim as rpc.Api.SimulateTransactionSuccessResponse).result!.retval; } @@ -56,21 +56,18 @@ async function simulateView( function mapStatus(raw: unknown): ProposalStatus { if (typeof raw === "string") return raw.toLowerCase() as ProposalStatus; if (raw && typeof raw === "object") { - const key = Object.keys(raw as object)[0] ?? "Pending"; + const key = Object.keys(raw as object)[0]?? "Pending"; return key.toLowerCase() as ProposalStatus; } return "pending"; } function mapCategory(raw: unknown): ProposalCategory { - // Soroban unit-enum variants decode either to their name as a string or to a - // single-key object, so handle both shapes like mapStatus does. Anything - // unrecognised (including an unset category) falls back to "other". let key: string; if (typeof raw === "string") { key = raw; } else if (raw && typeof raw === "object") { - key = Object.keys(raw as object)[0] ?? "Other"; + key = Object.keys(raw as object)[0]?? "Other"; } else { return "other"; } @@ -106,61 +103,69 @@ function safeBigInt(value: unknown): bigint { function mapKindDetails( kind: unknown -): Pick { - if (!kind || typeof kind !== "object") { +): Pick { + if (!kind || typeof kind!== "object") { return { kind: "transfer", to: "Unknown", amount: "0", + rawAmount: "0", token: "Unknown", }; } - const [variant, payload] = Object.entries(kind as Record)[0] ?? []; - const normalizedVariant = variant?.toLowerCase() ?? ""; - const values = Array.isArray(payload) ? payload : [payload]; + const [variant, payload] = Object.entries(kind as Record)[0]?? []; + const normalizedVariant = variant?.toLowerCase()?? ""; + const values = Array.isArray(payload)? payload : [payload]; + const raw = safeBigInt(values[1]); switch (normalizedVariant) { case "transfer": return { kind: "transfer", - to: shortenAddr(String(values[0] ?? "Unknown")), - amount: stroopsToDisplay(safeBigInt(values[1])), - token: shortenAddr(String(values[2] ?? "Unknown")), + to: shortenAddr(String(values[0]?? "Unknown")), + amount: stroopsToDisplay(raw), + rawAmount: String(raw), + token: shortenAddr(String(values[2]?? "Unknown")), }; case "addowner": return { kind: "add_owner", - to: shortenAddr(String(values[0] ?? "Unknown")), + to: shortenAddr(String(values[0]?? "Unknown")), amount: "-", + rawAmount: "-", token: "Add owner", }; case "removeowner": return { kind: "remove_owner", - to: shortenAddr(String(values[0] ?? "Unknown")), + to: shortenAddr(String(values[0]?? "Unknown")), amount: "-", + rawAmount: "-", token: "Remove owner", }; case "changethreshold": return { kind: "change_threshold", - to: `${values[0] ?? "Unknown"} approvals`, + to: `${values[0]?? "Unknown"} approvals`, amount: "-", + rawAmount: "-", token: "Threshold", }; case "setspendinglimit": return { kind: "set_spending_limit", - to: shortenAddr(String(values[0] ?? "Unknown")), - amount: String(values[2] ?? "Unknown"), - token: shortenAddr(String(values[1] ?? "Unknown")), + to: shortenAddr(String(values[0]?? "Unknown")), + amount: String(values[2]?? "Unknown"), + rawAmount: String(values[2]?? "0"), + token: shortenAddr(String(values[1]?? "Unknown")), }; case "changeownerweight": return { kind: "change_owner_weight", - to: shortenAddr(String(values[0] ?? "Unknown")), - amount: String(values[1] ?? "Unknown"), + to: shortenAddr(String(values[0]?? "Unknown")), + amount: String(values[1]?? "Unknown"), + rawAmount: String(values[1]?? "0"), token: "Owner weight", }; default: @@ -168,942 +173,10 @@ function mapKindDetails( kind: "transfer", to: "Unknown", amount: "0", + rawAmount: "0", token: "Unknown", }; - default: - return { kind: "transfer", to: "Unknown", amount: "0", token: "Unknown" }; - } -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function mapProposal(raw: any, threshold: number): Proposal { - const rawDeadline = BigInt(raw.deadline); - const mapped = mapKind(raw.kind); - - return { - id: Number(raw.id), - kind: details.kind, - to: details.to, - amount: details.amount, - token: details.token, - description: String(raw.description), - approvals: Number(raw.approvals), - threshold, - quorumWeight: Number(raw.quorum_weight ?? threshold), - approvalWeight: Number(raw.approval_weight ?? raw.approvals ?? 0), - totalWeight: 0, - status: mapStatus(raw.status), - deadline: formatDeadline(rawDeadline), - deadlineTs: Number(rawDeadline), - createdAt: `proposal #${Number(raw.id)}`, - proposer: shortenAddr(String(raw.proposer)), - userHasApproved: false, - approverAddresses: [], - executedAt: formatDeadline(rawDeadline), - }; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function mapDelegation(raw: any): Delegation { - const expiryTs = Number(safeBigInt(raw.expiry)); - return { - delegator: String(raw.delegator), - delegate: String(raw.delegate), - weight: Number(raw.weight ?? 0), - expiry: formatDeadline(safeBigInt(raw.expiry)), - expiryTs, - active: expiryTs * 1000 > Date.now(), - }; -} - -// Returns `owner`'s outgoing delegation (if any) alongside every delegation -// they currently receive from other owners. -export async function getDelegations(owner: string): Promise { - try { - const val = await simulateView("get_delegations", [ - nativeToScVal(owner, { type: "address" }), - ]); - const raw = scValToNative(val) as { outgoing?: unknown; incoming?: unknown[] }; - return { - outgoing: raw?.outgoing ? mapDelegation(raw.outgoing) : null, - incoming: Array.isArray(raw?.incoming) ? raw.incoming.map(mapDelegation) : [], - }; - } catch { - return { outgoing: null, incoming: [] }; - } -} - -// Returns every owner's outgoing delegation that has not yet expired. -export async function getActiveDelegations(): Promise { - try { - const val = await simulateView("get_active_delegations"); - const raw = scValToNative(val); - return Array.isArray(raw) ? raw.map(mapDelegation) : []; - } catch { - return []; - } -} - -export async function getOwners(): Promise { - const val = await simulateView("get_owners"); - return scValToNative(val) as string[]; -} - -export async function getOwnerWeight(owner: string): Promise { - try { - const val = await simulateView("get_owner_weight", [ - nativeToScVal(owner, { type: "address" }), - ]); - return Number(scValToNative(val)); - } catch { - return 1; - } -} - -export async function getOwnerWeights(): Promise> { - try { - const val = await simulateView("get_owner_weights"); - const raw = scValToNative(val) as Array<{ owner?: string; address?: string; weight?: number }>; - return (raw ?? []).map((entry) => ({ - address: String(entry.owner ?? entry.address ?? ""), - weight: Number(entry.weight ?? 0), - })); - } catch { - return []; - } -} - -export async function getTotalWeight(): Promise { - try { - const val = await simulateView("get_total_weight"); - return Number(scValToNative(val)); - } catch { - return 0; - } -} - -export async function getProposalApprovalProgress( - proposalId: number, -): Promise<{ approvalWeight: number; quorumWeight: number; totalWeight: number }> { - try { - const val = await simulateView("get_proposal_approval_progress", [ - nativeToScVal(BigInt(proposalId), { type: "u64" }), - ]); - const raw = scValToNative(val) as { - approval_weight?: number; - quorum_weight?: number; - total_weight?: number; - }; - return { - approvalWeight: Number(raw.approval_weight ?? 0), - quorumWeight: Number(raw.quorum_weight ?? 0), - totalWeight: Number(raw.total_weight ?? 0), - }; - } catch (error) { - console.error(`Failed to get approval progress for proposal ${proposalId}:`, error); - throw error; - } -} - -export async function getRequiredQuorumWeight(): Promise { - try { - const val = await simulateView("get_required_quorum_weight"); - return Number(scValToNative(val)); - } catch { - return 0; - } -} - -export async function getWeightCapPct(): Promise { - try { - const val = await simulateView("get_max_single_owner_weight_pct"); - return Number(scValToNative(val)); - } catch { - return 50; - } -} - - -export async function getThreshold(): Promise { - const val = await simulateView("get_threshold"); - return Number(scValToNative(val)); -} - -export async function getSpendingLimit(owner: string, token: string): Promise { - try { - const val = await simulateView("get_spending_limit", [ - nativeToScVal(owner, { type: "address" }), - nativeToScVal(token, { type: "address" }), - ]); - const raw = scValToNative(val); - return safeBigInt(raw); - } catch { - return -1n; // No limit record exists - } -} - -export async function getTotalProposals(): Promise { - const val = await simulateView("get_total_proposals"); - return Number(scValToNative(val)); -} - -export async function getProposalsPaged( - offset: number, - limit: number -): Promise { - const val = await simulateView("get_proposals_paged", [ - nativeToScVal(BigInt(offset), { type: "u64" }), - nativeToScVal(limit, { type: "u32" }), - ]); - const result = scValToNative(val); - return Array.isArray(result) ? result : []; -} - -function mapRecurringStatus(raw: unknown): RecurringStatus { - if (typeof raw === "string") return raw.toLowerCase() as RecurringStatus; - if (raw && typeof raw === "object") { - const key = Object.keys(raw as object)[0] ?? "Active"; - return key.toLowerCase() as RecurringStatus; - } - return "active"; -} - -function mapRecurringKind(raw: unknown): RecurringKind { - let key: string; - if (typeof raw === "string") { - key = raw; - } else if (raw && typeof raw === "object") { - key = Object.keys(raw as object)[0] ?? "FixedAmountPerPeriod"; - } else { - key = "FixedAmountPerPeriod"; - } - return key.toLowerCase() === "linearvesting" ? "linear_vesting" : "fixed_amount_per_period"; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function mapRecurringPayment(raw: any): RecurringPayment { - const endTime = Number(raw.end_time ?? 0); - const cliffTime = Number(raw.cliff_time ?? 0); - const totalCap = safeBigInt(raw.total_cap); - - return { - id: Number(raw.id), - proposer: shortenAddr(String(raw.proposer)), - recipient: shortenAddr(String(raw.recipient)), - token: shortenAddr(String(raw.token)), - amount: stroopsToDisplay(safeBigInt(raw.amount)), - intervalSecs: Number(raw.interval_secs ?? 0), - startTime: Number(raw.start_time ?? 0), - endTime: endTime > 0 ? endTime : undefined, - cliffTime: cliffTime > 0 ? cliffTime : undefined, - totalCap: totalCap > 0n ? stroopsToDisplay(totalCap) : undefined, - totalDisbursed: stroopsToDisplay(safeBigInt(raw.total_disbursed)), - lastDisbursedAt: Number(raw.last_disbursed_at ?? 0), - status: mapRecurringStatus(raw.status), - kind: mapRecurringKind(raw.kind), - category: mapCategory(raw.category), - description: String(raw.description ?? ""), - }; -} - -export async function getRecurringPayment(id: number): Promise { - const val = await simulateView("get_recurring_payment", [ - nativeToScVal(BigInt(id), { type: "u64" }), - ]); - return mapRecurringPayment(scValToNative(val)); -} - -export async function getRecurringPaymentsPaged( - offset: number, - limit: number -): Promise { - const val = await simulateView("get_recurring_payments_paged", [ - nativeToScVal(BigInt(offset), { type: "u64" }), - nativeToScVal(limit, { type: "u32" }), - ]); - const result = scValToNative(val); - return Array.isArray(result) ? result.map(mapRecurringPayment) : []; -} - -export async function getClaimableAmount(id: number): Promise { - const val = await simulateView("get_claimable_amount", [ - nativeToScVal(BigInt(id), { type: "u64" }), - ]); - return safeBigInt(scValToNative(val)); -} - -export async function getNextDisbursementTime(id: number): Promise { - const val = await simulateView("get_next_disbursement_time", [ - nativeToScVal(BigInt(id), { type: "u64" }), - ]); - return Number(scValToNative(val)); -} - -// The lifecycle proposals below submit signed transactions, so the actual -// transaction-building logic lives in submit.ts; these are thin wrappers so -// callers can reach recurring-payment governance from contract.ts too. -export { - createRecurringPaymentProposal, - createPauseRecurringProposal, - createResumeRecurringProposal, - createCancelRecurringProposal, - createModifyRecurringProposal, - disburseRecurring, -} from "./submit"; - -export async function getProposal(id: number): Promise { - const [val, thresh] = await Promise.all([ - simulateView("get_proposal", [ - nativeToScVal(BigInt(id), { type: "u64" }), - ]), - getThreshold(), - ]); - return mapProposal(scValToNative(val), thresh); -} - -export async function hasApproved( - walletAddress: string, - proposalId: number -): Promise { - const val = await simulateView("has_approved", [ - nativeToScVal(walletAddress, { type: "address" }), - nativeToScVal(BigInt(proposalId), { type: "u64" }), - ]); - return scValToNative(val) as boolean; -} - -export async function getLatestLedger(): Promise { - try { - const res = await server.getLatestLedger(); - return res.sequence; - } catch (err) { - console.error("Failed to get latest ledger:", err); - throw err; - } -} - -export async function getContractEvents(fromLedger: number): Promise { - try { - const res = await server.getEvents({ - startLedger: fromLedger, - filters: [ - { - type: "contract", - contractIds: [CONTRACT_ID], - }, - ], - limit: 100, - }); - return res.latestLedger || fromLedger; - } catch (err) { - console.error("Failed to get contract events:", err); - return fromLedger; - } -} - -async function simulateContractView( - contractId: string, - fn: string, - args: xdr.ScVal[] = [] -): Promise { - const account = await server.getAccount(SIM_SOURCE); - const contract = new Contract(contractId); - const tx = new TransactionBuilder(account, { - fee: "100", - networkPassphrase: NETWORK_PASSPHRASE, - }) - .addOperation(contract.call(fn, ...args)) - .setTimeout(30) - .build(); - - const sim = await server.simulateTransaction(tx); - if (!rpc.Api.isSimulationSuccess(sim)) { - const err = sim as rpc.Api.SimulateTransactionErrorResponse; - throw new Error(`${fn}: ${err.error ?? "simulation failed"}`); - } - return (sim as rpc.Api.SimulateTransactionSuccessResponse).result!.retval; -} - -export async function getContractXlmBalance(): Promise { - const entry = await server.getAccountEntry(CONTRACT_ID); - const stroops = BigInt(entry.balance().toString()); - return stroopsToDisplay(stroops); -} - -export async function getContractUsdcBalance(): Promise { - const usdcToken = import.meta.env.VITE_USDC_TOKEN_ADDRESS as string; - if (!usdcToken) return "N/A"; - try { - const val = await simulateContractView(usdcToken, "balance", [ - nativeToScVal(CONTRACT_ID, { type: "address" }), - ]); - const raw = scValToNative(val); - if (typeof raw === "bigint" || typeof raw === "number" || typeof raw === "string") { - return stroopsToDisplay(BigInt(raw)); - } - return "0"; - } catch { - return "—"; - } -} - -export async function getGuardian(): Promise { - try { - const val = await simulateView("get_guardian"); - const raw = scValToNative(val); - return String(raw); - } catch { - return "Unknown"; - } -} - -export async function isFrozen(): Promise { - try { - const val = await simulateView("is_frozen"); - const raw = scValToNative(val); - return Boolean(raw); - } catch { - return false; - } -} - -export async function getApprovers(proposalId: number): Promise { - try { - const owners = await getOwners(); - - const checks = await Promise.all( - owners.map(async (owner) => { - const approved = await hasApproved(owner, proposalId); - return { owner, approved }; - }) - ); - - return checks.filter((c) => c.approved).map((c) => c.owner); - } catch (error) { - console.error(`Failed to get approvers for proposal ${proposalId}:`, error); - return []; - } -} - -function parseScVal(val: unknown): unknown { - if (!val) return null; - if (typeof val === "string") { - try { - return scValToNative(xdr.ScVal.fromXDR(val, "base64")); - } catch { - return val; - } - } - try { - return scValToNative(val as xdr.ScVal); - } catch { - return val; - } -} - -function formatEventTimestamp(ledgerClosedAt?: string, ledger?: number): string { - if (ledgerClosedAt) { - const d = new Date(ledgerClosedAt); - if (!isNaN(d.getTime())) { - return d.toLocaleString(undefined, { - dateStyle: "medium", - timeStyle: "short", - }); - } - } - if (ledger) { - return `Ledger #${ledger}`; - } - return "Just now"; -} - -function resolveEventType(first: string, second: string): ProposalEventType | null { - const normFirst = first.replace(/_/g, ""); - const normSecond = second.replace(/_/g, ""); - - if (normFirst === "approved" || normFirst === "proposalapproved" || normFirst === "approve") { - return "approved"; - } - if (normFirst === "revoked" || normFirst === "proposalrevoked" || normFirst === "revoke") { - return "revoked"; - } - if (normFirst === "executed" || normFirst === "proposalexecuted" || normFirst === "execute") { - return "executed"; - } - if ( - normFirst === "ownerweightchanged" || - normFirst === "cwgt" || - normFirst === "ownerweightchange" - ) { - return "owner_weight_changed"; - } - if ( - normFirst === "recurringpaymentcreated" || - normFirst === "recurringcreated" || - normFirst === "reccreate" || - normFirst === "reccreated" || - normFirst === "recpmtcreated" || - normFirst === "schedulecreated" || - ((normFirst.includes("recurring") || normFirst.includes("schedule")) && - (normFirst.includes("create") || normSecond.includes("create"))) - ) { - return "recurring_payment_created"; - } - if ( - normFirst === "recurringpaymentdisbursed" || - normFirst === "recurringdisbursed" || - normFirst === "recdisburse" || - normFirst === "recdisbursed" || - normFirst === "recpmtdisbursed" || - normFirst === "scheduledisbursed" || - normFirst === "disbursed" || - normFirst === "disburse" || - ((normFirst.includes("recurring") || normFirst.includes("schedule")) && - (normFirst.includes("disburs") || normSecond.includes("disburs"))) - ) { - return "recurring_payment_disbursed"; } - if ( - normFirst === "recurringpaymentpaused" || - normFirst === "recurringpaused" || - normFirst === "recpause" || - normFirst === "recpaused" || - normFirst === "recpmtpaused" || - normFirst === "schedulepaused" || - normFirst === "paused" || - normFirst === "pause" || - ((normFirst.includes("recurring") || normFirst.includes("schedule")) && - (normFirst.includes("pause") || normSecond.includes("pause"))) - ) { - return "recurring_payment_paused"; - } - if ( - normFirst === "recurringpaymentcancelled" || - normFirst === "recurringpaymentcanceled" || - normFirst === "recurringcancelled" || - normFirst === "recurringcanceled" || - normFirst === "reccancel" || - normFirst === "reccancelled" || - normFirst === "reccanceled" || - normFirst === "recpmtcancelled" || - normFirst === "recpmtcanceled" || - normFirst === "schedulecancelled" || - normFirst === "schedulecanceled" || - normFirst === "cancelled" || - normFirst === "canceled" || - normFirst === "cancel" || - ((normFirst.includes("recurring") || normFirst.includes("schedule")) && - (normFirst.includes("cancel") || normSecond.includes("cancel"))) - ) { - return "recurring_payment_cancelled"; - } - return null; -} - -export async function getProposalEvents(proposalId: number): Promise { - try { - let startLedger = 1; - try { - const latest = await getLatestLedger(); - startLedger = Math.max(1, latest - 10000); - } catch { - startLedger = 1; - } - - const res = await server.getEvents({ - startLedger, - filters: [ - { - type: "contract", - contractIds: [CONTRACT_ID], - }, - ], - limit: 100, - }); - - const events: ProposalEvent[] = []; - - if (res.events && Array.isArray(res.events)) { - for (const rawEv of res.events) { - try { - const rawTopic = Array.isArray(rawEv.topic) ? rawEv.topic : [rawEv.topic]; - const topics = rawTopic.map(parseScVal); - const firstTopic = String(topics[0] ?? "").toLowerCase(); - const secondTopic = topics.length > 1 ? String(topics[1] ?? "").toLowerCase() : ""; - - const nativeValue = parseScVal(rawEv.value) as Record | null; - let eventType = resolveEventType(firstTopic, secondTopic); - if (!eventType && nativeValue && typeof nativeValue === "object") { - const innerType = String(nativeValue.event ?? nativeValue.type ?? "").toLowerCase(); - eventType = resolveEventType(innerType, ""); - } - - if (eventType && nativeValue && typeof nativeValue === "object") { - let eventPropId: number | null = null; - if (nativeValue.proposal_id !== undefined && nativeValue.proposal_id !== null) { - eventPropId = Number(nativeValue.proposal_id); - } else if (nativeValue.proposalId !== undefined && nativeValue.proposalId !== null) { - eventPropId = Number(nativeValue.proposalId); - } else if (nativeValue.proposal !== undefined && nativeValue.proposal !== null) { - eventPropId = Number(nativeValue.proposal); - } else if (nativeValue.id !== undefined && nativeValue.id !== null) { - eventPropId = Number(nativeValue.id); - } else if (nativeValue.schedule_id !== undefined && nativeValue.schedule_id !== null) { - eventPropId = Number(nativeValue.schedule_id); - } else if (nativeValue.scheduleId !== undefined && nativeValue.scheduleId !== null) { - eventPropId = Number(nativeValue.scheduleId); - } else if (nativeValue.schedule !== undefined && nativeValue.schedule !== null) { - eventPropId = Number(nativeValue.schedule); - } else if (topics.length > 1 && !isNaN(Number(topics[1]))) { - eventPropId = Number(topics[1]); - } - - if (eventPropId === proposalId) { - const rawActor = String( - nativeValue.approver ?? - nativeValue.executor ?? - nativeValue.actor ?? - nativeValue.proposer ?? - nativeValue.caller ?? - nativeValue.sender ?? - nativeValue.admin ?? - nativeValue.owner ?? - "" - ); - const actor = rawActor ? shortenAddr(rawActor) : "Unknown"; - const timestamp = formatEventTimestamp(rawEv.ledgerClosedAt, rawEv.ledger); - - const rawScheduleId = - nativeValue.schedule_id ?? - nativeValue.scheduleId ?? - nativeValue.schedule ?? - nativeValue.schedule_number ?? - (nativeValue.proposal_id !== undefined || nativeValue.proposalId !== undefined - ? nativeValue.id - : undefined) ?? - (topics.length > 2 && !isNaN(Number(topics[2])) ? Number(topics[2]) : undefined); - - const scheduleId = - rawScheduleId !== undefined && rawScheduleId !== null - ? typeof rawScheduleId === "bigint" || typeof rawScheduleId === "number" - ? Number(rawScheduleId) - : String(rawScheduleId) - : eventType.startsWith("recurring_payment") && nativeValue.id !== undefined - ? Number(nativeValue.id) - : undefined; - - const rawAmount = - nativeValue.amount ?? - nativeValue.disbursed_amount ?? - nativeValue.disbursement_amount ?? - nativeValue.payment_amount; - - let amount: string | undefined; - if (rawAmount !== undefined && rawAmount !== null) { - if (typeof rawAmount === "bigint") { - amount = stroopsToDisplay(rawAmount); - } else if (typeof rawAmount === "number") { - amount = - rawAmount >= 10_000_000 - ? stroopsToDisplay(BigInt(Math.round(rawAmount))) - : String(rawAmount); - } else if (typeof rawAmount === "string") { - if (/^\d{8,}$/.test(rawAmount)) { - amount = stroopsToDisplay(BigInt(rawAmount)); - } else { - amount = rawAmount; - } - } - } - - const rawToken = nativeValue.token ?? nativeValue.asset; - const token = rawToken - ? String(rawToken).length > 12 - ? shortenAddr(String(rawToken)) - : String(rawToken) - : undefined; - - const rawRecipient = - nativeValue.recipient ?? nativeValue.to ?? nativeValue.beneficiary; - const recipient = rawRecipient ? shortenAddr(String(rawRecipient)) : undefined; - - const reason = nativeValue.reason ? String(nativeValue.reason) : undefined; - - let details: string | undefined; - if (eventType === "recurring_payment_created") { - const parts: string[] = []; - if (scheduleId !== undefined) parts.push(`Schedule #${scheduleId}`); - if (amount) parts.push(token ? `${amount} ${token}` : amount); - if (recipient) parts.push(`to ${recipient}`); - if (parts.length > 0) details = parts.join(" · "); - } else if (eventType === "recurring_payment_disbursed") { - const parts: string[] = []; - if (scheduleId !== undefined) parts.push(`Schedule #${scheduleId}`); - if (amount) parts.push(token ? `${amount} ${token}` : amount); - if (recipient) parts.push(`to ${recipient}`); - if (parts.length > 0) details = parts.join(" · "); - } else if (eventType === "recurring_payment_paused") { - const parts: string[] = []; - if (scheduleId !== undefined) parts.push(`Schedule #${scheduleId}`); - if (reason) parts.push(reason); - if (parts.length > 0) details = parts.join(" · "); - } else if (eventType === "recurring_payment_cancelled") { - const parts: string[] = []; - if (scheduleId !== undefined) parts.push(`Schedule #${scheduleId}`); - if (reason) parts.push(reason); - if (parts.length > 0) details = parts.join(" · "); - } else if (eventType === "owner_weight_changed") { - if (nativeValue.old_weight !== undefined && nativeValue.new_weight !== undefined) { - details = `Weight: ${nativeValue.old_weight} → ${nativeValue.new_weight}`; - } - } - - events.push({ - type: eventType, - actor, - timestamp, - ledger: rawEv.ledger, - scheduleId, - amount, - token, - recipient, - details, - }); - } - } - } catch (evErr) { - console.warn("Failed to parse event record:", evErr); - } - } - } - - // Sort chronologically (oldest to newest) - events.sort((a, b) => (a.ledger ?? 0) - (b.ledger ?? 0)); - return events; - } catch (err) { - console.error(`Failed to fetch events for proposal #${proposalId}:`, err); - throw err; - } -} - -// --------------------------------------------------------------------------- -// Recurring payments -// --------------------------------------------------------------------------- - -function mapRecurringSchedule(raw: unknown): RecurringSchedule | null { - if (!raw || typeof raw !== "object") return null; - const obj = raw as Record; - - const id = Number(obj.id ?? obj.schedule_id ?? obj.schedule_number ?? 0); - if (!id) return null; - - const rawStatus = String(obj.status ?? "active").toLowerCase(); - const status: RecurringSchedule["status"] = - rawStatus === "paused" || rawStatus === "completed" || rawStatus === "cancelled" - ? rawStatus - : "active"; - - const rawAmount = obj.amount ?? obj.amount_per_period ?? obj.payment_amount ?? 0n; - let amountDisplay: string; - if (typeof rawAmount === "bigint") { - amountDisplay = stroopsToDisplay(rawAmount); - } else if (typeof rawAmount === "number") { - amountDisplay = rawAmount >= 10_000_000 ? stroopsToDisplay(BigInt(Math.round(rawAmount))) : String(rawAmount); - } else { - amountDisplay = String(rawAmount); - } - - const interval = obj.interval ?? obj.interval_secs ?? obj.cadence_secs; - const intervalNum = interval !== undefined ? Number(interval) : undefined; - - const recipient = String(obj.recipient ?? obj.to ?? ""); - const token = obj.token ? shortenAddr(String(obj.token)) : undefined; - const totalDisbursed = obj.total_disbursed !== undefined ? stroopsToDisplay(safeBigInt(obj.total_disbursed)) : "0"; - const cap = obj.cap !== undefined ? stroopsToDisplay(safeBigInt(obj.cap)) : undefined; - const nextDisbursementTs = obj.next_disbursement_ts !== undefined ? Number(safeBigInt(obj.next_disbursement_ts)) * 1000 : undefined; - const description = obj.description ? String(obj.description) : undefined; - const cliff = obj.cliff !== undefined ? Number(safeBigInt(obj.cliff)) : undefined; - const endDate = obj.end_date !== undefined ? Number(safeBigInt(obj.end_date)) : undefined; - - return { - id, - recipient, - amount: amountDisplay, - token, - cadence: obj.cadence ? String(obj.cadence) : undefined, - interval: intervalNum, - totalDisbursed, - status, - cliff, - endDate, - cap, - nextDisbursementTs, - description, - }; -} - -export async function getRecurringPayments(): Promise { - try { - const val = await simulateView("get_recurring_payments"); - const raw = scValToNative(val); - if (!Array.isArray(raw)) return []; - return raw.map(mapRecurringSchedule).filter((s): s is RecurringSchedule => s !== null); - } catch { - return []; - } -} - -const SECONDS_PER_MONTH = 2_629_743; - -export function computeMonthlyOutflow(schedules: RecurringSchedule[]): number { - let total = 0; - for (const s of schedules) { - if (s.status !== "active") continue; - const amountNum = Number.parseFloat(s.amount); - if (!Number.isFinite(amountNum) || amountNum <= 0) continue; - const intervalSecs = s.interval ?? 0; - if (intervalSecs <= 0) continue; - const periodsPerMonth = SECONDS_PER_MONTH / intervalSecs; - total += amountNum * periodsPerMonth; - } - return total; -} - -export async function getDueRecurring(): Promise { - const schedules = await getRecurringPayments(); - const now = Date.now(); - return schedules.filter((s) => { - if (s.status !== "active") return false; - if (s.nextDisbursementTs !== undefined) return now >= s.nextDisbursementTs; - return true; - }); - -// ─── Recurring Payment read wrappers ──────────────────────────────────────── - -function mapRecurringStatus(raw: unknown): RecurringScheduleStatus { - let key = ""; - if (typeof raw === "string") { - key = raw.toLowerCase(); - } else if (raw && typeof raw === "object") { - key = (Object.keys(raw as object)[0] ?? "").toLowerCase(); - } - if (key === "paused") return "paused"; - if (key === "completed") return "completed"; - if (key === "cancelled" || key === "canceled") return "cancelled"; - return "active"; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -function mapRecurringSchedule(raw: any): RecurringSchedule { - const intervalSecs = Number(safeBigInt(raw.interval ?? raw.interval_secs ?? 0)); - const amount = stroopsToDisplay(safeBigInt(raw.amount ?? 0)); - const totalDisbursed = stroopsToDisplay(safeBigInt(raw.total_disbursed ?? 0)); - const capRaw = raw.cap ?? (raw.total_cap != null && Number(safeBigInt(raw.total_cap)) > 0 ? raw.total_cap : null); - const cap = capRaw != null ? stroopsToDisplay(safeBigInt(capRaw)) : undefined; - - const rawKind = raw.kind ?? raw.payment_kind; - let kind: RecurringKind | undefined; - if (rawKind !== undefined) { - let key: string; - if (typeof rawKind === "string") { - key = rawKind; - } else if (rawKind && typeof rawKind === "object") { - key = Object.keys(rawKind as object)[0] ?? "FixedAmountPerPeriod"; - } else { - key = "FixedAmountPerPeriod"; - } - kind = key.toLowerCase() === "linearvesting" ? "linear_vesting" : "fixed_amount_per_period"; - } - - // Compute next disbursement timestamp (ms) for countdown display. - const lastDisbursedAt = Number(safeBigInt(raw.last_disbursed_at ?? 0)); - const periodsDisbursed = Number(raw.periods_disbursed ?? 0); - let nextDisbursementTs: number | undefined; - if (intervalSecs > 0) { - const startSecs = Number(safeBigInt(raw.start ?? raw.start_time ?? 0)); - const cliffSecs = - raw.cliff != null && Number(safeBigInt(raw.cliff)) > 0 - ? Number(safeBigInt(raw.cliff)) - : raw.cliff_time != null && Number(safeBigInt(raw.cliff_time)) > 0 - ? Number(safeBigInt(raw.cliff_time)) - : undefined; - - if (periodsDisbursed === 0) { - const firstAt = cliffSecs != null && cliffSecs > startSecs ? cliffSecs : startSecs; - nextDisbursementTs = firstAt * 1000; - } else if (lastDisbursedAt > 0) { - nextDisbursementTs = (lastDisbursedAt + intervalSecs) * 1000; - } - } - - return { - id: Number(safeBigInt(raw.id ?? 0)), - recipient: String(raw.recipient ?? ""), - amount, - token: raw.token ? String(raw.token) : undefined, - cadence: intervalSecs > 0 ? formatInterval(intervalSecs) : undefined, - interval: intervalSecs > 0 ? intervalSecs : undefined, - totalDisbursed, - status: mapRecurringStatus(raw.status), - kind, - cliff: - raw.cliff != null && Number(safeBigInt(raw.cliff)) > 0 - ? Number(safeBigInt(raw.cliff)) - : raw.cliff_time != null && Number(safeBigInt(raw.cliff_time)) > 0 - ? Number(safeBigInt(raw.cliff_time)) - : undefined, - endDate: - raw.end != null && Number(safeBigInt(raw.end)) > 0 - ? Number(safeBigInt(raw.end)) - : raw.end_time != null && Number(safeBigInt(raw.end_time)) > 0 - ? Number(safeBigInt(raw.end_time)) - : undefined, - cap, - nextDisbursementTs, - description: raw.description ? String(raw.description) : undefined, - }; -} - -export async function getRecurringPayment(scheduleId: number): Promise { - const val = await simulateView("get_recurring_payment", [ - nativeToScVal(BigInt(scheduleId), { type: "u64" }), - ]); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return mapRecurringSchedule(scValToNative(val) as any); -} - -export async function getRecurringPaymentsPaged( - offset: number, - limit: number -): Promise { - try { - const val = await simulateView("get_recurring_payments_paged", [ - nativeToScVal(BigInt(offset), { type: "u64" }), - nativeToScVal(limit, { type: "u32" }), - ]); - const raw = scValToNative(val); - if (!Array.isArray(raw)) return []; - return raw.map(mapRecurringSchedule).filter((s): s is RecurringSchedule => s !== null); - } catch { - return []; - } -} - -export async function getTotalRecurringPayments(): Promise { - try { - const val = await simulateView("get_total_recurring_payments"); - return Number(scValToNative(val)); - } catch { - return 0; - } -} - - const val = await simulateView("get_recurring_payments_paged", [ - nativeToScVal(BigInt(offset), { type: "u64" }), - nativeToScVal(limit, { type: "u32" }), - ]); - const raw = scValToNative(val); - if (!Array.isArray(raw)) return []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (raw as any[]).map((item) => mapRecurringSchedule(item)); -} From 62226d2b73b893adbac5b31d05136f70328cb333 Mon Sep 17 00:00:00 2001 From: Nuruddeen Abubakar Abubakar Date: Sun, 30 Aug 2026 22:43:05 +0100 Subject: [PATCH 3/3] feat: show rawAmount in proposal cards --- frontend/src/components/ProposalCard.tsx | 56 ++++++++++++++---------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/ProposalCard.tsx b/frontend/src/components/ProposalCard.tsx index 6c6c32a2..53229198 100644 --- a/frontend/src/components/ProposalCard.tsx +++ b/frontend/src/components/ProposalCard.tsx @@ -47,6 +47,14 @@ function KindSummary({ proposal }: { proposal: Proposal }) {

Send {proposal.amount} {proposal.token}

+ + {/* NAN NE RAWAMOUNT */} + {proposal.rawAmount && proposal.rawAmount !== "-" && ( +

+ Raw: {proposal.rawAmount} stroops +

+ )} +

To {proposal.to}

@@ -77,8 +85,15 @@ function KindSummary({ proposal }: { proposal: Proposal }) { Owner {proposal.to}

+ Limit {proposal.amount} for {proposal.token} +

Limit {proposal.amount} for {proposal.token}

+ {proposal.rawAmount && proposal.rawAmount!== "-" && ( +

+ Raw: {proposal.rawAmount} +

+ )} ); case "change_owner_weight": @@ -99,7 +114,6 @@ function KindSummary({ proposal }: { proposal: Proposal }) {

); default: { - // exhaustive check const _: never = proposal.kind; return null; } @@ -114,8 +128,8 @@ export function ProposalCard({ onRevoke, ownerWeights = {}, }: ProposalCardProps) { - const connected = !!walletAddress; - const showApprove = proposal.status === "pending" && !proposal.userHasApproved; + const connected =!!walletAddress; + const showApprove = proposal.status === "pending" &&!proposal.userHasApproved; const [copiedLink, setCopiedLink] = useState(false); const [copiedProposer, setCopiedProposer] = useState(false); const [awaitingConfirmation, setAwaitingConfirmation] = useState(false); @@ -134,7 +148,7 @@ export function ProposalCard({ }, [copiedProposer]); useEffect(() => { - if (proposal.status !== "ready") { + if (proposal.status!== "ready") { setAwaitingConfirmation(false); } }, [proposal.status]); @@ -162,7 +176,7 @@ export function ProposalCard({ }; return ( -
+

@@ -170,7 +184,7 @@ export function ProposalCard({

{labels.title}

- + {labels.badge}
@@ -202,13 +216,13 @@ export function ProposalCard({ onClick={() => copyAddress(proposal.proposer)} aria-label={ copiedProposer - ? `Proposer address copied for proposal #${proposal.id}` + ? `Proposer address copied for proposal #${proposal.id}` : `Copy proposer address for proposal #${proposal.id}` } className="rounded text-zinc-500 transition-colors hover:text-zinc-200 focus:outline-none focus:ring-2 focus:ring-zinc-400" - title={copiedProposer ? "Copied" : "Copy address"} + title={copiedProposer? "Copied" : "Copy address"} > - {copiedProposer ? ( + {copiedProposer? ( ) : ( @@ -236,13 +250,13 @@ export function ProposalCard({ onClick={copyProposalLink} aria-label={ copiedLink - ? `Proposal link copied for proposal #${proposal.id}` + ? `Proposal link copied for proposal #${proposal.id}` : `Copy proposal link for proposal #${proposal.id}` } className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-800 text-zinc-400 transition-colors hover:border-zinc-700 hover:text-white focus:outline-none focus:ring-2 focus:ring-zinc-400" - title={copiedLink ? "Link copied" : "Copy proposal link"} + title={copiedLink? "Link copied" : "Copy proposal link"} > - {copiedLink ? ( + {copiedLink? ( ) : ( @@ -253,7 +267,7 @@ export function ProposalCard({ role="note" aria-label={`Category: ${proposal.category}`} className={`text-xs px-2 py-0.5 rounded-full font-mono capitalize ${ - CATEGORY_STYLES[proposal.category] ?? "bg-zinc-800 text-zinc-400" + CATEGORY_STYLES[proposal.category]?? "bg-zinc-800 text-zinc-400" }`} > {proposal.category} @@ -264,14 +278,10 @@ export function ProposalCard({
- {/* ApprovalBar uses the proposal's snapshotted quorumWeight fixed at - creation time — not the live totalWeight. This ensures the progress - bar reflects the original approval requirement even if owner weights - change after the proposal is created. */}
@@ -283,12 +293,12 @@ export function ProposalCard({ onClick={() => onApprove(proposal.id)} aria-label={ connected - ? `Approve proposal #${proposal.id}` + ? `Approve proposal #${proposal.id}` : `Connect and approve proposal #${proposal.id}` } className="text-xs bg-emerald-600 hover:bg-emerald-500 text-white px-3 py-1 rounded-lg transition-colors font-medium disabled:opacity-50 focus:ring-2 focus:ring-zinc-400 focus:outline-none" > - {connected ? "Approve" : "Connect & Approve"} + {connected? "Approve" : "Connect & Approve"} )} @@ -305,7 +315,7 @@ export function ProposalCard({ )} - {connected && proposal.status === "ready" && !awaitingConfirmation && ( + {connected && proposal.status === "ready" &&!awaitingConfirmation && (