mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-07-22 20:45:51 +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
|
||||
|
||||
Reference in New Issue
Block a user