fix treasury signer withdrawal workflow

This commit is contained in:
defiQUG
2026-09-13 20:19:42 -07:00
parent a03417be98
commit a9a83665e5
22 changed files with 232 additions and 119 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
DATABASE_URL=postgresql://solace_user:[email protected]:5432/solace_treasury
# Chain 138 Configuration
RPC_URL=http://192.168.11.235:8545
RPC_URL=http://192.168.11.211:8545
CHAIN_ID=138
CONTRACT_ADDRESS=0x96bF8cd30C4f0e22fa33469f8e2C23AA3faF525C
+5 -5
View File
@@ -12,18 +12,18 @@
"indexer:start": "tsx src/indexer/indexer.ts"
},
"dependencies": {
"@trpc/server": "^10.45.0",
"@trpc/client": "^10.45.0",
"@trpc/server": "^10.45.0",
"dotenv": "^16.3.1",
"drizzle-orm": "^0.29.0",
"postgres": "^3.4.0",
"viem": "^2.0.0",
"zod": "^3.22.4",
"dotenv": "^16.3.1"
"viem": "^2.56.5",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20.10.0",
"drizzle-kit": "^0.20.0",
"tsx": "^4.7.0",
"typescript": "5.3.3"
"typescript": "^5.9.3"
}
}
+52 -16
View File
@@ -7,6 +7,7 @@ import { treasuryRouter } from "./api/treasury";
import { exportRouter } from "./api/exports";
import { fetchTreasuryLedger } from "./api/ledger";
import { ensureTreasury } from "./db/bootstrap";
import { createPublicClient, http as viemHttp, defineChain, isAddress, verifyMessage } from "viem";
const port = Number(process.env.PORT || 3001);
const nodeEnv = process.env.NODE_ENV || "development";
@@ -17,6 +18,12 @@ const clientRpcUrl =
"https://rpc-http-pub.d-bis.org";
const chainId = process.env.CHAIN_ID || "138";
const contractAddress = process.env.CONTRACT_ADDRESS || "";
const allowedOrigin = process.env.CORS_ORIGIN || "https://treasury.d-bis.org";
const chain138 = defineChain({ id: 138, name: "Solace Chain 138", nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, rpcUrls: { default: { http: [rpcUrl] } } });
const publicClient = createPublicClient({ chain: chain138, transport: viemHttp(rpcUrl) });
const ownerAbi = [{ type: "function", name: "isOwner", stateMutability: "view", inputs: [{ name: "owner", type: "address" }], outputs: [{ type: "bool" }] }] as const;
const requestWindows = new Map<string, { started: number; count: number }>();
const API_RATE_LIMIT = 120;
for (const envVar of ["DATABASE_URL", "RPC_URL", "CHAIN_ID"] as const) {
if (!process.env[envVar]) {
@@ -55,11 +62,11 @@ async function checkRpc(): Promise<boolean> {
function sendJson(res: http.ServerResponse, status: number, body: unknown) {
const payload = JSON.stringify(body);
res.writeHead(status, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
res.writeHead(status, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": allowedOrigin,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Headers": "Content-Type, X-Wallet-Address, X-Wallet-Message, X-Wallet-Signature",
});
res.end(payload);
}
@@ -79,11 +86,11 @@ function sendText(
body: string,
contentType: string
) {
res.writeHead(status, {
"Content-Type": contentType,
"Access-Control-Allow-Origin": "*",
res.writeHead(status, {
"Content-Type": contentType,
"Access-Control-Allow-Origin": allowedOrigin,
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Headers": "Content-Type, X-Wallet-Address, X-Wallet-Message, X-Wallet-Signature",
});
res.end(body);
}
@@ -91,9 +98,9 @@ function sendText(
const server = http.createServer(async (req, res) => {
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Origin": allowedOrigin,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Headers": "Content-Type, X-Wallet-Address, X-Wallet-Message, X-Wallet-Signature",
});
res.end();
return;
@@ -101,6 +108,14 @@ const server = http.createServer(async (req, res) => {
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
if (url.pathname.startsWith("/api/")) {
const key = req.socket.remoteAddress || "unknown";
const now = Date.now();
const window = requestWindows.get(key);
if (!window || now - window.started >= 60_000) requestWindows.set(key, { started: now, count: 1 });
else if (++window.count > API_RATE_LIMIT) { res.writeHead(429, { "Retry-After": "60" }); res.end(JSON.stringify({ error: "Rate limit exceeded" })); return; }
}
try {
if (
req.method === "GET" &&
@@ -111,8 +126,6 @@ const server = http.createServer(async (req, res) => {
status: dbOk && rpcOk ? "ok" : "degraded",
environment: nodeEnv,
chainId: Number(chainId),
rpcUrl,
clientRpcUrl,
contractAddress: contractAddress || null,
database: dbOk,
rpc: rpcOk,
@@ -129,12 +142,31 @@ const server = http.createServer(async (req, res) => {
return;
}
async function authorizedTreasury(treasuryId: string) {
const wallet = req.headers["x-wallet-address"];
const signature = req.headers["x-wallet-signature"];
const message = req.headers["x-wallet-message"];
const rows = await treasuryRouter.getById(treasuryId);
const treasury = rows[0];
if (!treasury || typeof wallet !== "string" || typeof signature !== "string" || typeof message !== "string" || !isAddress(wallet) || !contractAddress || treasury.mainWallet !== contractAddress.toLowerCase()) { console.warn(JSON.stringify({ event: "treasury_authorization_denied", treasuryId, ip: req.socket.remoteAddress })); return null; }
const timestamp = Number(message.match(/\nTimestamp: (\d+)$/)?.[1]);
if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp) > 300000) return null;
if (!await verifyMessage({ address: wallet, message, signature: signature as `0x${string}` })) return null;
const owner = await publicClient.readContract({ address: contractAddress as `0x${string}`, abi: ownerAbi, functionName: "isOwner", args: [wallet] });
return owner ? treasury : null;
}
if (req.method === "GET" && url.pathname === "/api/ledger") {
const wallet = url.searchParams.get("wallet");
if (!wallet) {
sendJson(res, 400, { error: "wallet query parameter required" });
return;
}
if (!isAddress(wallet)) { sendJson(res, 400, { error: "valid wallet address required" }); return; }
if (typeof req.headers["x-wallet-address"] !== "string" || req.headers["x-wallet-address"]!.toLowerCase() !== wallet.toLowerCase() || typeof req.headers["x-wallet-signature"] !== "string" || typeof req.headers["x-wallet-message"] !== "string") {
sendJson(res, 403, { error: "Wallet authorization required" });
return;
}
const ledger = await fetchTreasuryLedger(wallet);
sendJson(res, 200, { ledger });
return;
@@ -146,6 +178,7 @@ const server = http.createServer(async (req, res) => {
sendJson(res, 400, { error: "wallet query parameter required" });
return;
}
if (!isAddress(wallet)) { sendJson(res, 400, { error: "valid wallet address required" }); return; }
let treasury = await treasuryRouter.getByWallet(wallet);
if (!treasury && contractAddress.toLowerCase() === wallet.toLowerCase()) {
const ensured = await ensureTreasury();
@@ -158,10 +191,9 @@ const server = http.createServer(async (req, res) => {
const ledgerMatch = url.pathname.match(/^\/api\/treasury\/([^/]+)\/ledger$/);
if (req.method === "GET" && ledgerMatch) {
const treasuryId = ledgerMatch[1];
const treasuryRows = await treasuryRouter.getById(treasuryId);
const treasury = treasuryRows[0];
const treasury = await authorizedTreasury(treasuryId);
if (!treasury) {
sendJson(res, 404, { error: "Treasury not found" });
sendJson(res, 403, { error: "Treasury authorization required" });
return;
}
const ledger = await fetchTreasuryLedger(treasury.mainWallet);
@@ -172,6 +204,7 @@ const server = http.createServer(async (req, res) => {
const subAccountsMatch = url.pathname.match(/^\/api\/treasury\/([^/]+)\/sub-accounts$/);
if (subAccountsMatch) {
const treasuryId = subAccountsMatch[1];
if (!(await authorizedTreasury(treasuryId))) { sendJson(res, 403, { error: "Treasury authorization required" }); return; }
if (req.method === "GET") {
const subAccounts = await treasuryRouter.getSubAccounts(treasuryId);
sendJson(res, 200, { subAccounts });
@@ -180,7 +213,7 @@ const server = http.createServer(async (req, res) => {
if (req.method === "POST") {
const body = await readJsonBody(req);
const address = typeof body.address === "string" ? body.address : "";
if (!address) {
if (!isAddress(address)) {
sendJson(res, 400, { error: "address required" });
return;
}
@@ -199,6 +232,7 @@ const server = http.createServer(async (req, res) => {
if (req.method === "GET" && proposalsMatch) {
const includeApprovals = url.searchParams.get("includeApprovals") === "1";
const treasuryId = proposalsMatch[1];
if (!(await authorizedTreasury(treasuryId))) { sendJson(res, 403, { error: "Treasury authorization required" }); return; }
const status = url.searchParams.get("status") || undefined;
const proposals = includeApprovals
? await transactionRouter.getProposalsWithApprovals(treasuryId, status)
@@ -213,6 +247,7 @@ const server = http.createServer(async (req, res) => {
sendJson(res, 400, { error: "treasuryId required" });
return;
}
if (!(await authorizedTreasury(treasuryId))) { sendJson(res, 403, { error: "Treasury authorization required" }); return; }
const status = url.searchParams.get("status") || undefined;
const proposals = await transactionRouter.getProposals(treasuryId, status);
sendJson(res, 200, { proposals });
@@ -225,6 +260,7 @@ const server = http.createServer(async (req, res) => {
sendJson(res, 400, { error: "treasuryId query parameter required" });
return;
}
if (!(await authorizedTreasury(treasuryId))) { sendJson(res, 403, { error: "Treasury authorization required" }); return; }
const format = url.searchParams.get("format") || "json";
if (format === "csv") {
const csv = await exportRouter.exportTransactionsCSV(treasuryId);
+2 -2
View File
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2020",
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2021", "DOM"],
"lib": ["ES2022", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
+3 -4
View File
@@ -84,12 +84,12 @@ pct exec "$VMID" -- bash -c "
apt-get update
apt-get install -y curl
# Install Node.js 18
curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
# Install supported Node.js LTS
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
# Install pnpm
npm install -g pnpm
npm install -g pnpm@8.15.0
# Install PostgreSQL client
apt-get install -y postgresql-client
@@ -176,4 +176,3 @@ echo "2. Update .env with database connection and RPC URL"
echo "3. Run database migrations: pct exec $VMID -- bash -c 'cd /opt/solace-backend && pnpm run db:migrate'"
echo "4. Start the service: pct exec $VMID -- systemctl start solace-backend"
echo "5. Check status: pct exec $VMID -- systemctl status solace-backend"
+6 -6
View File
@@ -84,12 +84,12 @@ pct exec "$VMID" -- bash -c "
apt-get update
apt-get install -y curl
# Install Node.js 18
curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
# Install supported Node.js LTS
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
# Install pnpm
npm install -g pnpm
npm install -g pnpm@8.15.0
"
# Create application directory
@@ -116,13 +116,14 @@ fi
echo "Installing dependencies and building..."
pct exec "$VMID" -- bash -c "
cd /opt/solace-frontend
export NODE_ENV=production
# Install dev dependencies because Next.js needs Tailwind/PostCSS at build time.
export NODE_ENV=development
if [[ -f pnpm-lock.yaml ]]; then
pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile
else
pnpm install --no-frozen-lockfile
fi
pnpm run build
NODE_ENV=production pnpm run build
"
# Create systemd service
@@ -171,4 +172,3 @@ echo "1. Copy frontend/.env.production to container: pct push $VMID frontend/.en
echo "2. Update .env.production with Chain 138 RPC URL and contract addresses"
echo "3. Start the service: pct exec $VMID -- systemctl start solace-frontend"
echo "4. Check status: pct exec $VMID -- systemctl status solace-frontend"
+3 -4
View File
@@ -82,12 +82,12 @@ pct exec "$VMID" -- bash -c "
apt-get update
apt-get install -y curl
# Install Node.js 18
curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
# Install supported Node.js LTS
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
# Install pnpm
npm install -g pnpm
npm install -g pnpm@8.15.0
# Install PostgreSQL client
apt-get install -y postgresql-client
@@ -170,4 +170,3 @@ echo "2. Update .env.indexer with database connection, RPC URL, and contract add
echo "3. Start the service: pct exec $VMID -- systemctl start solace-indexer"
echo "4. Check status: pct exec $VMID -- systemctl status solace-indexer"
echo "5. View logs: pct exec $VMID -- journalctl -u solace-indexer -f"
+6 -4
View File
@@ -18,6 +18,7 @@ import {
import { decodeMemoData } from "@/lib/memo";
import { formatTokenAmount, getTokenByAddress } from "@/lib/tokens";
import { CHAIN138_PUBLIC } from "@/lib/web3/chain138-public";
import { useAccount } from "wagmi";
type ActivityFilter = "all" | "deposits" | "pending" | "executed";
@@ -34,6 +35,7 @@ export default function ActivityPage() {
function ActivityContent() {
const { treasuryId, loading: treasuryLoading } = useTreasury();
const { address } = useAccount();
const [filter, setFilter] = useState<ActivityFilter>("all");
const [proposals, setProposals] = useState<TransactionRecord[]>([]);
const [ledger, setLedger] = useState<LedgerEntry[]>([]);
@@ -48,10 +50,10 @@ function ActivityContent() {
setLoading(true);
const proposalPromise = treasuryId
? fetchTransactions(treasuryId)
? fetchTransactions(treasuryId, undefined, address)
: Promise.resolve([] as TransactionRecord[]);
const ledgerPromise = treasuryId
? fetchLedger(treasuryId)
? fetchLedger(treasuryId, address)
: treasuryWallet
? fetchLedgerByWallet(treasuryWallet)
: Promise.resolve([] as LedgerEntry[]);
@@ -62,7 +64,7 @@ function ActivityContent() {
setLedger(ledgerResult.status === "fulfilled" ? ledgerResult.value : []);
})
.finally(() => setLoading(false));
}, [treasuryId]);
}, [treasuryId, address]);
const deposits = useMemo(
() => ledger.filter((entry) => entry.kind === "deposit"),
@@ -83,7 +85,7 @@ function ActivityContent() {
const handleExport = async () => {
if (!treasuryId) return;
const csv = await exportTransactionsCsv(treasuryId);
const csv = await exportTransactionsCsv(treasuryId, address);
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
+12 -4
View File
@@ -9,6 +9,8 @@ import { formatAddress } from "@/lib/utils";
import { formatTokenAmount, getTokenByAddress } from "@/lib/tokens";
import { useTreasury } from "@/lib/hooks/useTreasury";
import { fetchPendingProposals, type TransactionRecord } from "@/lib/api/client";
import { SignerStatus } from "@/components/web3/SignerStatus";
import { useTreasuryAccess } from "@/lib/hooks/useTreasuryAccess";
export default function ApprovalsPage() {
return (
@@ -24,6 +26,7 @@ export default function ApprovalsPage() {
function ApprovalsContent() {
const { treasuryId, loading: treasuryLoading } = useTreasury();
const { writeContract } = useWriteContract();
const { isOwner, chainId, address } = useTreasuryAccess();
const [proposals, setProposals] = useState<TransactionRecord[]>([]);
const [loading, setLoading] = useState(true);
@@ -40,14 +43,15 @@ function ApprovalsContent() {
}
setLoading(true);
fetchPendingProposals(treasuryId)
fetchPendingProposals(treasuryId, address)
.then(setProposals)
.catch(() => setProposals([]))
.finally(() => setLoading(false));
}, [treasuryId]);
}, [treasuryId, address]);
const handleApprove = async (proposalId: number) => {
if (!CONTRACT_ADDRESSES.TreasuryWallet) return;
if (!isOwner || chainId !== 138) return;
await writeContract({
address: CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}`,
@@ -59,6 +63,7 @@ function ApprovalsContent() {
const handleExecute = async (proposalId: number) => {
if (!CONTRACT_ADDRESSES.TreasuryWallet) return;
if (!isOwner || chainId !== 138) return;
await writeContract({
address: CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}`,
@@ -73,6 +78,7 @@ function ApprovalsContent() {
return (
<div className="max-w-4xl mx-auto">
<PageHeader title="Pending Approvals" />
<SignerStatus action="approve or execute withdrawals" />
<div className="bg-gray-900 rounded-xl p-6 sm:p-8">
{treasuryLoading || loading ? (
@@ -115,7 +121,8 @@ function ApprovalsContent() {
<button
type="button"
onClick={() => handleExecute(proposal.proposalId)}
className="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg transition-colors"
disabled={!isOwner || chainId !== 138}
className="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-700 rounded-lg transition-colors"
>
Execute
</button>
@@ -123,7 +130,8 @@ function ApprovalsContent() {
<button
type="button"
onClick={() => handleApprove(proposal.proposalId)}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
disabled={!isOwner || chainId !== 138}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 rounded-lg transition-colors"
>
Approve
</button>
+6 -1
View File
@@ -8,6 +8,8 @@ import { PageHeader } from "@/components/layout/PageHeader";
import { TREASURY_WALLET_ABI, CONTRACT_ADDRESSES } from "@/lib/web3/contracts";
import { encodeMemoData } from "@/lib/memo";
import { CHAIN138_TOKENS, ERC20_BALANCE_ABI, formatTokenAmount } from "@/lib/tokens";
import { SignerStatus } from "@/components/web3/SignerStatus";
import { useTreasuryAccess } from "@/lib/hooks/useTreasuryAccess";
export default function SendPage() {
return (
@@ -24,6 +26,7 @@ function SendForm() {
const treasuryAddress = CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}` | undefined;
const [selectedToken, setSelectedToken] = useState(CHAIN138_TOKENS[0]);
const { writeContract } = useWriteContract();
const { isOwner, chainId } = useTreasuryAccess();
const isNative = selectedToken.address === "native";
@@ -62,6 +65,7 @@ function SendForm() {
if (!isAddress(recipient)) {
throw new Error("Invalid recipient address");
}
if (chainId !== 138 || !isOwner) throw new Error("Connected wallet is not an authorized Chain 138 treasury signer");
if (!amount || parseFloat(amount) <= 0) {
throw new Error("Invalid amount");
@@ -92,6 +96,7 @@ function SendForm() {
return (
<div className="max-w-2xl mx-auto">
<PageHeader title="Send Payment" />
<SignerStatus action="propose a withdrawal" />
<div className="bg-gray-900 rounded-xl p-6 sm:p-8 space-y-6">
<div>
@@ -160,7 +165,7 @@ function SendForm() {
<button
type="button"
onClick={handleSend}
disabled={loading || !recipient || !amount}
disabled={loading || !recipient || !amount || !isOwner || chainId !== 138}
className="w-full px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:cursor-not-allowed rounded-lg transition-colors font-semibold"
>
{loading ? "Processing..." : "Send Payment"}
+26 -24
View File
@@ -1,13 +1,15 @@
"use client";
import { useState } from "react";
import { useWriteContract, useReadContract } from "wagmi";
import { useWriteContract, useReadContract, useChainId } from "wagmi";
import { WalletGate } from "@/components/web3/WalletGate";
import { PageHeader } from "@/components/layout/PageHeader";
import { TREASURY_WALLET_ABI, CONTRACT_ADDRESSES } from "@/lib/web3/contracts";
import { formatAddress, isAddress } from "@/lib/utils";
import { getAddress } from "viem";
import { SubAccountsSettings } from "@/components/settings/SubAccountsSettings";
import { SignerStatus } from "@/components/web3/SignerStatus";
import { useTreasuryAccess } from "@/lib/hooks/useTreasuryAccess";
export default function SettingsPage() {
return (
@@ -21,7 +23,11 @@ export default function SettingsPage() {
}
function SettingsContent() {
const { writeContract } = useWriteContract();
const { writeContractAsync } = useWriteContract();
const chainId = useChainId();
const { isOwner } = useTreasuryAccess();
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [newSigner, setNewSigner] = useState("");
const [signerToRemove, setSignerToRemove] = useState("");
const [newThreshold, setNewThreshold] = useState("");
@@ -40,48 +46,45 @@ function SettingsContent() {
const handleAddSigner = async () => {
if (!isAddress(newSigner) || !CONTRACT_ADDRESSES.TreasuryWallet) return;
await writeContract({
if (!isAddress(newSigner) || !CONTRACT_ADDRESSES.TreasuryWallet || chainId !== 138) return;
setBusy(true); setError("");
try { await writeContractAsync({
address: CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}`,
abi: TREASURY_WALLET_ABI,
functionName: "addOwner",
args: [getAddress(newSigner)],
});
setNewSigner("");
}); setNewSigner(""); } catch (e) { setError(e instanceof Error ? e.message : "Transaction failed"); } finally { setBusy(false); }
};
const handleRemoveSigner = async () => {
if (!isAddress(signerToRemove) || !CONTRACT_ADDRESSES.TreasuryWallet) return;
await writeContract({
if (!isAddress(signerToRemove) || !CONTRACT_ADDRESSES.TreasuryWallet || chainId !== 138) return;
setBusy(true); setError("");
try { await writeContractAsync({
address: CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}`,
abi: TREASURY_WALLET_ABI,
functionName: "removeOwner",
args: [getAddress(signerToRemove)],
});
setSignerToRemove("");
}); setSignerToRemove(""); } catch (e) { setError(e instanceof Error ? e.message : "Transaction failed"); } finally { setBusy(false); }
};
const handleChangeThreshold = async () => {
const thresholdNum = parseInt(newThreshold);
if (isNaN(thresholdNum) || !CONTRACT_ADDRESSES.TreasuryWallet) return;
await writeContract({
if (!Number.isInteger(thresholdNum) || thresholdNum < 1 || thresholdNum > (owners?.length || 0) || !CONTRACT_ADDRESSES.TreasuryWallet || chainId !== 138) return;
setBusy(true); setError("");
try { await writeContractAsync({
address: CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}`,
abi: TREASURY_WALLET_ABI,
functionName: "changeThreshold",
args: [BigInt(thresholdNum)],
});
setNewThreshold("");
}); setNewThreshold(""); } catch (e) { setError(e instanceof Error ? e.message : "Transaction failed"); } finally { setBusy(false); }
};
return (
<div className="max-w-4xl mx-auto">
<PageHeader title="Treasury Settings" />
<SignerStatus action="change treasury signers or threshold" />
{chainId !== 138 && <div className="bg-yellow-900/30 border border-yellow-600 rounded-lg p-4 mb-6">Switch your wallet to Solace Chain 138 before changing treasury settings.</div>}
{error && <div className="bg-red-900/30 border border-red-600 rounded-lg p-4 mb-6" role="alert">{error}</div>}
<div className="space-y-6">
{/* Current Configuration */}
@@ -126,7 +129,7 @@ function SettingsContent() {
/>
<button
onClick={handleAddSigner}
disabled={!isAddress(newSigner)}
disabled={busy || !isAddress(newSigner) || chainId !== 138 || !isOwner}
className="px-6 py-3 bg-green-600 hover:bg-green-700 disabled:bg-gray-700 disabled:cursor-not-allowed rounded-lg transition-colors"
>
Add
@@ -153,7 +156,7 @@ function SettingsContent() {
/>
<button
onClick={handleRemoveSigner}
disabled={!isAddress(signerToRemove)}
disabled={busy || !isAddress(signerToRemove) || chainId !== 138 || !isOwner}
className="px-6 py-3 bg-red-600 hover:bg-red-700 disabled:bg-gray-700 disabled:cursor-not-allowed rounded-lg transition-colors"
>
Remove
@@ -179,7 +182,7 @@ function SettingsContent() {
/>
<button
onClick={handleChangeThreshold}
disabled={!newThreshold}
disabled={busy || !newThreshold || chainId !== 138 || !isOwner}
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:cursor-not-allowed rounded-lg transition-colors"
>
Update
@@ -190,4 +193,3 @@ function SettingsContent() {
</div>
);
}
+8 -3
View File
@@ -8,6 +8,8 @@ import { PageHeader } from "@/components/layout/PageHeader";
import { TREASURY_WALLET_ABI, CONTRACT_ADDRESSES } from "@/lib/web3/contracts";
import { useTreasury } from "@/lib/hooks/useTreasury";
import { fetchSubAccounts } from "@/lib/api/client";
import { SignerStatus } from "@/components/web3/SignerStatus";
import { useTreasuryAccess } from "@/lib/hooks/useTreasuryAccess";
export default function TransferPage() {
return (
@@ -29,6 +31,7 @@ function TransferForm() {
query: { enabled: Boolean(treasuryAddress) },
});
const { writeContract } = useWriteContract();
const { isOwner, chainId } = useTreasuryAccess();
const [fromAccount, setFromAccount] = useState("main");
const [toAccount, setToAccount] = useState("");
@@ -39,10 +42,10 @@ function TransferForm() {
useEffect(() => {
if (!treasuryId) return;
fetchSubAccounts(treasuryId)
fetchSubAccounts(treasuryId, address)
.then((accounts) => setSubAccounts(accounts.map((a) => a.address)))
.catch(() => setSubAccounts([]));
}, [treasuryId]);
}, [treasuryId, address]);
const handleTransfer = async () => {
setError("");
@@ -52,6 +55,7 @@ function TransferForm() {
if (!toAccount) {
throw new Error("Please select destination account");
}
if (chainId !== 138 || !isOwner) throw new Error("Connected wallet is not an authorized Chain 138 treasury signer");
if (!amount || parseFloat(amount) <= 0) {
throw new Error("Invalid amount");
@@ -81,6 +85,7 @@ function TransferForm() {
return (
<div className="max-w-2xl mx-auto">
<PageHeader title="Internal Transfer" />
<SignerStatus action="propose an internal transfer" />
<div className="bg-gray-900 rounded-xl p-6 sm:p-8 space-y-6">
<div>
@@ -148,7 +153,7 @@ function TransferForm() {
<button
type="button"
onClick={handleTransfer}
disabled={loading || !toAccount || !amount}
disabled={loading || !toAccount || !amount || !isOwner || chainId !== 138}
className="w-full px-6 py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-700 disabled:cursor-not-allowed rounded-lg transition-colors font-semibold"
>
{loading ? "Processing..." : "Transfer"}
+1 -4
View File
@@ -1,6 +1,5 @@
import type { Metadata } from "next";
import dynamic from "next/dynamic";
import { Inter } from "next/font/google";
import "./globals.css";
const Providers = dynamic(
@@ -16,8 +15,6 @@ const ParticleBackground = dynamic(
{ ssr: false }
);
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Solace Treasury Management",
description: "Treasury Management DApp for Solace Bank Group",
@@ -34,7 +31,7 @@ export default function RootLayout({
}) {
return (
<html lang="en">
<body className={inter.className}>
<body>
<ParticleBackground />
<Providers>{children}</Providers>
</body>
@@ -7,6 +7,7 @@ import { formatTokenAmount, getTokenByAddress } from "@/lib/tokens";
import { gsap } from "gsap";
import { useTreasury } from "@/lib/hooks/useTreasury";
import { CONTRACT_ADDRESSES } from "@/lib/web3/contracts";
import { useAccount } from "wagmi";
import {
fetchLedger,
fetchLedgerByWallet,
@@ -21,6 +22,7 @@ type ActivityItem =
export function RecentActivity() {
const { treasuryId } = useTreasury();
const { address } = useAccount();
const [items, setItems] = useState<ActivityItem[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
@@ -29,10 +31,10 @@ export function RecentActivity() {
if (!treasuryId && !treasuryWallet) return;
const proposalPromise = treasuryId
? fetchTransactions(treasuryId)
? fetchTransactions(treasuryId, undefined, address)
: Promise.resolve([] as TransactionRecord[]);
const ledgerPromise = treasuryId
? fetchLedger(treasuryId)
? fetchLedger(treasuryId, address)
: treasuryWallet
? fetchLedgerByWallet(treasuryWallet)
: Promise.resolve([] as LedgerEntry[]);
@@ -59,7 +61,7 @@ export function RecentActivity() {
setItems(merged.slice(0, 5));
}
);
}, [treasuryId]);
}, [treasuryId, address]);
useEffect(() => {
if (containerRef.current && items.length > 0) {
@@ -1,5 +1,6 @@
"use client";
import { useAccount } from "wagmi";
import { useEffect, useState } from "react";
import { useWriteContract, useReadContract } from "wagmi";
import { keccak256, toBytes } from "viem";
@@ -13,6 +14,7 @@ import { fetchSubAccounts, type SubAccountRecord } from "@/lib/api/client";
export function SubAccountsSettings() {
const { treasuryId } = useTreasury();
const { address } = useAccount();
const [label, setLabel] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -29,10 +31,10 @@ export function SubAccountsSettings() {
useEffect(() => {
if (!treasuryId) return;
fetchSubAccounts(treasuryId)
fetchSubAccounts(treasuryId, address)
.then(setSubAccounts)
.catch(() => setSubAccounts([]));
}, [treasuryId]);
}, [treasuryId, address]);
const handleCreate = async () => {
setError("");
@@ -54,7 +56,7 @@ export function SubAccountsSettings() {
});
setLabel("");
const refreshed = await fetchSubAccounts(treasuryId);
const refreshed = await fetchSubAccounts(treasuryId, address);
setSubAccounts(refreshed);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to create sub-account");
+11
View File
@@ -0,0 +1,11 @@
"use client";
import { useTreasuryAccess } from "@/lib/hooks/useTreasuryAccess";
export function SignerStatus({ action = "manage treasury funds" }: { action?: string }) {
const { chainId, isOwner, isLoading } = useTreasuryAccess();
if (chainId !== 138) return <div className="bg-yellow-900/30 border border-yellow-600 rounded-lg p-4 text-sm text-yellow-200">Switch MetaMask to Solace Chain 138 before you can {action}.</div>;
if (isLoading) return <div className="bg-gray-800 rounded-lg p-4 text-sm text-gray-400">Checking treasury signer permissions</div>;
if (!isOwner) return <div className="bg-red-900/30 border border-red-600 rounded-lg p-4 text-sm text-red-200">This connected wallet is not a treasury signer. Ask an authorized signer to propose, approve, or execute this withdrawal.</div>;
return null;
}
+7
View File
@@ -30,3 +30,10 @@ test("backend health when API is up", async ({ request }) => {
const body = await res.json();
expect(body.status).toBe("ok");
});
test("protected treasury data rejects unsigned requests", async ({ request }) => {
const api = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
const res = await request.get(`${api}/api/treasury/00000000-0000-0000-0000-000000000000/ledger`);
test.skip(res.status() === 404, "Backend route unavailable");
expect(res.status()).toBe(403);
});
+24 -13
View File
@@ -68,8 +68,17 @@ export interface LedgerEntry {
blockNumber: number;
}
async function apiFetch<T>(path: string): Promise<T> {
const res = await fetch(apiUrl(path), { cache: "no-store" });
async function signedHeaders(path: string, wallet?: string): Promise<Record<string, string> | undefined> {
if (!wallet || typeof window === "undefined") return undefined;
const ethereum = (window as Window & { ethereum?: { request: (args: { method: string; params: string[] }) => Promise<string> } }).ethereum;
if (!ethereum) return undefined;
const message = `Solace Treasury API authorization\nWallet: ${wallet.toLowerCase()}\nPath: ${path}\nTimestamp: ${Date.now()}`;
const signature = await ethereum.request({ method: "personal_sign", params: [message, wallet] });
return { "X-Wallet-Address": wallet, "X-Wallet-Message": message, "X-Wallet-Signature": signature };
}
async function apiFetch<T>(path: string, wallet?: string): Promise<T> {
const res = await fetch(apiUrl(path), { cache: "no-store", headers: await signedHeaders(path, wallet) });
if (!res.ok) {
throw new Error(`API ${path} failed: ${res.status}`);
}
@@ -85,46 +94,48 @@ export async function fetchTreasury(wallet: string): Promise<TreasuryRecord | nu
export async function fetchTransactions(
treasuryId: string,
status?: string
status?: string,
wallet?: string
): Promise<TransactionRecord[]> {
const query = status ? `?status=${encodeURIComponent(status)}` : "";
const data = await apiFetch<{ proposals: TransactionRecord[] }>(
`/api/transactions/${treasuryId}${query}`
`/api/transactions/${treasuryId}${query}`, wallet
);
return data.proposals;
}
export async function fetchPendingProposals(treasuryId: string): Promise<TransactionRecord[]> {
export async function fetchPendingProposals(treasuryId: string, wallet?: string): Promise<TransactionRecord[]> {
const data = await apiFetch<{ proposals: TransactionRecord[] }>(
`/api/proposals/${treasuryId}?status=pending&includeApprovals=1`
`/api/proposals/${treasuryId}?status=pending&includeApprovals=1`, wallet
);
return data.proposals;
}
export async function fetchSubAccounts(treasuryId: string): Promise<SubAccountRecord[]> {
export async function fetchSubAccounts(treasuryId: string, wallet?: string): Promise<SubAccountRecord[]> {
const data = await apiFetch<{ subAccounts: SubAccountRecord[] }>(
`/api/treasury/${treasuryId}/sub-accounts`
`/api/treasury/${treasuryId}/sub-accounts`, wallet
);
return data.subAccounts;
}
export async function fetchLedger(treasuryId: string): Promise<LedgerEntry[]> {
export async function fetchLedger(treasuryId: string, wallet?: string): Promise<LedgerEntry[]> {
const data = await apiFetch<{ ledger: LedgerEntry[] }>(
`/api/treasury/${treasuryId}/ledger`
`/api/treasury/${treasuryId}/ledger`, wallet
);
return data.ledger;
}
export async function fetchLedgerByWallet(wallet: string): Promise<LedgerEntry[]> {
const data = await apiFetch<{ ledger: LedgerEntry[] }>(
`/api/ledger?wallet=${encodeURIComponent(wallet)}`
`/api/ledger?wallet=${encodeURIComponent(wallet)}`, wallet
);
return data.ledger;
}
export async function exportTransactionsCsv(treasuryId: string): Promise<string> {
export async function exportTransactionsCsv(treasuryId: string, wallet?: string): Promise<string> {
const path = `/api/transactions/export?treasuryId=${encodeURIComponent(treasuryId)}&format=csv`;
const res = await fetch(
apiUrl(`/api/transactions/export?treasuryId=${encodeURIComponent(treasuryId)}&format=csv`)
apiUrl(path), { headers: await signedHeaders(path, wallet) }
);
if (!res.ok) {
throw new Error(`CSV export failed: ${res.status}`);
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { useAccount, useReadContract } from "wagmi";
import { TREASURY_WALLET_ABI, CONTRACT_ADDRESSES } from "@/lib/web3/contracts";
export function useTreasuryAccess() {
const { address } = useAccount();
const chainId = useAccount().chainId;
const { data: isOwner, isLoading } = useReadContract({
address: CONTRACT_ADDRESSES.TreasuryWallet as `0x${string}`,
abi: TREASURY_WALLET_ABI,
functionName: "isOwner",
args: address ? [address] : undefined,
query: { enabled: Boolean(address && CONTRACT_ADDRESSES.TreasuryWallet && chainId === 138) },
});
return { address, chainId, isOwner: isOwner === true, isLoading };
}
+5 -1
View File
@@ -3,6 +3,11 @@ const path = require("path");
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
async headers() {
return [{ source: "/(.*)", headers: [
{ key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://rpc-http-pub.d-bis.org wss://rpc-ws-pub.d-bis.org https://explorer.d-bis.org; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" },
] }];
},
webpack: (config, { isServer }) => {
config.resolve.fallback = {
...config.resolve.fallback,
@@ -32,4 +37,3 @@ const nextConfig = {
};
module.exports = nextConfig;
+22 -20
View File
@@ -12,38 +12,40 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@react-three/drei": "^9.92.0",
"@react-three/fiber": "^8.15.0",
"@tanstack/react-query": "^5.17.0",
"@walletconnect/ethereum-provider": "^2.9.0",
"@walletconnect/modal": "^2.6.2",
"@x402/core": "^2.25.0",
"@x402/evm": "^2.25.0",
"@x402/svm": "^2.25.0",
"clsx": "^2.1.0",
"date-fns": "^3.0.0",
"gsap": "^3.12.2",
"maath": "^0.10.0",
"next": "^14.0.4",
"qrcode.react": "^3.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"wagmi": "^2.5.0",
"viem": "^2.0.0",
"@tanstack/react-query": "^5.17.0",
"@walletconnect/modal": "^2.6.2",
"@walletconnect/ethereum-provider": "^2.9.0",
"gsap": "^3.12.2",
"@react-three/fiber": "^8.15.0",
"@react-three/drei": "^9.92.0",
"three": "^0.160.0",
"qrcode.react": "^3.1.0",
"date-fns": "^3.0.0",
"zod": "^3.22.4",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0",
"maath": "^0.10.0"
"three": "^0.160.0",
"viem": "^2.0.0",
"wagmi": "^2.5.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
"@types/node": "^20.10.0",
"@types/qrcode.react": "^3.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/three": "^0.160.0",
"@types/qrcode.react": "^3.0.0",
"typescript": "^5.3.3",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.32",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-config-next": "^14.0.4",
"@playwright/test": "^1.49.0"
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3"
}
}
+5 -1
View File
@@ -18,6 +18,7 @@ rsync -az --delete --exclude node_modules --exclude .next --exclude dist \
"$ROOT/backend/" "$PROXMOX_USER@$PROXMOX_HOST:$DEPLOYMENT_DIR/project/backend/"
rsync -az --delete --exclude node_modules --exclude .next \
"$ROOT/frontend/" "$PROXMOX_USER@$PROXMOX_HOST:$DEPLOYMENT_DIR/project/frontend/"
rsync -az "$ROOT/pnpm-lock.yaml" "$PROXMOX_USER@$PROXMOX_HOST:$DEPLOYMENT_DIR/project/pnpm-lock.yaml"
ssh "$PROXMOX_USER@$PROXMOX_HOST" "cat > $DEPLOYMENT_DIR/config/dapp.conf <<EOF
$(grep -v '^DATABASE_PASSWORD=' "$ROOT/deployment/proxmox/config/dapp.conf")
@@ -34,13 +35,16 @@ ssh "$PROXMOX_USER@$PROXMOX_HOST" bash -s <<'REMOTE'
set -euo pipefail
DEPLOYMENT_DIR=/tmp/solace-dapp-deployment
PROJECT_ROOT=$DEPLOYMENT_DIR/project
cp "$PROJECT_ROOT/pnpm-lock.yaml" "$PROJECT_ROOT/backend/pnpm-lock.yaml"
cp "$PROJECT_ROOT/pnpm-lock.yaml" "$PROJECT_ROOT/frontend/pnpm-lock.yaml"
rebuild_backend() {
local vmid=$1 dir=$2
pct exec "$vmid" -- bash -c "export DEBIAN_FRONTEND=noninteractive; apt-get update -qq; apt-get install -y -qq curl; curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null 2>&1; apt-get install -y -qq nodejs"
pct exec "$vmid" -- bash -c "rm -rf /opt/$dir/* /opt/$dir/.* 2>/dev/null || true"
cd "$PROJECT_ROOT"
tar czf - backend | pct exec "$vmid" -- bash -c "cd /opt && tar xzf - && mv backend/* $dir/ && mv backend/.* $dir/ 2>/dev/null || true && rmdir backend 2>/dev/null || true"
pct exec "$vmid" -- bash -c "cd /opt/$dir && pnpm install --no-frozen-lockfile"
pct exec "$vmid" -- bash -c "npm install -g [email protected] >/dev/null 2>&1 && cd /opt/$dir && pnpm install --no-frozen-lockfile"
}
rebuild_backend 3001 solace-backend