mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-07-22 09:15:39 +00:00
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:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
Generated
+4251
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}</>;
|
||||
};
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user