From 0a2c7badd89c825e091cb1e0df3ccc6b349b71c6 Mon Sep 17 00:00:00 2001 From: vic-Gray Date: Fri, 26 Jun 2026 14:44:19 +0000 Subject: [PATCH 1/4] feat: multisig events, pytest infra, WASM sizes, propose withdrawal UI #130 - Extend Stellar listener to sync multisig proposal events #142 - Set up pytest infrastructure for ai_agent #140 - Track and report WASM binary sizes on PRs #131 - Build Propose Withdrawal UI on treasury page --- .github/workflows/contracts-ci.yml | 49 +++++ apps/ai_agent/pyproject.toml | 13 ++ apps/ai_agent/tests/conftest.py | 30 +++ apps/backend/src/app.ts | 2 + apps/backend/src/db/schema.ts | 35 +++ apps/backend/src/index.ts | 10 +- apps/backend/src/routes/treasury.ts | 41 ++++ apps/backend/src/services/stellarListener.ts | 201 ++++++++++++++++-- apps/web/src/app/app/treasury/page.tsx | 30 ++- .../treasury/ProposeWithdrawalModal.tsx | 174 +++++++++++++++ 10 files changed, 561 insertions(+), 24 deletions(-) create mode 100644 apps/ai_agent/tests/conftest.py create mode 100644 apps/backend/src/routes/treasury.ts create mode 100644 apps/web/src/components/treasury/ProposeWithdrawalModal.tsx diff --git a/.github/workflows/contracts-ci.yml b/.github/workflows/contracts-ci.yml index 41dc124..694309e 100644 --- a/.github/workflows/contracts-ci.yml +++ b/.github/workflows/contracts-ci.yml @@ -56,6 +56,55 @@ jobs: - name: cargo build (release wasm32) run: cargo build -p token_transfer --target wasm32-unknown-unknown --release + - name: Report WASM binary sizes + if: always() + env: + GH_TOKEN: ${{ github.token }} + run: | + set -e + + # Collect sizes for all release WASMs + WASM_DIR="target/wasm32-unknown-unknown/release" + TABLE="| Contract | Size (bytes) | Size (KB) |\n|---|---|---|\n" + THRESHOLD_BYTES=102400 # 100 KB + + FAILED=0 + for wasm in "$WASM_DIR"/*.wasm; do + [ -f "$wasm" ] || continue + name=$(basename "$wasm") + bytes=$(wc -c < "$wasm") + kb=$(echo "scale=2; $bytes / 1024" | bc) + TABLE="${TABLE}| \`${name}\` | ${bytes} | ${kb} KB |\n" + if [ "$bytes" -gt "$THRESHOLD_BYTES" ]; then + echo "::error file=${name}::WASM size ${bytes} bytes exceeds ${THRESHOLD_BYTES} byte limit" + FAILED=1 + fi + done + + # Write to job summary + printf "## WASM Binary Sizes\n\n${TABLE}\n" >> "$GITHUB_STEP_SUMMARY" + + # Update-in-place PR comment + if [ "${{ github.event_name }}" = "pull_request" ]; then + BODY=$(printf "\n## WASM Binary Sizes\n\n${TABLE}") + # Find existing comment + COMMENT_ID=$(gh api \ + repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \ + --jq '.[] | select(.body | startswith("")) | .id' \ + | head -1) + if [ -n "$COMMENT_ID" ]; then + gh api --method PATCH \ + repos/${{ github.repository }}/issues/comments/"$COMMENT_ID" \ + -f body="$BODY" + else + gh api --method POST \ + repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \ + -f body="$BODY" + fi + fi + + exit $FAILED + clippy: name: cargo clippy (wasm32) runs-on: ubuntu-latest diff --git a/apps/ai_agent/pyproject.toml b/apps/ai_agent/pyproject.toml index 02d9e1d..e24c9f4 100644 --- a/apps/ai_agent/pyproject.toml +++ b/apps/ai_agent/pyproject.toml @@ -11,6 +11,14 @@ dependencies = [ "weaviate-client>=4.0.0", ] +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "httpx>=0.27.0", + "pytest-mock>=3.14.0", + "pytest-cov>=5.0.0", +] + [dependency-groups] dev = [ "pytest>=8.0.0", @@ -19,3 +27,8 @@ dev = [ [tool.pytest.ini_options] pythonpath = ["."] +testpaths = ["tests"] +addopts = "--cov=. --cov-report=term-missing --cov-config=pyproject.toml" + +[tool.coverage.run] +omit = ["tests/*"] diff --git a/apps/ai_agent/tests/conftest.py b/apps/ai_agent/tests/conftest.py new file mode 100644 index 0000000..fb7f1cf --- /dev/null +++ b/apps/ai_agent/tests/conftest.py @@ -0,0 +1,30 @@ +"""Shared pytest fixtures for the ai_agent test suite.""" + +import os +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture(autouse=True) +def set_openai_key(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure OPENAI_API_KEY is always set so _openai_client() doesn't 500.""" + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + + +@pytest.fixture() +def client() -> TestClient: + """FastAPI TestClient for the main app.""" + from main import app + return TestClient(app) + + +@pytest.fixture() +def mock_openai(mocker): + """Patch the OpenAI client used inside main.py.""" + return mocker.patch("main.OpenAI") + + +@pytest.fixture() +def mock_weaviate(mocker): + """Patch weaviate.connect_to_local used inside main.py.""" + return mocker.patch("main.weaviate.connect_to_local") diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 70bb0f5..45e4d8b 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -10,6 +10,7 @@ import { conversationsRouter } from './routes/conversations.js'; import { messagesRouter } from './routes/messages.js'; import { usersRouter } from './routes/users.js'; import { devicesRouter } from './routes/devices.js'; +import { treasuryRouter } from './routes/treasury.js'; import { requireAuth, type AuthRequest } from './middleware/auth.js'; const packageJson = JSON.parse( @@ -49,6 +50,7 @@ app.use('/conversations', conversationsRouter); app.use('/messages', messagesRouter); app.use('/users', usersRouter); app.use('/devices', devicesRouter); +app.use('/treasury', treasuryRouter); app.get('/me', requireAuth, (req, res) => { res.json({ user: (req as AuthRequest).auth }); diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index e85eff7..a7ee397 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -160,6 +160,40 @@ export const userDevices = pgTable( ], ); +// ─── Treasury Proposals (#130) ──────────────────────────────────────────────── +// +// Synced from GROUP_TREASURY_CONTRACT_ID events by the Stellar listener. +// Idempotent upsert on (contractId, proposalId). + +export const treasuryProposalStatusEnum = pgEnum('treasury_proposal_status', [ + 'active', + 'approved', + 'rejected', + 'executed', + 'expired', +]); + +export const treasuryProposals = pgTable( + 'treasury_proposals', + { + id: uuid('id').primaryKey().defaultRandom(), + contractId: text('contract_id').notNull(), + proposalId: text('proposal_id').notNull(), + conversationId: uuid('conversation_id').references(() => conversations.id, { + onDelete: 'set null', + }), + status: treasuryProposalStatusEnum('status').notNull().default('active'), + approvalsCount: integer('approvals_count').notNull().default(0), + rejectionsCount: integer('rejections_count').notNull().default(0), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => [uniqueIndex('treasury_proposals_contract_proposal_idx').on(table.contractId, table.proposalId)], +); + +export type TreasuryProposal = typeof treasuryProposals.$inferSelect; +export type NewTreasuryProposal = typeof treasuryProposals.$inferInsert; + // ─── Relations ──────────────────────────────────────────────────────────────── export const usersRelations = relations(users, ({ many }) => ({ @@ -178,6 +212,7 @@ export const conversationsRelations = relations(conversations, ({ many }) => ({ members: many(conversationMembers), messages: many(messages), transfers: many(tokenTransfers), + treasuryProposals: many(treasuryProposals), })); export const conversationMembersRelations = relations(conversationMembers, ({ one }) => ({ diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 63ac88b..b559e61 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -12,7 +12,7 @@ import { app } from './app.js'; import { redis as appRedis } from './lib/redis.js'; import { setSocketServer } from './lib/socket.js'; import { setOnline, setOffline, refreshPresence } from './services/presence.js'; -import { buildRpcFetcher, runForever as runStellarListener } from './services/stellarListener.js'; +import { buildRpcFetcher, buildTreasuryRpcFetcher, runForever as runStellarListener } from './services/stellarListener.js'; import { loadEnv } from './config.js'; dotenv.config(); @@ -125,12 +125,20 @@ void attachRedisAdapter(); // chain connection logs but doesn't crash the API. const stellarRpcUrl = process.env['STELLAR_RPC_URL']; const tokenTransferContractId = process.env['TOKEN_TRANSFER_CONTRACT_ID']; +const groupTreasuryContractId = process.env['GROUP_TREASURY_CONTRACT_ID']; + if (stellarRpcUrl && tokenTransferContractId) { void runStellarListener({ fetchEvents: buildRpcFetcher({ rpcUrl: stellarRpcUrl, contractId: tokenTransferContractId, }), + ...(groupTreasuryContractId && { + fetchTreasuryEvents: buildTreasuryRpcFetcher({ + rpcUrl: stellarRpcUrl, + contractId: groupTreasuryContractId, + }), + }), }); } else { console.log( diff --git a/apps/backend/src/routes/treasury.ts b/apps/backend/src/routes/treasury.ts new file mode 100644 index 0000000..ca68f87 --- /dev/null +++ b/apps/backend/src/routes/treasury.ts @@ -0,0 +1,41 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { requireAuth, type AuthRequest } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; + +export const treasuryRouter = Router(); + +treasuryRouter.use(requireAuth); + +const TTL_LEDGERS: Record = { + '24h': 17280, // ~24 h at 5 s/ledger + '72h': 51840, + '7d': 120960, +}; + +const proposeSchema = z.object({ + amount: z.number().positive(), + token: z.string().min(1), + recipient: z.string().regex(/^G[A-Z2-7]{55}$/, 'Invalid Stellar public key'), + ttl: z.enum(['24h', '72h', '7d']), +}); + +/** + * POST /treasury/propose + * Body: { amount, token, recipient, ttl } + * Stub: records intent and returns the ledger count for TTL. + */ +treasuryRouter.post('/propose', validate(proposeSchema), async (req, res) => { + const { amount, token, recipient, ttl } = req.body as z.infer; + const auth = (req as AuthRequest).auth!; + + // In production this would submit a multisig proposal transaction via Soroban SDK. + // For now, return the resolved ledger TTL so the frontend can display it. + res.status(201).json({ + proposer: auth.userId, + amount, + token, + recipient, + ttlLedgers: TTL_LEDGERS[ttl], + }); +}); diff --git a/apps/backend/src/services/stellarListener.ts b/apps/backend/src/services/stellarListener.ts index afefacf..a3ccb75 100644 --- a/apps/backend/src/services/stellarListener.ts +++ b/apps/backend/src/services/stellarListener.ts @@ -1,27 +1,25 @@ /** - * Stellar event listener for `token_transfer` (#46). + * Stellar event listener for `token_transfer` (#46) and `group_treasury` multisig (#130). * - * Subscribes to contract events emitted by the `token_transfer` Soroban - * contract and writes each into the `token_transfers` table. The listener: + * Subscribes to contract events emitted by the `token_transfer` and + * `group_treasury` Soroban contracts. The listener: * - * - Polls Soroban RPC `getEvents` on a short interval (cursor-based), - * which is the supported pattern in stellar-sdk's `rpc` module today. + * - Polls Soroban RPC `getEvents` on a short interval (cursor-based). * - Reconnects automatically after a transient failure with exponential * backoff capped at 30 seconds. - * - Upserts on the unique `tx_hash` so a reconnect that re-reads a page - * of events produces no duplicates. + * - Upserts on the unique `tx_hash` / `(contractId, proposalId)` so + * reconnects that re-read a page produce no duplicates. + * - After each treasury proposal DB update, emits a + * `treasury_proposal_updated` Socket.IO event to the relevant room. * - Logs errors via the standard backend logger but never rethrows out * of `runForever`, so the API server stays up even if the chain is * unreachable. - * - * The actual fetch is wrapped behind a `fetchEvents` dependency so the - * unit tests under `__tests__/stellarListener.test.ts` can drive the - * loop deterministically without hitting Soroban RPC. */ import { rpc } from '@stellar/stellar-sdk'; import { db } from '../db/index.js'; -import { tokenTransfers, messages, conversations, users } from '../db/schema.js'; +import { tokenTransfers, messages, conversations, users, treasuryProposals } from '../db/schema.js'; import { eq, sql } from 'drizzle-orm'; +import { getSocketServer } from '../lib/socket.js'; const DEFAULT_POLL_INTERVAL_MS = 5_000; const DEFAULT_BACKOFF_BASE_MS = 1_000; @@ -44,6 +42,27 @@ export interface StellarTransferEvent { cursor: string; } +// ── Treasury multisig event types (#130) ───────────────────────────────────── + +export type TreasuryProposalStatus = 'active' | 'approved' | 'rejected' | 'executed' | 'expired'; + +export interface TreasuryProposalEvent { + /** The contract that emitted the event. */ + contractId: string; + /** Soroban event type name, e.g. "proposal_created". */ + eventType: + | 'proposal_created' + | 'proposal_approved' + | 'proposal_rejected' + | 'proposal_executed' + | 'proposal_expired'; + proposalId: string; + approvalsCount?: number | undefined; + rejectionsCount?: number | undefined; + /** Cursor token for the next `fetchTreasuryEvents` call. */ + cursor: string; +} + export interface StellarListenerDeps { /** Optional logger; defaults to a console wrapper. */ log?: { @@ -51,11 +70,14 @@ export interface StellarListenerDeps { warn: (msg: string, ctx?: unknown) => void; error: (msg: string, ctx?: unknown) => void; }; - /** Fetches the next page of events starting at `cursor`. Must throw on - * network / RPC failures so the listener can apply backoff. */ + /** Fetches the next page of token-transfer events starting at `cursor`. */ fetchEvents: (cursor: string | null) => Promise; + /** Fetches the next page of treasury proposal events starting at `cursor`. */ + fetchTreasuryEvents?: (cursor: string | null) => Promise; /** Persistence layer; swapped out in tests. */ persistEvent?: (event: StellarTransferEvent) => Promise; + /** Treasury event persistence; swapped out in tests. */ + persistTreasuryEvent?: (event: TreasuryProposalEvent) => Promise; /** Pause between successful polls (default 5s). */ pollIntervalMs?: number; /** Initial backoff after a failure (doubles up to `backoffMaxMs`). */ @@ -134,6 +156,62 @@ async function defaultPersistEvent(event: StellarTransferEvent): Promise { }); } +/** + * Default treasury proposal persistence (#130). + * Upserts on (contractId, proposalId), then emits treasury_proposal_updated + * to the relevant Socket.IO room. + */ +async function defaultPersistTreasuryEvent(event: TreasuryProposalEvent): Promise { + const statusMap: Record = { + proposal_created: 'active', + proposal_approved: 'approved', + proposal_rejected: 'rejected', + proposal_executed: 'executed', + proposal_expired: 'expired', + }; + + const newStatus = statusMap[event.eventType]; + + const [row] = await db + .insert(treasuryProposals) + .values({ + contractId: event.contractId, + proposalId: event.proposalId, + status: newStatus, + approvalsCount: event.approvalsCount ?? 0, + rejectionsCount: event.rejectionsCount ?? 0, + }) + .onConflictDoUpdate({ + target: [treasuryProposals.contractId, treasuryProposals.proposalId], + set: { + status: newStatus, + approvalsCount: + event.approvalsCount !== undefined + ? event.approvalsCount + : sql`${treasuryProposals.approvalsCount}`, + rejectionsCount: + event.rejectionsCount !== undefined + ? event.rejectionsCount + : sql`${treasuryProposals.rejectionsCount}`, + updatedAt: sql`now()`, + }, + }) + .returning(); + + if (!row) return; + + const payload = { + proposalId: row.proposalId, + status: row.status, + approvalsCount: row.approvalsCount, + rejectionsCount: row.rejectionsCount, + }; + + // Emit to the linked conversation room if known; fall back to a contract-scoped room. + const room = row.conversationId ?? `treasury:${row.contractId}`; + getSocketServer()?.to(room).emit('treasury_proposal_updated', payload); +} + /** * Run the listener loop until `signal` aborts (or process exit). Never * throws — RPC / DB errors are logged and the loop backs off. @@ -141,11 +219,13 @@ async function defaultPersistEvent(event: StellarTransferEvent): Promise { export async function runForever(deps: StellarListenerDeps): Promise { const log = deps.log ?? consoleLogger; const persist = deps.persistEvent ?? defaultPersistEvent; + const persistTreasury = deps.persistTreasuryEvent ?? defaultPersistTreasuryEvent; const pollMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; const backoffBase = deps.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS; const backoffMax = deps.backoffMaxMs ?? DEFAULT_BACKOFF_MAX_MS; let cursor: string | null = null; + let treasuryCursor: string | null = null; let consecutiveFailures = 0; log.info('listener starting'); @@ -160,9 +240,6 @@ export async function runForever(deps: StellarListenerDeps): Promise { await persist(event); cursor = event.cursor; } catch (err) { - // Per-event failure: log and move on so one bad row doesn't - // freeze the cursor. cursor is NOT advanced here so the next - // poll retries. log.warn('failed to persist event', { txHash: event.txHash, error: err instanceof Error ? err.message : String(err), @@ -170,6 +247,22 @@ export async function runForever(deps: StellarListenerDeps): Promise { } } + // Poll treasury events when a fetcher is provided (#130). + if (deps.fetchTreasuryEvents) { + const treasuryEvents = await deps.fetchTreasuryEvents(treasuryCursor); + for (const event of treasuryEvents) { + try { + await persistTreasury(event); + treasuryCursor = event.cursor; + } catch (err) { + log.warn('failed to persist treasury event', { + proposalId: event.proposalId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + await wait(pollMs, deps.signal); } catch (err) { consecutiveFailures += 1; @@ -267,3 +360,77 @@ export function buildRpcFetcher(opts: { }); }; } + +/** + * Build a fetcher for GROUP_TREASURY_CONTRACT_ID multisig proposal events (#130). + * Listens for: proposal_created, proposal_approved, proposal_rejected, + * proposal_executed, proposal_expired. + */ +export function buildTreasuryRpcFetcher(opts: { + rpcUrl: string; + contractId: string; + pageSize?: number; +}): NonNullable { + const server = new rpc.Server(opts.rpcUrl, { allowHttp: opts.rpcUrl.startsWith('http://') }); + const pageSize = opts.pageSize ?? 100; + + const TREASURY_TOPICS = [ + 'proposal_created', + 'proposal_approved', + 'proposal_rejected', + 'proposal_executed', + 'proposal_expired', + ] as const; + + type EventType = (typeof TREASURY_TOPICS)[number]; + + type RpcEvent = { + contractId?: string; + topic?: string[]; + value?: { id?: string | number; approvals?: number; rejections?: number }; + pagingToken?: string; + }; + + const eventServer = server as unknown as { + getEvents: (request: { + startLedger: undefined; + cursor: string | undefined; + filters: Array<{ type: 'contract'; contractIds: string[]; topics: string[][] }>; + limit: number; + }) => Promise<{ events?: RpcEvent[] }>; + }; + + return async (cursor) => { + const response = await eventServer.getEvents({ + startLedger: undefined, + cursor: cursor ?? undefined, + filters: [ + { + type: 'contract', + contractIds: [opts.contractId], + topics: [TREASURY_TOPICS as unknown as string[]], + }, + ], + limit: pageSize, + }); + + const events = response.events ?? []; + + return events + .filter((e) => { + const topic = e.topic?.[0]; + return e.value?.id != null && TREASURY_TOPICS.includes(topic as EventType); + }) + .map((e) => { + const eventType = e.topic![0] as EventType; + return { + contractId: e.contractId ?? opts.contractId, + eventType, + proposalId: String(e.value!.id), + approvalsCount: e.value?.approvals, + rejectionsCount: e.value?.rejections, + cursor: e.pagingToken ?? '', + } satisfies TreasuryProposalEvent; + }); + }; +} diff --git a/apps/web/src/app/app/treasury/page.tsx b/apps/web/src/app/app/treasury/page.tsx index 94af279..cd173ea 100644 --- a/apps/web/src/app/app/treasury/page.tsx +++ b/apps/web/src/app/app/treasury/page.tsx @@ -1,8 +1,11 @@ "use client"; -import React from "react"; +import React, { useState } from "react"; +import { ProposeWithdrawalModal } from "@/components/treasury/ProposeWithdrawalModal"; export default function TreasuryPage() { + const [isModalOpen, setIsModalOpen] = useState(false); + const assets = [ { name: "Stellar Lumens", symbol: "XLM", balance: "420,500 XLM", value: "$42,050.00", percentage: "65%", color: "bg-accent" }, { name: "USD Coin", symbol: "USDC", balance: "18,200 USDC", value: "$18,200.00", percentage: "28%", color: "bg-emerald-500" }, @@ -18,13 +21,28 @@ export default function TreasuryPage() { return (
{/* Page Header */} -
-

- Treasury Vault -

-

Manage and track your DAO's multi-signature assets on Stellar.

+
+
+

+ Treasury Vault +

+

Manage and track your DAO's multi-signature assets on Stellar.

+
+
+ setIsModalOpen(false)} + onSuccess={() => { /* list refresh hook can be added here */ }} + /> + {/* Summary Cards */}
diff --git a/apps/web/src/components/treasury/ProposeWithdrawalModal.tsx b/apps/web/src/components/treasury/ProposeWithdrawalModal.tsx new file mode 100644 index 0000000..7868886 --- /dev/null +++ b/apps/web/src/components/treasury/ProposeWithdrawalModal.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { Modal } from "@/components/ui/Modal"; +import { apiFetch } from "@/lib/api"; +import { useToast } from "@/lib/useToast"; + +const STELLAR_ADDRESS_RE = /^G[A-Z2-7]{55}$/; + +const TTL_OPTIONS = [ + { label: "24 hours", value: "24h" }, + { label: "72 hours", value: "72h" }, + { label: "7 days", value: "7d" }, +] as const; + +type TTL = (typeof TTL_OPTIONS)[number]["value"]; + +interface Props { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +} + +export function ProposeWithdrawalModal({ isOpen, onClose, onSuccess }: Props) { + const { success, error: toastError } = useToast(); + + const [amount, setAmount] = useState(""); + const [token, setToken] = useState("XLM"); + const [recipient, setRecipient] = useState(""); + const [ttl, setTtl] = useState("24h"); + const [recipientError, setRecipientError] = useState(""); + const [loading, setLoading] = useState(false); + + function validateRecipient(value: string): string { + if (!value) return "Recipient address is required"; + if (!STELLAR_ADDRESS_RE.test(value)) return "Must be a valid Stellar address (G...)"; + return ""; + } + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + + const recipientErr = validateRecipient(recipient); + if (recipientErr) { + setRecipientError(recipientErr); + return; + } + + const parsedAmount = parseFloat(amount); + if (!parsedAmount || parsedAmount <= 0) return; // blocked by input[min] + + setLoading(true); + try { + const token_stored = typeof window !== "undefined" ? window.localStorage.getItem("clicked.jwt") : null; + const res = await apiFetch("/treasury/propose", { + method: "POST", + body: JSON.stringify({ amount: parsedAmount, token, recipient, ttl }), + headers: token_stored ? { Authorization: `Bearer ${token_stored}` } : {}, + }); + + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + toastError(body.error ?? "Failed to submit proposal"); + return; + } + + success("Withdrawal proposal submitted successfully"); + onSuccess(); + onClose(); + // Reset + setAmount(""); + setToken("XLM"); + setRecipient(""); + setTtl("24h"); + } catch { + toastError("Network error — please try again"); + } finally { + setLoading(false); + } + } + + return ( + +
+ {/* Amount */} +
+ + setAmount(e.target.value)} + placeholder="0.00" + className="w-full rounded-lg bg-white/5 border border-white/10 px-3 py-2 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-accent" + /> +
+ + {/* Token */} +
+ + +
+ + {/* Recipient */} +
+ + { + setRecipient(e.target.value); + if (recipientError) setRecipientError(validateRecipient(e.target.value)); + }} + onBlur={() => setRecipientError(validateRecipient(recipient))} + placeholder="G..." + className={`w-full rounded-lg bg-white/5 border px-3 py-2 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-accent ${ + recipientError ? "border-rose-500" : "border-white/10" + }`} + /> + {recipientError && ( +

{recipientError}

+ )} +
+ + {/* TTL */} +
+ + +
+ + +
+
+ ); +} From 31193eb36ecd5661b8fde453d4a96c3e20adb30d Mon Sep 17 00:00:00 2001 From: vic-Gray Date: Fri, 26 Jun 2026 15:05:29 +0000 Subject: [PATCH 2/4] updated --- .github/workflows/contracts-ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/contracts-ci.yml b/.github/workflows/contracts-ci.yml index 694309e..e82b011 100644 --- a/.github/workflows/contracts-ci.yml +++ b/.github/workflows/contracts-ci.yml @@ -24,6 +24,9 @@ jobs: test-and-build: name: cargo test + build (wasm32) runs-on: ubuntu-latest + permissions: + contents: read + issues: write defaults: run: @@ -56,6 +59,9 @@ jobs: - name: cargo build (release wasm32) run: cargo build -p token_transfer --target wasm32-unknown-unknown --release + - name: Install GitHub CLI + run: sudo apt-get update && sudo apt-get install -y gh + - name: Report WASM binary sizes if: always() env: From 4752a6b02409b1e59501244ff23d60e1a019a9ff Mon Sep 17 00:00:00 2001 From: vic-Gray Date: Fri, 26 Jun 2026 15:11:00 +0000 Subject: [PATCH 3/4] updated --- apps/backend/src/db/schema.ts | 4 +++- apps/backend/src/index.ts | 6 +++++- apps/backend/src/routes/treasury.ts | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index a7ee397..17f4cd0 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -188,7 +188,9 @@ export const treasuryProposals = pgTable( createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, - (table) => [uniqueIndex('treasury_proposals_contract_proposal_idx').on(table.contractId, table.proposalId)], + (table) => [ + uniqueIndex('treasury_proposals_contract_proposal_idx').on(table.contractId, table.proposalId), + ], ); export type TreasuryProposal = typeof treasuryProposals.$inferSelect; diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index b559e61..f8d60b7 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -12,7 +12,11 @@ import { app } from './app.js'; import { redis as appRedis } from './lib/redis.js'; import { setSocketServer } from './lib/socket.js'; import { setOnline, setOffline, refreshPresence } from './services/presence.js'; -import { buildRpcFetcher, buildTreasuryRpcFetcher, runForever as runStellarListener } from './services/stellarListener.js'; +import { + buildRpcFetcher, + buildTreasuryRpcFetcher, + runForever as runStellarListener, +} from './services/stellarListener.js'; import { loadEnv } from './config.js'; dotenv.config(); diff --git a/apps/backend/src/routes/treasury.ts b/apps/backend/src/routes/treasury.ts index ca68f87..660f768 100644 --- a/apps/backend/src/routes/treasury.ts +++ b/apps/backend/src/routes/treasury.ts @@ -8,7 +8,7 @@ export const treasuryRouter = Router(); treasuryRouter.use(requireAuth); const TTL_LEDGERS: Record = { - '24h': 17280, // ~24 h at 5 s/ledger + '24h': 17280, // ~24 h at 5 s/ledger '72h': 51840, '7d': 120960, }; From b1adff98269345d011a63a818f4c9f27085a6b2b Mon Sep 17 00:00:00 2001 From: vic-Gray Date: Fri, 26 Jun 2026 15:23:17 +0000 Subject: [PATCH 4/4] updated --- .github/workflows/contracts-ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/contracts-ci.yml b/.github/workflows/contracts-ci.yml index e82b011..dcdec38 100644 --- a/.github/workflows/contracts-ci.yml +++ b/.github/workflows/contracts-ci.yml @@ -97,15 +97,15 @@ jobs: COMMENT_ID=$(gh api \ repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \ --jq '.[] | select(.body | startswith("")) | .id' \ - | head -1) + 2>/dev/null | head -1) || true if [ -n "$COMMENT_ID" ]; then gh api --method PATCH \ repos/${{ github.repository }}/issues/comments/"$COMMENT_ID" \ - -f body="$BODY" + -f body="$BODY" || true else gh api --method POST \ repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \ - -f body="$BODY" + -f body="$BODY" || true fi fi