Reject legacy addresses at CARROT heights, fix wallet B sync bug

After HF10 legacy CryptoNote addresses cannot be used — CARROT outputs built
   with legacy pubkeys are undetectable by the receiver's CARROT scanner because
   the CN view key differs from the CARROT viewIncomingKey. transfer() and sweep()
   now fail fast with a clear error when a SaLv... address is used post-HF10,
   using the network-aware hard fork table (not hardcoded heights).
This commit is contained in:
Matt Hess
2026-02-12 17:49:04 +00:00
parent e1e656bd33
commit 102f65b180
5 changed files with 141 additions and 3 deletions
+1
View File
@@ -112,6 +112,7 @@ export function parseAddress(address) {
result.paymentId = data.slice(KEY_SIZE * 2, KEY_SIZE * 2 + PAYMENT_ID_SIZE);
}
result.isCarrot = result.format === 'carrot';
result.valid = true;
return result;
}
+2 -1
View File
@@ -1102,7 +1102,8 @@ export class WalletSync {
const rct = {
type: txJson.rct_signatures?.type || 0,
txnFee: this._safeBigInt(txJson.rct_signatures?.txnFee),
// p_r is the ephemeral pubkey used for CARROT output scanning
// p_r is the mask difference commitment in Salvium RCT (NOT the CARROT ephemeral pubkey D_e).
// D_e is stored as txPubKey in tx_extra (tag 0x01).
p_r: txJson.rct_signatures?.p_r ? hexToBytes(txJson.rct_signatures.p_r) : null,
outPk: (txJson.rct_signatures?.outPk || []).map(pk => {
if (typeof pk === 'string') return hexToBytes(pk);
+23
View File
@@ -272,6 +272,7 @@ export async function transfer({ wallet, daemon, destinations, options = {} }) {
viewPublicKey: parsed.viewPublicKey,
spendPublicKey: parsed.spendPublicKey,
isSubaddress: parsed.type === 'subaddress',
isCarrot: parsed.isCarrot,
amount: typeof d.amount === 'bigint' ? d.amount : BigInt(d.amount)
};
});
@@ -290,6 +291,20 @@ export async function transfer({ wallet, daemon, destinations, options = {} }) {
blockWeightMedian: infoData?.block_weight_median || infoData?.block_weight_limit / 2 || 300000,
};
// 3b. Reject legacy addresses at CARROT heights (fail fast before UTXO work).
// After HF10, only CARROT addresses are valid. Legacy CryptoNote pubkeys differ
// from CARROT pubkeys, so the receiver's scanner would never detect the output.
if (isCarrotActive(currentHeight, options.network)) {
for (const d of parsedDests) {
if (d.isCarrot === false) {
throw new Error(
'Legacy address cannot be used at CARROT heights (post-HF10). ' +
'Use a CARROT address (SC1...) instead.'
);
}
}
}
// 4. Get spendable outputs — use HF-based asset type if not specified
const assetType = assetTypeOpt || getActiveAssetType(currentHeight, options.network);
const allOutputs = await wallet.storage.getOutputs({
@@ -499,6 +514,14 @@ export async function sweep({ wallet, daemon, address, options = {} }) {
blockWeightMedian: infoData?.block_weight_median || infoData?.block_weight_limit / 2 || 300000,
};
// Reject legacy addresses at CARROT heights
if (isCarrotActive(currentHeight, options.network) && parsed.isCarrot === false) {
throw new Error(
'Legacy address cannot be used at CARROT heights (post-HF10). ' +
'Use a CARROT address (SC1...) instead.'
);
}
// Use HF-based asset type detection (like C++ wallet2)
const assetType = assetTypeOpt || getActiveAssetType(currentHeight, options.network);
+7 -2
View File
@@ -390,8 +390,13 @@ async function phaseCN(walletA, walletB) {
banner('PHASE 1: CN ERA (pre-CARROT, height < 1100)');
await refreshAssetType();
const addrA = walletA.getLegacyAddress();
const addrB = walletB.getLegacyAddress();
// Post-HF10: CARROT outputs require CARROT addresses. Using legacy addresses
// at CARROT heights causes a pubkey mismatch (legacy CN view key != CARROT view key),
// making outputs undetectable by the receiver's CARROT scanner.
const h = await getHeight(daemon);
const postCarrot = h >= CARROT_FORK_HEIGHT;
const addrA = postCarrot ? walletA.getCarrotAddress() : walletA.getLegacyAddress();
const addrB = postCarrot ? walletB.getCarrotAddress() : walletB.getLegacyAddress();
console.log(` A address: ${short(addrA)}`);
console.log(` B address: ${short(addrB)}`);
+108
View File
@@ -448,6 +448,114 @@ test('WALLET_TYPE constants exist', () => {
assertEqual(WALLET_TYPE.WATCH, 'watch');
});
// Legacy address rejection at CARROT heights
console.log('\n--- Legacy Address Rejection ---');
import { transfer } from '../src/wallet/transfer.js';
// Mock daemon that returns a configurable height
function mockDaemon(height) {
return {
getInfo: async () => ({
success: true,
result: { height, block_weight_median: 300000 }
})
};
}
test('transfer rejects legacy address after HF10 (testnet)', async () => {
const wallet = Wallet.create('testnet');
const legacyAddr = wallet.getLegacyAddress();
const mockD = mockDaemon(1200); // post-CARROT (HF10 = height 1100 on testnet)
let threw = false;
try {
await transfer({
wallet, daemon: mockD,
destinations: [{ address: legacyAddr, amount: 100000000n }],
options: { network: 'testnet', assetType: 'SAL1' }
});
} catch (e) {
threw = true;
assert(e.message.includes('Legacy address cannot be used'), `Wrong error: ${e.message}`);
}
assert(threw, 'Should have thrown for legacy address at CARROT height');
});
test('transfer accepts legacy address before HF10 (testnet)', async () => {
const wallet = Wallet.create('testnet');
const legacyAddr = wallet.getLegacyAddress();
const mockD = mockDaemon(500); // pre-CARROT
let legacyRejected = false;
try {
await transfer({
wallet, daemon: mockD,
destinations: [{ address: legacyAddr, amount: 100000000n }],
options: { network: 'testnet', assetType: 'SAL' }
});
} catch (e) {
// Should fail for other reasons (no UTXOs), but NOT legacy address rejection
if (e.message.includes('Legacy address cannot be used')) legacyRejected = true;
}
assert(!legacyRejected, 'Should NOT reject legacy address before HF10');
});
test('transfer accepts CARROT address after HF10 (testnet)', async () => {
const wallet = Wallet.create('testnet');
const carrotAddr = wallet.getCarrotAddress();
const mockD = mockDaemon(1200); // post-CARROT
let legacyRejected = false;
try {
await transfer({
wallet, daemon: mockD,
destinations: [{ address: carrotAddr, amount: 100000000n }],
options: { network: 'testnet', assetType: 'SAL1' }
});
} catch (e) {
if (e.message.includes('Legacy address cannot be used')) legacyRejected = true;
}
assert(!legacyRejected, 'Should NOT reject CARROT address at CARROT height');
});
test('transfer rejects legacy address after HF10 (mainnet)', async () => {
const wallet = Wallet.create('mainnet');
const legacyAddr = wallet.getLegacyAddress();
const mockD = mockDaemon(340000); // post-CARROT (HF10 = 334750 on mainnet)
let threw = false;
try {
await transfer({
wallet, daemon: mockD,
destinations: [{ address: legacyAddr, amount: 100000000n }],
options: { network: 'mainnet', assetType: 'SAL1' }
});
} catch (e) {
threw = true;
assert(e.message.includes('Legacy address cannot be used'), `Wrong error: ${e.message}`);
}
assert(threw, 'Should have thrown for legacy address at CARROT height (mainnet)');
});
test('transfer accepts legacy address before HF10 (mainnet)', async () => {
const wallet = Wallet.create('mainnet');
const legacyAddr = wallet.getLegacyAddress();
const mockD = mockDaemon(330000); // pre-CARROT on mainnet
let legacyRejected = false;
try {
await transfer({
wallet, daemon: mockD,
destinations: [{ address: legacyAddr, amount: 100000000n }],
options: { network: 'mainnet', assetType: 'SAL1' }
});
} catch (e) {
if (e.message.includes('Legacy address cannot be used')) legacyRejected = true;
}
assert(!legacyRejected, 'Should NOT reject legacy address before HF10 (mainnet)');
});
// Summary
console.log(`\n--- Summary ---`);
console.log(`Passed: ${passed}`);