Add multisig pending-operations UI, fix broken multisig address calc, new signing portal

- calculateMultisigAddress was completely broken (hex-decoded an SS58 string
  and never hashed the preimage) - fixed via @pezkuwi/util-crypto's real
  encodeMultiAddress/createKeyMulti, verified against the actual known
  multisig address on-chain.
- ReservesDashboardPage had stale Noter/Berdevk addresses that don't match
  the real signers - centralized as BRIDGE_MULTISIG_SPECIFIC_ADDRESSES.
- USDTBridge withdrawal called assets.burn directly as a single-signer
  extrinsic (always fails - only the multisig is Admin) while only
  checking status.isFinalized (a failed dispatch is still finalized, so it
  silently did nothing) - replaced with the correct transfer-to-custody
  flow the relayer actually watches for.
- New MultisigOperationsPage (/multisig/pending) lists pending calls from
  real Multisig.Multisigs storage and lets any of the 5 signers
  approve/reject with their own wallet extension.
- New standalone sign/ app (deployed separately at
  pezbridge-sign.pex.mom) - a dedicated, gated signing portal for the same
  operations, so signing isn't dependent on this app alone.
This commit is contained in:
2026-07-14 07:19:34 -07:00
parent fe3c65a706
commit dd58fe9164
24 changed files with 5662 additions and 64 deletions
+105
View File
@@ -0,0 +1,105 @@
// ========================================
// Multisig Pending Operations (higher-level orchestration for the review/approve UI)
// ========================================
// Sits on top of multisig.ts's low-level pallet-multisig calls and usdt.ts's bridge constants.
// There is no code path connecting this web app to the usdt-bridge relayer's private local
// database (confirmed while researching this feature - see the usdt-bridge-multisig-migration
// memory), so pending calls are described purely from on-chain state: the one deterministic,
// always-reconstructible candidate (the automation key's periodic allowance renewal) is
// auto-matched by hash; anything else needs a signer to paste the known call data, exactly
// like the Android wallet's "Enter Call Data" fallback exists for the same reason - a call
// hash alone is cryptographically insufficient to recover the original call.
import type { ApiPromise } from '@pezkuwi/api';
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
import {
getPendingMultisigCalls,
USDT_MULTISIG_CONFIG,
type MultisigTimepoint,
} from './multisig';
import { WUSDT_ASSET_ID, WUSDT_AUTOMATION_KEY_ADDRESS, STANDARD_RENEWAL_TOPUP } from './usdt';
export interface PendingOperation {
callHash: string;
when: MultisigTimepoint;
deposit: string;
depositor: string;
approvals: string[];
approvalsCount: number;
threshold: number;
description: string;
resolvedCall: SubmittableExtrinsic<'promise'> | null;
isAutoMatched: boolean;
}
/**
* The one deterministic, always-reconstructible pending-call shape this UI can auto-describe:
* the automation key's periodic Assets.approve_transfer renewal (see PezbridgeBot /
* pezkuwi-DKS/tools/usdt-bridge/src/bin/pezbridge_bot.rs, which builds the exact same call).
*/
export function buildRenewalCandidateCall(api: ApiPromise): SubmittableExtrinsic<'promise'> {
return api.tx.assets.approveTransfer(
WUSDT_ASSET_ID,
WUSDT_AUTOMATION_KEY_ADDRESS,
STANDARD_RENEWAL_TOPUP.toString()
) as unknown as SubmittableExtrinsic<'promise'>;
}
/**
* Lists every pending Multisig.Multisigs entry for the bridge's custody account and attaches a
* human-readable description + the real call object wherever it can be determined.
*/
export async function listPendingOperations(
api: ApiPromise,
multisigAddress: string
): Promise<PendingOperation[]> {
const raw = await getPendingMultisigCalls(api, multisigAddress);
const renewalCandidate = buildRenewalCandidateCall(api);
const renewalHash = renewalCandidate.method.hash.toHex().toLowerCase();
return raw.map((entry) => {
const approvals = (entry.approvals ?? []) as string[];
const isRenewal = String(entry.callHash).toLowerCase() === renewalHash;
return {
callHash: entry.callHash,
when: entry.when,
deposit: String(entry.deposit),
depositor: entry.depositor,
approvals,
approvalsCount: approvals.length,
threshold: USDT_MULTISIG_CONFIG.threshold,
description: isRenewal
? `Automation key allowance renewal (${(Number(STANDARD_RENEWAL_TOPUP) / 1e6).toFixed(2)} wUSDT)`
: 'Unknown call - paste the call data below to decode it',
resolvedCall: isRenewal ? renewalCandidate : null,
isAutoMatched: isRenewal,
};
});
}
/**
* Decodes raw hex call data a signer pasted in (the manual fallback for any pending call this
* UI couldn't auto-reconstruct) and verifies its hash actually matches the pending entry before
* returning it - pasted data is never trusted blindly.
*/
export function decodeAndVerifyCallData(
api: ApiPromise,
callDataHex: string,
expectedCallHash: string
): { call: SubmittableExtrinsic<'promise'>; description: string } {
const decoded = api.createType('Call', callDataHex);
const call = api.tx(decoded) as unknown as SubmittableExtrinsic<'promise'>;
const actualHash = call.method.hash.toHex().toLowerCase();
if (actualHash !== expectedCallHash.toLowerCase()) {
throw new Error(
`Call data does not match this operation: decoded hash ${actualHash}, expected ${expectedCallHash.toLowerCase()}`
);
}
const method = call.method as unknown as { section: string; method: string; args: unknown[] };
const argsText = method.args.map((a) => String(a)).join(', ');
return { call, description: `${method.section}.${method.method}(${argsText})` };
}
+25 -16
View File
@@ -6,7 +6,7 @@
import type { ApiPromise } from '@pezkuwi/api';
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
import { Tiki } from './tiki';
import { encodeAddress, sortAddresses } from '@pezkuwi/util-crypto';
import { encodeMultiAddress, sortAddresses } from '@pezkuwi/util-crypto';
// ========================================
// MULTISIG CONFIGURATION
@@ -30,6 +30,21 @@ export const USDT_MULTISIG_CONFIG = {
] as MultisigMember[],
};
/**
* Addresses for the two non-unique Tiki roles (Noter, Berdevk are held by all 21 validators,
* so which specific validator sits on THIS multisig has to be pinned manually - it can't be
* derived from the Tiki on-chain lookup alone). This is the single source of truth: it MUST
* match the real 5 signatories the on-chain bridge multisig was actually derived from (see
* res/validators-tiki.md "USDT Bridge Custody Multisig" and the usdt-bridge-multisig-migration
* memory). Centralized here instead of copy-pasted per-page - a stale copy of these two
* addresses previously sat in ReservesDashboardPage.tsx and didn't match any real signer,
* which silently made every derived multisig address wrong.
*/
export const BRIDGE_MULTISIG_SPECIFIC_ADDRESSES: Record<string, string> = {
Noter: '5ELgySrX5ZyK7EWXjj6bAedyTCcTNWDANbiiipsT5gnpoCEp', // Validator_04
Berdevk: '5HWFZbhkZuTUySXu6ZXYKrTHBnWXHvWRKLozE22zhnwXGGxk', // Validator_02
};
// ========================================
// MULTISIG MEMBER QUERIES
// ========================================
@@ -87,21 +102,15 @@ export function calculateMultisigAddress(
threshold: number = USDT_MULTISIG_CONFIG.threshold,
ss58Format: number = 42
): string {
// Sort members (multisig requires sorted order)
const sortedMembers = sortAddresses(members);
// Create multisig address
// Formula: blake2(b"modlpy/utilisuba" + concat(sorted_members) + threshold)
const multisigId = encodeAddress(
new Uint8Array([
...Buffer.from('modlpy/utilisuba'),
...sortedMembers.flatMap((addr) => Array.from(Buffer.from(addr, 'hex'))),
threshold,
]),
ss58Format
);
return multisigId;
// Delegates to @pezkuwi/util-crypto's own createKeyMulti/encodeMultiAddress, which correctly
// implements pallet_multisig's derivation (blake2_256 of
// SCALE-encode(b"modlpy/utilisuba" ++ compact-len ++ sorted pubkeys ++ u16 threshold)). A
// previous hand-rolled version here decoded each address with `Buffer.from(addr, 'hex')` -
// treating an SS58 string as hex, which is simply wrong - and never hashed the preimage at
// all, so it silently produced an address with no relationship to the real multisig account.
// Caught by directly testing this function against the known real 5 bridge signatories,
// which threw immediately instead of quietly returning a bogus address.
return encodeMultiAddress(members, threshold, ss58Format);
}
/**
+52
View File
@@ -4,6 +4,7 @@
// Handles wUSDT minting, burning, and reserve management
import type { ApiPromise } from '@pezkuwi/api';
import { encodeAddress } from '@pezkuwi/util-crypto';
import { ASSET_IDS, ASSET_CONFIGS } from './wallet';
import { getMultisigMembers, createMultisigTx } from './multisig';
@@ -14,6 +15,27 @@ import { getMultisigMembers, createMultisigTx } from './multisig';
export const WUSDT_ASSET_ID = ASSET_CONFIGS.WUSDT.id;
export const WUSDT_DECIMALS = ASSET_CONFIGS.WUSDT.decimals;
// ========================================
// BRIDGE CUSTODY (3-of-5 multisig)
// ========================================
// See res/validators-tiki.md "USDT Bridge Custody Multisig" and the
// usdt-bridge-multisig-migration memory for how these were derived/verified on-chain.
// Deliberately NOT env-overridable like the constants in wallet.ts - these are fixed,
// security-critical addresses, not per-deployment config; an env misconfiguration must
// never be able to silently point this UI at the wrong custody account.
/** The 3-of-5 multisig's own account on Pezkuwi Asset Hub - owns/issues wUSDT (asset 1000). */
export const WUSDT_BRIDGE_CUSTODY_PEZKUWI = '5GvwxmCDp3PC33KHoeWSgj3S7ocE7nzk1jiCCZMPSDBFeNcj';
/** Delegate key the multisig grants a bounded, auto-renewing Assets.approve_transfer allowance
* to (see pezkuwi-DKS/tools/usdt-bridge/src/bin/pezbridge_bot.rs) so small/routine deposits
* don't need a live 3-of-5 signature every time. */
export const WUSDT_AUTOMATION_KEY_ADDRESS = '5GQu4PFUb1f3MTJ7i7c1CtLgDk3TVvpSW1VbQCRmfkMoC8cM';
/** The standard top-up amount the multisig renews the automation key's allowance to (6
* decimals) - used to reconstruct and auto-describe that one deterministic pending call. */
export const STANDARD_RENEWAL_TOPUP = 10_000_000_000n; // 10,000 wUSDT
// Withdrawal limits and timeouts
export const WITHDRAWAL_LIMITS = {
instant: {
@@ -162,6 +184,36 @@ export async function createBurnWUSDTTx(
// WITHDRAWAL HELPERS
// ========================================
/**
* Builds the plain, single-signer transfer a user submits THEMSELVES to initiate a withdrawal:
* sending their own wUSDT to the multisig custody account. This is what usdt-bridge's
* listen_withdrawals() actually watches for (an Assets.Transferred event targeting the custody
* account) - the user never calls Assets.burn directly. Burning is the multisig's job, later,
* once real USDT has actually been released on the Polkadot side; burn requires Admin origin
* (the multisig), so a user-submitted burn call has always failed on-chain with NoPermission -
* see the fixed bug in USDTBridge.tsx's handleWithdrawal, which used to call burn directly and
* report success purely from `status.isFinalized` without checking whether the extrinsic
* actually succeeded (a failed dispatch is still included/finalized, so it always "succeeded").
* @param api - Polkadot API instance
* @param amount - Amount in human-readable format (e.g., 100.50 USDT)
* @returns Plain (non-multisig) transfer extrinsic the user signs with their own key
*/
export function createWithdrawalInitiationTx(api: ApiPromise, amount: number) {
const amountBN = parseWUSDT(amount);
return api.tx.assets.transfer(WUSDT_ASSET_ID, WUSDT_BRIDGE_CUSTODY_PEZKUWI, amountBN.toString());
}
/**
* Where real USDT actually gets released to on Polkadot Asset Hub: usdt-bridge's relayer
* (listen_withdrawals in main.rs) derives it by re-encoding the SENDER's own address under
* Polkadot's SS58 prefix (0) - the same underlying sr25519/ed25519 key, just a different
* network's address format. It does NOT read a separately user-specified destination, so the
* UI must show this derived address rather than pretend an arbitrary one can be entered.
*/
export function deriveWithdrawalDestination(pezkuwiAddress: string): string {
return encodeAddress(pezkuwiAddress, 0);
}
/**
* Calculate withdrawal delay based on amount
* @param amount - Withdrawal amount in USDT
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<title>PezBridge Sign</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4251
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "pezbridge-sign",
"private": true,
"version": "1.0.0",
"type": "module",
"description": "Dedicated, minimal-surface signing portal for PezkuwiChain multisig operations - gated to known signatory addresses only.",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@pezkuwi/api": "^16.5.36",
"@pezkuwi/extension-dapp": "^0.62.20",
"@pezkuwi/extension-inject": "^0.62.20",
"@pezkuwi/keyring": "^14.0.25",
"@pezkuwi/util": "^14.0.25",
"@pezkuwi/util-crypto": "^14.0.25"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react-swc": "^3.5.0",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vite-plugin-node-polyfills": "^0.22.0"
}
}
+313
View File
@@ -0,0 +1,313 @@
import React, { useCallback, useEffect, useState } from 'react';
import { web3Accounts } from '@pezkuwi/extension-dapp';
import type { InjectedAccountWithMeta } from '@pezkuwi/extension-inject/types';
import type { ApiPromise } from '@pezkuwi/api';
import {
getMultisigMembers,
calculateMultisigAddress,
approveMultisigTx,
cancelMultisigTx,
isMultisigMember,
USDT_MULTISIG_CONFIG,
} from '@pezkuwi/lib/multisig';
import {
listPendingOperations,
decodeAndVerifyCallData,
type PendingOperation,
} from '@pezkuwi/lib/multisig-operations';
import { connectApi } from './lib/chain';
import { getSigner } from './lib/get-signer';
import { SIGNER_SETS, type SignerSet } from './signer-sets';
const PEX_MOM_URL = 'https://pex.mom';
interface OperationsPanelProps {
api: ApiPromise;
account: InjectedAccountWithMeta;
signerSet: SignerSet;
}
function OperationsPanel({ api, account, signerSet }: OperationsPanelProps) {
const [operations, setOperations] = useState<PendingOperation[]>([]);
const [otherSignatories, setOtherSignatories] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [processingHash, setProcessingHash] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [manualCallData, setManualCallData] = useState<Record<string, string>>({});
const [decodeErrors, setDecodeErrors] = useState<Record<string, string>>({});
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const allMembers = await getMultisigMembers(api, signerSet.specificAddresses);
const multisigAddr = calculateMultisigAddress(allMembers);
setOtherSignatories(allMembers.filter((addr) => addr !== account.address));
const ops = await listPendingOperations(api, multisigAddr);
setOperations(ops);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load pending operations');
} finally {
setLoading(false);
}
}, [api, account.address, signerSet]);
useEffect(() => {
load();
}, [load]);
const handleDecode = (callHash: string) => {
const hex = manualCallData[callHash];
if (!hex) return;
try {
const { call, description } = decodeAndVerifyCallData(api, hex, callHash);
setOperations((prev) =>
prev.map((op) => (op.callHash === callHash ? { ...op, resolvedCall: call, description, isAutoMatched: false } : op))
);
setDecodeErrors((prev) => ({ ...prev, [callHash]: '' }));
} catch (err) {
setDecodeErrors((prev) => ({ ...prev, [callHash]: err instanceof Error ? err.message : 'Decode failed' }));
}
};
const runExtrinsic = async (
callHash: string,
tx: ReturnType<typeof approveMultisigTx> | ReturnType<typeof cancelMultisigTx>,
successMessage: string
) => {
setProcessingHash(callHash);
setError(null);
setSuccess(null);
try {
const injector = await getSigner(account.address);
await new Promise<void>((resolve, reject) => {
tx.signAndSend(account.address, { signer: injector.signer }, ({ status, dispatchError }) => {
if (status.isInBlock || status.isFinalized) {
if (dispatchError) {
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`));
} else {
reject(new Error(dispatchError.toString()));
}
} else {
resolve();
}
}
}).catch(reject);
});
setSuccess(successMessage);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : 'Transaction failed');
} finally {
setProcessingHash(null);
}
};
const handleApprove = (op: PendingOperation) => {
if (!op.resolvedCall) return;
const tx = approveMultisigTx(api, op.resolvedCall, otherSignatories, op.when, op.threshold);
runExtrinsic(op.callHash, tx, 'Approval submitted successfully.');
};
const handleReject = (op: PendingOperation) => {
const tx = cancelMultisigTx(api, op.callHash, otherSignatories, op.when, op.threshold);
runExtrinsic(op.callHash, tx, 'Operation cancelled successfully.');
};
return (
<section className="panel">
<div className="panel-header">
<h2>{signerSet.name}</h2>
<button className="btn-outline" onClick={load} disabled={loading}>
Refresh
</button>
</div>
{error && <div className="alert alert-error">{error}</div>}
{success && <div className="alert alert-success">{success}</div>}
{loading ? (
<div className="loading">Loading pending operations</div>
) : operations.length === 0 ? (
<div className="empty">No pending operations right now.</div>
) : (
<div className="ops-list">
{operations.map((op) => {
const alreadyApproved = op.approvals.includes(account.address);
const canReject = op.depositor === account.address;
const isProcessing = processingHash === op.callHash;
return (
<div key={op.callHash} className="op-card">
<div className="op-header">
<div>
<p className="op-desc">{op.description}</p>
<code className="op-hash">
{op.callHash.slice(0, 10)}{op.callHash.slice(-8)}
</code>
</div>
<span className={`badge ${op.approvalsCount >= op.threshold ? 'badge-ready' : ''}`}>
{op.approvalsCount}/{op.threshold} approvals
</span>
</div>
<p className="op-meta">
Proposed by <code>{op.depositor.slice(0, 8)}{op.depositor.slice(-6)}</code>
</p>
{alreadyApproved && <p className="op-approved">You already approved this operation</p>}
{!op.resolvedCall && (
<div className="decode-box">
<p className="hint">This call couldn't be auto-identified from its hash alone - paste the known call data to decode and verify it.</p>
<textarea
placeholder="Paste call data (0x...)"
value={manualCallData[op.callHash] ?? ''}
onChange={(e) => setManualCallData((prev) => ({ ...prev, [op.callHash]: e.target.value }))}
rows={2}
/>
{decodeErrors[op.callHash] && <p className="error-text">{decodeErrors[op.callHash]}</p>}
<button className="btn-outline" onClick={() => handleDecode(op.callHash)} disabled={!manualCallData[op.callHash]}>
Decode
</button>
</div>
)}
<div className="op-actions">
<button
className="btn-primary"
onClick={() => handleApprove(op)}
disabled={!op.resolvedCall || alreadyApproved || isProcessing}
>
{isProcessing ? 'Processing' : 'Approve'}
</button>
{canReject && (
<button className="btn-danger" onClick={() => handleReject(op)} disabled={isProcessing}>
Reject
</button>
)}
</div>
</div>
);
})}
</div>
)}
<p className="threshold-note">
{USDT_MULTISIG_CONFIG.threshold} of {USDT_MULTISIG_CONFIG.members.length} signatures are required to execute any
operation - no single signer, including this site, can move funds alone.
</p>
</section>
);
}
export default function App() {
const [api, setApi] = useState<ApiPromise | null>(null);
const [apiReady, setApiReady] = useState(false);
const [account, setAccount] = useState<InjectedAccountWithMeta | null>(null);
const [accounts, setAccounts] = useState<InjectedAccountWithMeta[]>([]);
const [authorizedSets, setAuthorizedSets] = useState<SignerSet[] | null>(null); // null = not checked yet
const [connecting, setConnecting] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null);
useEffect(() => {
connectApi()
.then((instance) => {
setApi(instance);
setApiReady(true);
})
.catch((err) => setConnectError(err instanceof Error ? err.message : 'Failed to connect to chain'));
}, []);
const handleConnect = useCallback(async () => {
setConnecting(true);
setConnectError(null);
try {
const accs = await web3Accounts();
if (accs.length === 0) {
setConnectError(
'No accounts found. Install a Pezkuwi/Substrate wallet extension, unlock it, and authorize this site.'
);
return;
}
setAccounts(accs);
setAccount(accs[0]);
} catch (err) {
setConnectError(err instanceof Error ? err.message : String(err));
} finally {
setConnecting(false);
}
}, []);
// Gate: once a wallet is connected, check membership across every known signer set. Anyone
// not authorized for at least one is bounced to pex.mom - this page has nothing to show them.
useEffect(() => {
if (!api || !apiReady || !account) return;
let cancelled = false;
Promise.all(SIGNER_SETS.map((set) => isMultisigMember(api, account.address, set.specificAddresses))).then(
(results) => {
if (cancelled) return;
const matched = SIGNER_SETS.filter((_, i) => results[i]);
if (matched.length === 0) {
window.location.href = PEX_MOM_URL;
return;
}
setAuthorizedSets(matched);
}
);
return () => {
cancelled = true;
};
}, [api, apiReady, account]);
return (
<div className="app">
<header className="app-header">
<h1>PezBridge Sign</h1>
<p className="subtitle">Signing portal for PezkuwiChain multisig operations</p>
</header>
{connectError && <div className="alert alert-error">{connectError}</div>}
{!account ? (
<div className="connect-box">
<p>Connect the wallet extension holding one of your authorized signer accounts.</p>
<button className="btn-primary" onClick={handleConnect} disabled={connecting || !apiReady}>
{connecting ? 'Connecting' : !apiReady ? 'Connecting to chain' : 'Connect Wallet'}
</button>
</div>
) : authorizedSets === null ? (
<div className="loading">Verifying signer status on-chain</div>
) : (
<>
{accounts.length > 1 && (
<div className="account-switcher">
<label htmlFor="account-select">Account:</label>
<select
id="account-select"
value={account.address}
onChange={(e) => {
const next = accounts.find((a) => a.address === e.target.value) ?? null;
setAccount(next);
setAuthorizedSets(null);
}}
>
{accounts.map((a) => (
<option key={a.address} value={a.address}>
{a.meta.name ?? a.address}
</option>
))}
</select>
</div>
)}
{api &&
authorizedSets.map((set) => <OperationsPanel key={set.name} api={api} account={account} signerSet={set} />)}
</>
)}
</div>
);
}
+242
View File
@@ -0,0 +1,242 @@
:root {
color-scheme: dark;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #0a0e14;
color: #e6e9ef;
}
.app {
max-width: 720px;
margin: 0 auto;
padding: 48px 20px;
}
.app-header {
text-align: center;
margin-bottom: 32px;
}
.app-header h1 {
margin: 0 0 4px;
font-size: 1.5rem;
}
.subtitle {
color: #8b93a3;
margin: 0;
font-size: 0.9rem;
}
.connect-box {
text-align: center;
padding: 40px 20px;
background: #131a24;
border: 1px solid #232c3a;
border-radius: 12px;
}
.connect-box p {
color: #8b93a3;
margin-bottom: 20px;
}
.account-switcher {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
font-size: 0.85rem;
color: #8b93a3;
}
.account-switcher select {
background: #131a24;
color: #e6e9ef;
border: 1px solid #232c3a;
border-radius: 6px;
padding: 4px 8px;
}
.panel {
background: #131a24;
border: 1px solid #232c3a;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.panel-header h2 {
margin: 0;
font-size: 1.1rem;
}
.loading,
.empty {
text-align: center;
color: #8b93a3;
padding: 24px 0;
}
.ops-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.op-card {
background: #0f1520;
border: 1px solid #232c3a;
border-radius: 8px;
padding: 14px;
}
.op-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.op-desc {
margin: 0;
font-weight: 600;
}
.op-hash {
font-size: 0.75rem;
color: #6b7385;
}
.op-meta {
font-size: 0.8rem;
color: #8b93a3;
margin: 8px 0 0;
}
.op-approved {
font-size: 0.8rem;
color: #4ade80;
margin: 4px 0 0;
}
.badge {
font-size: 0.75rem;
border: 1px solid #3a4557;
border-radius: 999px;
padding: 2px 10px;
white-space: nowrap;
color: #8b93a3;
}
.badge-ready {
border-color: #4ade80;
color: #4ade80;
}
.decode-box {
margin-top: 10px;
}
.decode-box textarea {
width: 100%;
background: #0a0e14;
color: #e6e9ef;
border: 1px solid #232c3a;
border-radius: 6px;
padding: 8px;
font-family: monospace;
font-size: 0.75rem;
margin: 6px 0;
}
.hint {
font-size: 0.75rem;
color: #e0b04a;
margin: 0;
}
.error-text {
font-size: 0.75rem;
color: #f87171;
}
.op-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.threshold-note {
margin-top: 16px;
font-size: 0.75rem;
color: #6b7385;
}
.btn-primary,
.btn-outline,
.btn-danger {
border-radius: 6px;
padding: 8px 16px;
font-size: 0.85rem;
cursor: pointer;
border: 1px solid transparent;
}
.btn-primary {
background: #4ade80;
color: #0a0e14;
font-weight: 600;
}
.btn-primary:disabled,
.btn-outline:disabled,
.btn-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-outline {
background: transparent;
color: #e6e9ef;
border-color: #3a4557;
}
.btn-danger {
background: #f87171;
color: #1a0a0a;
font-weight: 600;
}
.alert {
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 16px;
font-size: 0.85rem;
}
.alert-error {
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.4);
color: #f87171;
}
.alert-success {
background: rgba(74, 222, 128, 0.1);
border: 1px solid rgba(74, 222, 128, 0.4);
color: #4ade80;
}
+10
View File
@@ -0,0 +1,10 @@
import { ApiPromise, WsProvider } from '@pezkuwi/api';
// Same endpoint the rest of this ecosystem's tooling (pwap-web, PezbridgeBot) uses for Pezkuwi
// Asset Hub - the chain this multisig lives on.
const RPC_ENDPOINT = 'wss://asset-hub-rpc.pezkuwichain.io';
export function connectApi(): Promise<ApiPromise> {
const provider = new WsProvider(RPC_ENDPOINT);
return ApiPromise.create({ provider });
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Extension-only signer helper - deliberately simpler than pwap-web's get-signer.ts (which also
* supports WalletConnect for mobile). The real 5 multisig signatories operate from desktop/
* server contexts, and this site's whole purpose is to be a minimal, easy-to-audit surface -
* not pulling in the WalletConnect session-management stack keeps that true. Add it back here
* only if a signer genuinely needs to sign from a phone.
*/
import { web3Enable, web3FromAddress } from '@pezkuwi/extension-dapp';
export async function getSigner(address: string) {
await web3Enable('PezBridge Sign');
return web3FromAddress(address);
}
+12
View File
@@ -0,0 +1,12 @@
// Production Rollup alias for vite-plugin-node-polyfills/shims/process (mirrors web/'s copy -
// see its comment for why: Rollup can't resolve the plugin's virtual module during a real
// build, only in dev). IMPORTANT: must not reference the `process` identifier at runtime -
// vite-plugin-node-polyfills rewrites it to `__process_polyfill`, creating a circular TDZ. Use
// bracket notation so the plugin leaves this file alone.
const g: Record<string, unknown> =
typeof globalThis !== 'undefined' ? (globalThis as Record<string, unknown>)
: typeof window !== 'undefined' ? (window as unknown as Record<string, unknown>)
: {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default (g['process'] ?? { env: {} }) as any;
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+16
View File
@@ -0,0 +1,16 @@
import { BRIDGE_MULTISIG_SPECIFIC_ADDRESSES } from '@pezkuwi/lib/multisig';
export interface SignerSet {
name: string;
specificAddresses: Record<string, string>;
}
/**
* Every known multisig this site gates access for. The connected wallet is authorized if it's
* a member of ANY set below - add new entries here as new signing needs come up (this is the
* single registry the login gate checks against, per the design decision to keep one dedicated
* portal for all future signing needs rather than a bespoke UI per multisig).
*/
export const SIGNER_SETS: SignerSet[] = [
{ name: 'USDT Bridge Treasury', specificAddresses: BRIDGE_MULTISIG_SPECIFIC_ADDRESSES },
];
+32
View File
@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"resolveJsonModule": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitAny": false,
"noFallthroughCasesInSwitch": false,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@pezkuwi/lib": ["../shared/lib"],
"@pezkuwi/lib/*": ["../shared/lib/*"]
},
"types": ["vite/client"]
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+41
View File
@@ -0,0 +1,41 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import path from 'path';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
// Deliberately minimal compared to web/vite.config.ts: this app has exactly one job (gate +
// sign multisig operations), so it carries none of web/'s SPA routing, i18n, or UI-kit
// machinery - fewer dependencies is itself a security property for a signing-critical site.
export default defineConfig(({ command }) => ({
server: {
host: '::',
port: 8090,
},
plugins: [
react(),
nodePolyfills({
globals: { Buffer: true, global: true, process: true },
protocolImports: true,
}),
],
resolve: {
mainFields: ['browser', 'module', 'main', 'exports'],
alias: {
// Rollup cannot resolve the plugin's virtual shim module in production - alias to a real
// file (mirrors web/vite.config.ts's identical workaround). Dev mode leaves the plugin's
// own virtual module handling it.
...(command === 'build'
? { 'vite-plugin-node-polyfills/shims/process': path.resolve(__dirname, './src/lib/process-shim.ts') }
: {}),
'@': path.resolve(__dirname, './src'),
'@pezkuwi/lib': path.resolve(__dirname, '../shared/lib'),
},
dedupe: ['react', '@pezkuwi/util-crypto', '@pezkuwi/util', '@pezkuwi/api', '@pezkuwi/extension-dapp', '@pezkuwi/keyring'],
},
optimizeDeps: {
include: ['@pezkuwi/util-crypto', '@pezkuwi/util', '@pezkuwi/api', '@pezkuwi/extension-dapp', '@pezkuwi/keyring', 'buffer'],
},
build: {
chunkSizeWarningLimit: 600,
},
}));
+6
View File
@@ -32,6 +32,7 @@ const ProfileSettings = lazy(() => import('@/pages/ProfileSettings'));
const AdminPanel = lazy(() => import('@/pages/AdminPanel'));
const WalletDashboard = lazy(() => import('./pages/WalletDashboard'));
const ReservesDashboardPage = lazy(() => import('./pages/ReservesDashboardPage'));
const MultisigOperationsPage = lazy(() => import('./pages/MultisigOperationsPage'));
const BeCitizen = lazy(() => import('./pages/BeCitizen'));
const Identity = lazy(() => import('./pages/Identity'));
const Bereketli = lazy(() => import('./pages/Bereketli'));
@@ -192,6 +193,11 @@ function App() {
<ReservesDashboardPage />
</ProtectedRoute>
} />
<Route path="/multisig/pending" element={
<ProtectedRoute requireMultisigMember>
<MultisigOperationsPage />
</ProtectedRoute>
} />
<Route path="/elections" element={
<ProtectedRoute>
<Elections />
+301
View File
@@ -0,0 +1,301 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Shield, RefreshCw, CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { getSigner } from '@/lib/get-signer';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
import {
getMultisigMembers,
calculateMultisigAddress,
approveMultisigTx,
cancelMultisigTx,
USDT_MULTISIG_CONFIG,
BRIDGE_MULTISIG_SPECIFIC_ADDRESSES,
} from '@pezkuwi/lib/multisig';
import {
listPendingOperations,
decodeAndVerifyCallData,
type PendingOperation,
} from '@pezkuwi/lib/multisig-operations';
interface MultisigOperationsProps {
specificAddresses?: Record<string, string>;
}
export const MultisigOperations: React.FC<MultisigOperationsProps> = ({
specificAddresses = BRIDGE_MULTISIG_SPECIFIC_ADDRESSES,
}) => {
const { t } = useTranslation();
const { api, isApiReady, selectedAccount, walletSource } = usePezkuwi();
const [multisigAddress, setMultisigAddress] = useState('');
const [otherSignatories, setOtherSignatories] = useState<string[]>([]);
const [operations, setOperations] = useState<PendingOperation[]>([]);
const [loading, setLoading] = useState(true);
const [processingHash, setProcessingHash] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [manualCallData, setManualCallData] = useState<Record<string, string>>({});
const [decodeErrors, setDecodeErrors] = useState<Record<string, string>>({});
const load = useCallback(async () => {
if (!api || !isApiReady) return;
setLoading(true);
setError(null);
try {
const allMembers = await getMultisigMembers(api, specificAddresses);
const multisigAddr = calculateMultisigAddress(allMembers);
setMultisigAddress(multisigAddr);
if (selectedAccount) {
setOtherSignatories(allMembers.filter((addr) => addr !== selectedAccount.address));
}
const ops = await listPendingOperations(api, multisigAddr);
setOperations(ops);
} catch (err) {
if (import.meta.env.DEV) console.error('Error loading pending multisig operations:', err);
setError(err instanceof Error ? err.message : 'Failed to load pending operations');
} finally {
setLoading(false);
}
}, [api, isApiReady, selectedAccount, specificAddresses]);
useEffect(() => {
load();
}, [load]);
const handleDecode = (callHash: string) => {
if (!api) return;
const hex = manualCallData[callHash];
if (!hex) return;
try {
const { call, description } = decodeAndVerifyCallData(api, hex, callHash);
setOperations((prev) =>
prev.map((op) =>
op.callHash === callHash ? { ...op, resolvedCall: call, description, isAutoMatched: false } : op
)
);
setDecodeErrors((prev) => ({ ...prev, [callHash]: '' }));
} catch (err) {
setDecodeErrors((prev) => ({
...prev,
[callHash]: err instanceof Error ? err.message : 'Failed to decode call data',
}));
}
};
const runExtrinsic = async (
callHash: string,
tx: ReturnType<typeof approveMultisigTx> | ReturnType<typeof cancelMultisigTx>,
successMessage: string
) => {
if (!api || !selectedAccount) return;
setProcessingHash(callHash);
setError(null);
setSuccess(null);
try {
const injector = await getSigner(selectedAccount.address, walletSource, api);
await new Promise<void>((resolve, reject) => {
tx.signAndSend(selectedAccount.address, { signer: injector.signer }, ({ status, dispatchError }) => {
if (status.isInBlock || status.isFinalized) {
if (dispatchError) {
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`));
} else {
reject(new Error(dispatchError.toString()));
}
} else {
resolve();
}
}
}).catch(reject);
});
setSuccess(successMessage);
await load();
} catch (err) {
if (import.meta.env.DEV) console.error('Multisig operation submission failed:', err);
setError(err instanceof Error ? err.message : 'Transaction failed');
} finally {
setProcessingHash(null);
}
};
const handleApprove = (op: PendingOperation) => {
if (!api || !op.resolvedCall) return;
const tx = approveMultisigTx(api, op.resolvedCall, otherSignatories, op.when, op.threshold);
runExtrinsic(op.callHash, tx, 'Approval submitted successfully.');
};
const handleReject = (op: PendingOperation) => {
if (!api) return;
const tx = cancelMultisigTx(api, op.callHash, otherSignatories, op.when, op.threshold);
runExtrinsic(op.callHash, tx, 'Operation cancelled successfully.');
};
if (loading) {
return (
<Card className="p-6 bg-gray-800/50 border-gray-700">
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
</div>
</Card>
);
}
return (
<Card className="p-6 bg-gray-800/50 border-gray-700">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Shield className="h-6 w-6 text-blue-400" />
<div>
<h3 className="text-lg font-bold text-white">{t('multisigOps.title')}</h3>
<p className="text-sm text-gray-400">
{t('multisigOps.subtitle', { count: operations.length })}
</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={load} className="border-gray-700">
<RefreshCw className="h-4 w-4 mr-2" />
{t('multisigOps.refresh')}
</Button>
</div>
{error && (
<Alert className="mb-4 bg-red-900/20 border-red-500">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert className="mb-4 bg-green-900/20 border-green-500">
<CheckCircle2 className="h-4 w-4" />
<AlertDescription>{success}</AlertDescription>
</Alert>
)}
{operations.length === 0 ? (
<div className="text-center py-8 text-gray-400">{t('multisigOps.empty')}</div>
) : (
<div className="space-y-4">
{operations.map((op) => {
const alreadyApproved = selectedAccount ? op.approvals.includes(selectedAccount.address) : false;
const canReject = selectedAccount ? op.depositor === selectedAccount.address : false;
const isProcessing = processingHash === op.callHash;
return (
<div key={op.callHash} className="p-4 bg-gray-900/30 rounded-lg space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<p className="font-semibold text-white">{op.description}</p>
<code className="text-xs text-gray-500 font-mono">
{op.callHash.slice(0, 10)}...{op.callHash.slice(-8)}
</code>
</div>
<Badge
variant={op.approvalsCount >= op.threshold ? 'default' : 'outline'}
className="whitespace-nowrap"
>
{op.approvalsCount}/{op.threshold} {t('multisigOps.approvals')}
</Badge>
</div>
<div className="text-xs text-gray-400 space-y-1">
<p>
{t('multisigOps.proposedBy')}:{' '}
<code className="font-mono">
{op.depositor.slice(0, 8)}...{op.depositor.slice(-6)}
</code>
</p>
{alreadyApproved && (
<p className="text-green-400 flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" /> {t('multisigOps.youApproved')}
</p>
)}
</div>
{!op.resolvedCall && (
<div className="space-y-2">
<p className="text-xs text-yellow-500">{t('multisigOps.unknownCallHint')}</p>
<Textarea
placeholder={t('multisigOps.pasteCallData')}
value={manualCallData[op.callHash] ?? ''}
onChange={(e) =>
setManualCallData((prev) => ({ ...prev, [op.callHash]: e.target.value }))
}
className="bg-gray-800 border-gray-700 text-white text-xs font-mono"
rows={2}
/>
{decodeErrors[op.callHash] && (
<p className="text-xs text-red-400">{decodeErrors[op.callHash]}</p>
)}
<Button
size="sm"
variant="outline"
onClick={() => handleDecode(op.callHash)}
disabled={!manualCallData[op.callHash]}
>
{t('multisigOps.decode')}
</Button>
</div>
)}
<div className="flex gap-2">
<Button
size="sm"
onClick={() => handleApprove(op)}
disabled={!op.resolvedCall || alreadyApproved || isProcessing || !selectedAccount}
className="bg-green-600 hover:bg-green-700"
>
{isProcessing ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4 mr-2" />
)}
{t('multisigOps.approve')}
</Button>
{canReject && (
<Button
size="sm"
variant="destructive"
onClick={() => handleReject(op)}
disabled={isProcessing}
>
<XCircle className="h-4 w-4 mr-2" />
{t('multisigOps.reject')}
</Button>
)}
</div>
</div>
);
})}
</div>
)}
<Alert className="mt-6 bg-blue-900/20 border-blue-500">
<Shield className="h-4 w-4" />
<AlertDescription className="text-sm">
{t('multisigOps.thresholdNote', {
threshold: USDT_MULTISIG_CONFIG.threshold,
total: USDT_MULTISIG_CONFIG.members.length,
})}
</AlertDescription>
</Alert>
{multisigAddress && (
<p className="mt-4 text-xs text-gray-500 text-center font-mono">{multisigAddress}</p>
)}
</Card>
);
};
+77 -5
View File
@@ -4,11 +4,16 @@ import { useAuth } from '@/contexts/AuthContext';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
import { Loader2, Wallet } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { isMultisigMember, BRIDGE_MULTISIG_SPECIFIC_ADDRESSES } from '@pezkuwi/lib/multisig';
interface ProtectedRouteProps {
children: React.ReactNode;
requireAdmin?: boolean;
allowTelegramSession?: boolean;
/** Gates access to the connected account being one of the 5 real USDT bridge multisig
* signatories (checked live against chain state, not a decorative badge like the
* isMultisigMember usage elsewhere in the app before this). */
requireMultisigMember?: boolean;
}
// Check if valid telegram session exists
@@ -32,14 +37,48 @@ function getTelegramSession(): { telegram_id: string; wallet_address: string; us
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children,
requireAdmin = false,
allowTelegramSession = false
allowTelegramSession = false,
requireMultisigMember = false
}) => {
const { user, loading, isAdmin } = useAuth();
const { selectedAccount, connectWallet } = usePezkuwi();
const { api, isApiReady, selectedAccount, connectWallet } = usePezkuwi();
const [walletRestoreChecked, setWalletRestoreChecked] = useState(false);
const [forceUpdate, setForceUpdate] = useState(0);
const [multisigCheckDone, setMultisigCheckDone] = useState(false);
const [isSigner, setIsSigner] = useState(false);
const telegramSession = allowTelegramSession ? getTelegramSession() : null;
// Live on-chain check - this is a REAL gate (unlike the isMultisigMember badge elsewhere in
// the app, which is purely decorative and gates nothing).
useEffect(() => {
if (!requireMultisigMember) return;
if (!api || !isApiReady || !selectedAccount) {
setMultisigCheckDone(false);
return;
}
let cancelled = false;
setMultisigCheckDone(false);
isMultisigMember(api, selectedAccount.address, BRIDGE_MULTISIG_SPECIFIC_ADDRESSES)
.then((result) => {
if (!cancelled) {
setIsSigner(result);
setMultisigCheckDone(true);
}
})
.catch(() => {
if (!cancelled) {
setIsSigner(false);
setMultisigCheckDone(true);
}
});
return () => {
cancelled = true;
};
}, [requireMultisigMember, api, isApiReady, selectedAccount]);
// Listen for wallet changes
useEffect(() => {
const handleWalletChange = () => {
@@ -81,8 +120,8 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
);
}
// For admin routes, require wallet connection
if (requireAdmin && !selectedAccount) {
// For admin/multisig-gated routes, require wallet connection
if ((requireAdmin || requireMultisigMember) && !selectedAccount) {
const handleConnect = async () => {
await connectWallet();
// Event is automatically dispatched by handleSetSelectedAccount wrapper
@@ -94,7 +133,9 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
<Wallet className="w-16 h-16 text-green-500 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-white mb-2">Connect Your Wallet</h2>
<p className="text-gray-400 mb-6">
Admin panel requires wallet authentication. Please connect your wallet to continue.
{requireMultisigMember
? 'This page requires connecting one of the USDT bridge multisig signer accounts.'
: 'Admin panel requires wallet authentication. Please connect your wallet to continue.'}
</p>
<Button onClick={handleConnect} size="lg" className="bg-green-600 hover:bg-green-700">
<Wallet className="mr-2 h-5 w-5" />
@@ -127,5 +168,36 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
);
}
if (requireMultisigMember) {
if (!multisigCheckDone) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-green-500 mx-auto mb-4" />
<p className="text-gray-400">Verifying multisig signer status on-chain...</p>
</div>
</div>
);
}
if (!isSigner) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<div className="text-center max-w-md">
<div className="text-red-500 text-6xl mb-4"></div>
<h2 className="text-2xl font-bold text-white mb-2">Access Denied</h2>
<p className="text-gray-400 mb-4">
Your wallet ({selectedAccount?.address.slice(0, 8)}...) is not one of the 5 USDT
bridge multisig signatories.
</p>
<p className="text-sm text-gray-500">
Only Serok, SerokiMeclise, Xezinedar, Noter, and Berdevk can access this page.
</p>
</div>
</div>
);
}
}
return <>{children}</>;
};
+42 -32
View File
@@ -14,9 +14,10 @@ import {
getWithdrawalTier,
formatDelay,
formatWUSDT,
createWithdrawalInitiationTx,
deriveWithdrawalDestination,
} from '@pezkuwi/lib/usdt';
import { isMultisigMember } from '@pezkuwi/lib/multisig';
import { ASSET_IDS } from '@pezkuwi/lib/wallet';
interface USDTBridgeProps {
isOpen: boolean;
@@ -35,7 +36,6 @@ export const USDTBridge: React.FC<USDTBridgeProps> = ({
const [depositAmount, setDepositAmount] = useState('');
const [withdrawAmount, setWithdrawAmount] = useState('');
const [withdrawAddress, setWithdrawAddress] = useState(''); // Bank account or crypto address
const [wusdtBalance, setWusdtBalance] = useState(0);
const [isMultisigMemberState, setIsMultisigMemberState] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@@ -88,7 +88,14 @@ export const USDTBridge: React.FC<USDTBridgeProps> = ({
}
};
// Handle withdrawal (burn wUSDT)
// Handle withdrawal - the user transfers their OWN wUSDT to the multisig custody account.
// This used to call `assets.burn` directly, signed by the user - but burn requires Admin
// origin (the multisig), so that call has always failed on-chain with NoPermission. The bug
// went unnoticed because the callback only checked `status.isFinalized`, and a FAILED
// dispatch is still included/finalized as a valid extrinsic - so every withdrawal reported
// "success" while silently doing nothing. usdt-bridge's relayer (listen_withdrawals) actually
// watches for exactly this transfer-to-custody event and records it as pending_multisig
// approval; burning happens later, once the multisig actually releases real USDT.
const handleWithdrawal = async () => {
if (!api || !selectedAccount) return;
@@ -104,34 +111,40 @@ export const USDTBridge: React.FC<USDTBridgeProps> = ({
return;
}
if (!withdrawAddress) {
setError(t('bridge.noAddress'));
return;
}
setIsLoading(true);
setError(null);
setSuccess(null);
try {
const injector = await getSigner(selectedAccount.address, walletSource, api);
const transferTx = createWithdrawalInitiationTx(api, amount);
// Burn wUSDT
const amountBN = BigInt(Math.floor(amount * 1e6)); // 6 decimals
const burnTx = api.tx.assets.burn(ASSET_IDS.WUSDT, selectedAccount.address, amountBN.toString());
await burnTx.signAndSend(selectedAccount.address, { signer: injector.signer }, ({ status }) => {
if (status.isFinalized) {
const delay = calculateWithdrawalDelay(amount);
setSuccess(
t('bridge.withdrawSuccess', { address: withdrawAddress, delay: formatDelay(delay) })
);
setWithdrawAmount('');
setWithdrawAddress('');
refreshBalances();
setIsLoading(false);
}
await new Promise<void>((resolve, reject) => {
transferTx
.signAndSend(selectedAccount.address, { signer: injector.signer }, ({ status, dispatchError }) => {
if (status.isInBlock || status.isFinalized) {
if (dispatchError) {
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`));
} else {
reject(new Error(dispatchError.toString()));
}
} else {
resolve();
}
}
})
.catch(reject);
});
const delay = calculateWithdrawalDelay(amount);
setSuccess(
t('bridge.withdrawSuccess', { address: withdrawDestination, delay: formatDelay(delay) })
);
setWithdrawAmount('');
refreshBalances();
setIsLoading(false);
} catch (err) {
if (import.meta.env.DEV) console.error('Withdrawal error:', err);
setError(err instanceof Error ? err.message : 'Withdrawal failed');
@@ -143,6 +156,7 @@ export const USDTBridge: React.FC<USDTBridgeProps> = ({
const withdrawalTier = withdrawAmount ? getWithdrawalTier(parseFloat(withdrawAmount)) : null;
const withdrawalDelay = withdrawAmount ? calculateWithdrawalDelay(parseFloat(withdrawAmount)) : 0;
const withdrawDestination = selectedAccount ? deriveWithdrawalDestination(selectedAccount.address) : '';
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
@@ -299,14 +313,10 @@ export const USDTBridge: React.FC<USDTBridgeProps> = ({
<label className="block text-sm font-medium text-gray-300 mb-2">
{t('bridge.withdrawAddress')}
</label>
<input
type="text"
value={withdrawAddress}
onChange={(e) => setWithdrawAddress(e.target.value)}
placeholder={t('bridge.addressPlaceholder')}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500 placeholder:text-gray-500 placeholder:opacity-50"
disabled={isLoading}
/>
<div className="w-full bg-gray-800/60 border border-gray-700 rounded-lg px-4 py-3">
<code className="text-sm text-gray-300 font-mono break-all">{withdrawDestination}</code>
</div>
<p className="text-xs text-gray-500 mt-1">{t('bridge.withdrawAddressAutoNote')}</p>
</div>
{withdrawAmount && parseFloat(withdrawAmount) > 0 && (
@@ -333,7 +343,7 @@ export const USDTBridge: React.FC<USDTBridgeProps> = ({
<Button
onClick={handleWithdrawal}
disabled={isLoading || !withdrawAmount || !withdrawAddress}
disabled={isLoading || !withdrawAmount || !selectedAccount}
className="w-full bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 h-12"
>
{isLoading ? (
+30
View File
@@ -0,0 +1,30 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft } from 'lucide-react';
import { MultisigOperations } from '@/components/MultisigOperations';
const MultisigOperationsPage = () => {
const navigate = useNavigate();
const { t } = useTranslation();
return (
<div className="min-h-screen bg-gray-950 pt-24 pb-12">
<div className="container mx-auto px-4 py-8 relative max-w-3xl">
<button
onClick={() => navigate('/wallet')}
className="absolute top-4 left-4 text-gray-400 hover:text-white transition-colors flex items-center gap-2"
>
<ArrowLeft className="w-5 h-5" />
<span>{t('multisigOps.backToWallet')}</span>
</button>
<div className="pt-16">
<MultisigOperations />
</div>
</div>
</div>
);
};
export default MultisigOperationsPage;
+3 -9
View File
@@ -4,13 +4,7 @@ import { ArrowLeft, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ReservesDashboard } from '@/components/ReservesDashboard';
import { USDTBridge } from '@/components/USDTBridge';
// USDT Treasury Multisig Member Addresses
const SPECIFIC_ADDRESSES = {
// Non-unique roles - manually specified
Noter: '5DFwqK698vL4gXHEcanaewnAqhxJ2rjhAogpSTHw3iwGDwd3',
Berdevk: '5F4V6dzpe72dE2C7YN3y7VGznMTWPFeSKL3ANhp4XasXjfvj',
};
import { BRIDGE_MULTISIG_SPECIFIC_ADDRESSES } from '@pezkuwi/lib/multisig';
const ReservesDashboardPage = () => {
const navigate = useNavigate();
@@ -42,7 +36,7 @@ const ReservesDashboardPage = () => {
{/* Main Content */}
<ReservesDashboard
specificAddresses={SPECIFIC_ADDRESSES}
specificAddresses={BRIDGE_MULTISIG_SPECIFIC_ADDRESSES}
offChainReserveAmount={offChainReserve}
/>
@@ -50,7 +44,7 @@ const ReservesDashboardPage = () => {
<USDTBridge
isOpen={isBridgeOpen}
onClose={() => setIsBridgeOpen(false)}
specificAddresses={SPECIFIC_ADDRESSES}
specificAddresses={BRIDGE_MULTISIG_SPECIFIC_ADDRESSES}
/>
</div>
</div>
+11 -2
View File
@@ -24,8 +24,17 @@
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
"@/*": ["./src/*"],
"@pezkuwi/i18n": ["../shared/i18n"],
"@pezkuwi/lib": ["../shared/lib"],
"@pezkuwi/lib/*": ["../shared/lib/*"],
"@pezkuwi/utils": ["../shared/utils"],
"@pezkuwi/theme": ["../shared/theme"],
"@pezkuwi/types": ["../shared/types"],
"@pezkuwi/components/*": ["../shared/components/*"],
"@shared/*": ["../shared/*"]
},
"types": ["vite/client"]
},
"include": ["src", "../shared"]
}