275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
/**
|
|
* wallet-op-builders — cp424 (wallet security pass, foundation).
|
|
*
|
|
* The wallet's Power up (transfer_to_vesting), Power down
|
|
* (withdraw_vesting), and Send (transfer) all sign with the ACTIVE key
|
|
* and move money IRREVERSIBLY. This smoke pins the op-builder +
|
|
* balance-math foundation those flows stand on:
|
|
*
|
|
* 1. Amount math — blurtPowerToVests round-trips with vestsToBlurtPower
|
|
* (a wrong power-DOWN conversion = wrong amount unstaked); the
|
|
* op-amount formatters emit EXACTLY the chain shapes ("N.NNN BLURT",
|
|
* "N.NNNNNN VESTS") and REFUSE a non-finite / negative number.
|
|
* 2. Validation — the prepare* builders reject a malformed account or
|
|
* amount BEFORE the chain round-trip (the checks run before the
|
|
* network fetch, so this is exercisable offline); power-UP permits
|
|
* from === to (self power-up is normal), power-DOWN's VESTS shape is
|
|
* enforced.
|
|
* 3. GENUINE round-trip signing — POWER UP (transfer_to_vesting) is
|
|
* signed with the real op-layer signer (signTransferWithKey) and
|
|
* the signature is verified to recover to the signing key; a
|
|
* DIFFERENT key is rejected (the signature actually binds the op).
|
|
* 4. POWER DOWN (withdraw_vesting) — dblurt's serializer has NO entry
|
|
* for this op (op ID 4), so it's signed via the hand-rolled
|
|
* manualTransactionDigest (built from dblurt's exported Types) +
|
|
* signWithdrawVestingWithKey (noble). A BYTE-IDENTITY GUARD proves
|
|
* the manual digest matches dblurt's own digest for an op both know
|
|
* (transfer_to_vesting) — so the manual layout is provably correct
|
|
* and a dblurt format change is caught — then the withdraw_vesting
|
|
* signature is recovered against that manual digest (dblurt's
|
|
* verify path can't re-serialize this op either). Includes the
|
|
* cancel op (0.000000 VESTS) and the signer's defensive guards.
|
|
*
|
|
* The prepare* builders' happy path (which fetches ref_block over the
|
|
* network) is integration-tested manually against a live endpoint, same
|
|
* as prepareUnsignedTransfer; this smoke constructs a fixture tx for the
|
|
* signing scenarios (mirrors chain-op-verify-smoke).
|
|
*/
|
|
import {
|
|
PrivateKey,
|
|
Signature,
|
|
type AuthorityType,
|
|
type Transaction,
|
|
type SignedTransaction
|
|
} from '@beblurt/dblurt';
|
|
import { cryptoUtils } from '@beblurt/dblurt';
|
|
import { verifyTransactionSignatures } from '../src/lib/chat/chainOpVerifyCore.ts';
|
|
import {
|
|
signTransferWithKey,
|
|
prepareUnsignedTransferToVesting,
|
|
prepareUnsignedWithdrawVesting
|
|
} from '../src/lib/blurt/sign.ts';
|
|
import {
|
|
manualTransactionDigest,
|
|
signWithdrawVestingWithKey
|
|
} from '../src/lib/blurt/withdrawVestingSign.ts';
|
|
import {
|
|
blurtPowerToVests,
|
|
vestsToBlurtPower,
|
|
formatBlurtAmount,
|
|
formatVestsAmount
|
|
} from '../src/lib/blurt/balanceMath.ts';
|
|
|
|
let failures = 0;
|
|
function check(name: string, cond: boolean, detail = ''): void {
|
|
if (cond) {
|
|
console.log(` ✓ ${name}`);
|
|
} else {
|
|
console.log(` ✗ ${name}${detail ? `\n ${detail}` : ''}`);
|
|
failures++;
|
|
}
|
|
}
|
|
async function throwsAsync(fn: () => Promise<unknown>): Promise<boolean> {
|
|
try {
|
|
await fn();
|
|
return false;
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
function throwsSync(fn: () => unknown): boolean {
|
|
try {
|
|
fn();
|
|
return false;
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
async function run(): Promise<void> {
|
|
console.log('wallet-op-builders smoke');
|
|
|
|
// ─── 1. Amount math ────────────────────────────────────────────
|
|
// Fixture pool: 1,000,000 BLURT staked across 500,000,000 VESTS →
|
|
// 1 BP = 500 VESTS. (Numbers chosen so the ratio is exact.)
|
|
const FUND = '1000000.000 BLURT';
|
|
const TOTAL_VESTS = '500000000.000000 VESTS';
|
|
check('blurtPowerToVests: 10 BP → 5000 VESTS at the fixture ratio', blurtPowerToVests(10, FUND, TOTAL_VESTS) === 5000);
|
|
check(
|
|
'round-trips with vestsToBlurtPower (5000 VESTS → 10 BP)',
|
|
vestsToBlurtPower('5000.000000 VESTS', FUND, TOTAL_VESTS) === 10
|
|
);
|
|
check('blurtPowerToVests: zero fund (degenerate pool) → NaN', Number.isNaN(blurtPowerToVests(10, '0.000 BLURT', TOTAL_VESTS)));
|
|
check('blurtPowerToVests: NaN bp → NaN', Number.isNaN(blurtPowerToVests(NaN, FUND, TOTAL_VESTS)));
|
|
|
|
check('formatBlurtAmount: 1.5 → "1.500 BLURT" (exact 3 decimals)', formatBlurtAmount(1.5) === '1.500 BLURT');
|
|
check('formatBlurtAmount: 0 → "0.000 BLURT"', formatBlurtAmount(0) === '0.000 BLURT');
|
|
check('formatBlurtAmount: rounds to 3 decimals (1.23456 → "1.235 BLURT")', formatBlurtAmount(1.23456) === '1.235 BLURT');
|
|
check('formatBlurtAmount: REFUSES NaN', throwsSync(() => formatBlurtAmount(NaN)));
|
|
check('formatBlurtAmount: REFUSES negative', throwsSync(() => formatBlurtAmount(-1)));
|
|
check('formatVestsAmount: 5000 → "5000.000000 VESTS" (exact 6 decimals)', formatVestsAmount(5000) === '5000.000000 VESTS');
|
|
check('formatVestsAmount: REFUSES NaN', throwsSync(() => formatVestsAmount(NaN)));
|
|
|
|
// ─── 2. Validation (runs before the network fetch) ─────────────
|
|
check(
|
|
'transfer_to_vesting: rejects a malformed from account',
|
|
await throwsAsync(() => prepareUnsignedTransferToVesting('BAD!', 'alice', '1.000 BLURT'))
|
|
);
|
|
check(
|
|
'transfer_to_vesting: rejects a malformed to account',
|
|
await throwsAsync(() => prepareUnsignedTransferToVesting('alice', 'BAD!', '1.000 BLURT'))
|
|
);
|
|
check(
|
|
'transfer_to_vesting: rejects a non-3-decimal amount',
|
|
await throwsAsync(() => prepareUnsignedTransferToVesting('alice', 'alice', '1.0 BLURT'))
|
|
);
|
|
check(
|
|
'transfer_to_vesting: rejects a VESTS amount (wrong asset)',
|
|
await throwsAsync(() => prepareUnsignedTransferToVesting('alice', 'alice', '1.000000 VESTS'))
|
|
);
|
|
check(
|
|
'withdraw_vesting: rejects a malformed account',
|
|
await throwsAsync(() => prepareUnsignedWithdrawVesting('BAD!', '1.000000 VESTS'))
|
|
);
|
|
check(
|
|
'withdraw_vesting: rejects a non-6-decimal VESTS amount',
|
|
await throwsAsync(() => prepareUnsignedWithdrawVesting('alice', '1.000 VESTS'))
|
|
);
|
|
check(
|
|
'withdraw_vesting: rejects a BLURT amount (wrong asset)',
|
|
await throwsAsync(() => prepareUnsignedWithdrawVesting('alice', '1.000 BLURT'))
|
|
);
|
|
|
|
// ─── 3. Genuine round-trip signing ─────────────────────────────
|
|
const priv = PrivateKey.fromSeed('morphit wallet op-builders smoke seed');
|
|
const pub = priv.createPublic('BLT').toString();
|
|
// The op-layer signer takes a raw 32-byte active-key scalar; extract
|
|
// it from the dblurt PrivateKey (same bytes rawToPrivateKey wraps).
|
|
const rawScalar = Uint8Array.from((priv as unknown as { key: Uint8Array }).key);
|
|
check('extracted a 32-byte active-key scalar for signing', rawScalar.length === 32);
|
|
|
|
const otherPriv = PrivateKey.fromSeed('a DIFFERENT unrelated key seed');
|
|
const otherPub = otherPriv.createPublic('BLT').toString();
|
|
|
|
const authFor = (key: string): AuthorityType => ({
|
|
weight_threshold: 1,
|
|
account_auths: [],
|
|
key_auths: [[key, 1]]
|
|
});
|
|
const fixtureRef = { ref_block_num: 4321, ref_block_prefix: 987654, expiration: '2035-01-01T00:00:00' };
|
|
|
|
const powerUpTx: Transaction = {
|
|
...fixtureRef,
|
|
operations: [['transfer_to_vesting', { from: 'alice', to: 'alice', amount: '1.500 BLURT' }]],
|
|
extensions: []
|
|
};
|
|
const powerUpSigned = signTransferWithKey(powerUpTx, rawScalar);
|
|
const powerUpOk = await verifyTransactionSignatures(powerUpSigned, authFor(pub));
|
|
check(
|
|
'transfer_to_vesting: signed with the active key → verifies (weightSum 1)',
|
|
powerUpOk.ok === true && powerUpOk.weightSum === 1,
|
|
JSON.stringify(powerUpOk)
|
|
);
|
|
const powerUpWrong = await verifyTransactionSignatures(powerUpSigned, authFor(otherPub));
|
|
check(
|
|
'transfer_to_vesting: does NOT verify against a different key',
|
|
powerUpWrong.ok === false,
|
|
JSON.stringify(powerUpWrong)
|
|
);
|
|
|
|
// ─── 4. withdraw_vesting (POWER DOWN) — hand-serialized path ────
|
|
// dblurt's serializer has NO entry for withdraw_vesting (op ID 4), so
|
|
// power-down signs via manualTransactionDigest (built from dblurt's
|
|
// EXPORTED Types primitives) + the noble signer. Two things must hold:
|
|
// (a) BYTE-IDENTITY GUARD — the manual digest matches dblurt's own
|
|
// digest for an op BOTH can serialize (transfer_to_vesting). If a
|
|
// dblurt upgrade ever changes the byte format (or adds
|
|
// withdraw_vesting), this catches it.
|
|
const oracleDblurt = Buffer.from(cryptoUtils.transactionDigest(powerUpTx)).toString('hex');
|
|
const oracleManual = Buffer.from(manualTransactionDigest(powerUpTx)).toString('hex');
|
|
check(
|
|
'byte-identity: manual digest == dblurt digest for transfer_to_vesting',
|
|
oracleDblurt === oracleManual,
|
|
`dblurt=${oracleDblurt} manual=${oracleManual}`
|
|
);
|
|
|
|
// (b) GENUINE round-trip — sign a withdraw_vesting tx with the real
|
|
// power-down signer and verify the signature recovers to the key.
|
|
// verifyTransactionSignatures itself re-serializes via dblurt (so
|
|
// it can't handle this op), so recovery is done directly against
|
|
// the manual digest — which the guard above just proved correct.
|
|
const recoverSigner = (signed: SignedTransaction, tx: Transaction): string | null => {
|
|
try {
|
|
const digest = Buffer.from(manualTransactionDigest(tx));
|
|
return Signature.fromString(signed.signatures[0]!).recover(digest).toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const powerDownTx: Transaction = {
|
|
...fixtureRef,
|
|
operations: [['withdraw_vesting', { account: 'alice', vesting_shares: '5000.000000 VESTS' }]],
|
|
extensions: []
|
|
};
|
|
const powerDownSigned = signWithdrawVestingWithKey(powerDownTx, rawScalar);
|
|
const powerDownSigner = recoverSigner(powerDownSigned, powerDownTx);
|
|
check(
|
|
'withdraw_vesting: signature recovers to the signing active key',
|
|
powerDownSigner === pub,
|
|
`recovered ${powerDownSigner}`
|
|
);
|
|
check(
|
|
'withdraw_vesting: does NOT recover to a different key',
|
|
powerDownSigner !== null && powerDownSigner !== otherPub
|
|
);
|
|
// "power down everything" cancel op (0.000000 VESTS) still signs + recovers.
|
|
const cancelTx: Transaction = {
|
|
...fixtureRef,
|
|
operations: [['withdraw_vesting', { account: 'alice', vesting_shares: '0.000000 VESTS' }]],
|
|
extensions: []
|
|
};
|
|
check(
|
|
'withdraw_vesting: cancel (0.000000 VESTS) signs + recovers to the key',
|
|
recoverSigner(signWithdrawVestingWithKey(cancelTx, rawScalar), cancelTx) === pub
|
|
);
|
|
|
|
// Defence: the power-down signer refuses a malformed scalar AND any tx
|
|
// that isn't withdraw_vesting-only (it bypasses dblurt's op dispatch).
|
|
check(
|
|
'signWithdrawVestingWithKey: rejects a non-32-byte scalar',
|
|
throwsSync(() => signWithdrawVestingWithKey(powerDownTx, new Uint8Array(16)))
|
|
);
|
|
check(
|
|
'signWithdrawVestingWithKey: refuses a non-withdraw_vesting tx',
|
|
throwsSync(() => signWithdrawVestingWithKey(powerUpTx, rawScalar))
|
|
);
|
|
check(
|
|
'manualTransactionDigest: throws on an op it has no serializer for',
|
|
throwsSync(() =>
|
|
manualTransactionDigest({
|
|
...fixtureRef,
|
|
operations: [['vote', { voter: 'a', author: 'b', permlink: 'c', weight: 1 }]],
|
|
extensions: []
|
|
} as Transaction)
|
|
)
|
|
);
|
|
|
|
// Signing must reject a malformed raw scalar (defence at the signer).
|
|
check(
|
|
'signTransferWithKey: rejects a non-32-byte scalar',
|
|
throwsSync(() => signTransferWithKey(powerUpTx, new Uint8Array(16)))
|
|
);
|
|
|
|
const scenarios = 28;
|
|
console.log(`\n${'─'.repeat(56)}`);
|
|
if (failures === 0) {
|
|
console.log(`✓ all ${scenarios} wallet-op-builders scenarios passed`);
|
|
process.exit(0);
|
|
} else {
|
|
console.log(`✗ ${failures}/${scenarios} wallet-op-builders scenarios failed`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
void run();
|