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
+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,
},
}));