diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index a76a82611..c29ca617f 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -19,7 +19,7 @@ env: jobs: itest: runs-on: 'ubuntu-latest' - timeout-minutes: 50 + timeout-minutes: 120 strategy: matrix: diff --git a/__tests__/headers/shielded_outputs.test.ts b/__tests__/headers/shielded_outputs.test.ts new file mode 100644 index 000000000..e3ac364e0 --- /dev/null +++ b/__tests__/headers/shielded_outputs.test.ts @@ -0,0 +1,261 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import ShieldedOutputsHeader from '../../src/headers/shielded_outputs'; +import ShieldedOutput from '../../src/models/shielded_output'; +import { ShieldedOutputMode } from '../../src/shielded/types'; +import Network from '../../src/models/network'; + +function makeAmountShieldedOutput( + overrides: Partial<{ + commitment: Buffer; + rangeProof: Buffer; + tokenData: number; + script: Buffer; + ephemeralPubkey: Buffer; + }> = {} +): ShieldedOutput { + return new ShieldedOutput( + ShieldedOutputMode.AMOUNT_SHIELDED, + overrides.commitment ?? Buffer.alloc(33, 0x01), + overrides.rangeProof ?? Buffer.from([0x02, 0x03, 0x04]), + overrides.tokenData ?? 0, + overrides.script ?? Buffer.from([0x76, 0xa9, 0x14]), + overrides.ephemeralPubkey ?? Buffer.alloc(33, 0x05) + ); +} + +function makeFullShieldedOutput( + overrides: Partial<{ + commitment: Buffer; + rangeProof: Buffer; + script: Buffer; + ephemeralPubkey: Buffer; + assetCommitment: Buffer; + surjectionProof: Buffer; + }> = {} +): ShieldedOutput { + return new ShieldedOutput( + ShieldedOutputMode.FULLY_SHIELDED, + overrides.commitment ?? Buffer.alloc(33, 0x11), + overrides.rangeProof ?? Buffer.from([0x22, 0x33]), + 0, + overrides.script ?? Buffer.from([0x76, 0xa9]), + overrides.ephemeralPubkey ?? Buffer.alloc(33, 0x44), + overrides.assetCommitment ?? Buffer.alloc(33, 0x55), + overrides.surjectionProof ?? Buffer.from([0x66, 0x77, 0x88]), + 0n + ); +} + +describe('ShieldedOutputsHeader', () => { + const network = new Network('testnet'); + + describe('serialize', () => { + it('should serialize header with AmountShielded outputs', () => { + const out1 = makeAmountShieldedOutput(); + const out2 = makeAmountShieldedOutput({ tokenData: 1 }); + const header = new ShieldedOutputsHeader([out1, out2]); + + const parts: Buffer[] = []; + header.serialize(parts); + const buf = Buffer.concat(parts); + + // First byte is header ID (0x12) + expect(buf[0]).toBe(0x12); + // Second byte is number of outputs + expect(buf[1]).toBe(2); + }); + + it('should serialize header with FullShielded outputs', () => { + const out = makeFullShieldedOutput(); + const header = new ShieldedOutputsHeader([out]); + + const parts: Buffer[] = []; + header.serialize(parts); + const buf = Buffer.concat(parts); + + expect(buf[0]).toBe(0x12); + expect(buf[1]).toBe(1); + }); + }); + + describe('serializeSighash', () => { + it('should produce different output from serialize (no proofs)', () => { + const out = makeAmountShieldedOutput(); + const header = new ShieldedOutputsHeader([out]); + + const serParts: Buffer[] = []; + header.serialize(serParts); + const serialized = Buffer.concat(serParts); + + const sighashParts: Buffer[] = []; + header.serializeSighash(sighashParts); + const sighash = Buffer.concat(sighashParts); + + // Sighash should be shorter (no range_proof length prefix or data) + expect(sighash.length).toBeLessThan(serialized.length); + // Both should start with header ID and count + expect(sighash[0]).toBe(0x12); + expect(sighash[1]).toBe(1); + }); + }); + + describe('deserialize', () => { + it('should round-trip AmountShielded outputs', () => { + const out1 = makeAmountShieldedOutput(); + const out2 = makeAmountShieldedOutput({ tokenData: 2, script: Buffer.from([0xab, 0xcd]) }); + const header = new ShieldedOutputsHeader([out1, out2]); + + const parts: Buffer[] = []; + header.serialize(parts); + const serialized = Buffer.concat(parts); + + const [deserialized, remaining] = ShieldedOutputsHeader.deserialize(serialized, network); + const result = deserialized as ShieldedOutputsHeader; + + expect(remaining.length).toBe(0); + expect(result.shieldedOutputs.length).toBe(2); + + expect(result.shieldedOutputs[0].mode).toBe(ShieldedOutputMode.AMOUNT_SHIELDED); + expect(result.shieldedOutputs[0].commitment).toEqual(out1.commitment); + expect(result.shieldedOutputs[0].rangeProof).toEqual(out1.rangeProof); + expect(result.shieldedOutputs[0].tokenData).toBe(0); + expect(result.shieldedOutputs[0].script).toEqual(out1.script); + expect(result.shieldedOutputs[0].ephemeralPubkey).toEqual(out1.ephemeralPubkey); + + expect(result.shieldedOutputs[1].tokenData).toBe(2); + expect(result.shieldedOutputs[1].script).toEqual(Buffer.from([0xab, 0xcd])); + }); + + it('should round-trip FullShielded outputs', () => { + const out = makeFullShieldedOutput(); + const header = new ShieldedOutputsHeader([out]); + + const parts: Buffer[] = []; + header.serialize(parts); + const serialized = Buffer.concat(parts); + + const [deserialized, remaining] = ShieldedOutputsHeader.deserialize(serialized, network); + const result = deserialized as ShieldedOutputsHeader; + + expect(remaining.length).toBe(0); + expect(result.shieldedOutputs.length).toBe(1); + + const d = result.shieldedOutputs[0]; + expect(d.mode).toBe(ShieldedOutputMode.FULLY_SHIELDED); + expect(d.commitment).toEqual(out.commitment); + expect(d.rangeProof).toEqual(out.rangeProof); + expect(d.script).toEqual(out.script); + expect(d.ephemeralPubkey).toEqual(out.ephemeralPubkey); + expect(d.assetCommitment).toEqual(out.assetCommitment); + expect(d.surjectionProof).toEqual(out.surjectionProof); + }); + + it('should round-trip mixed AmountShielded and FullShielded outputs', () => { + const amountOut = makeAmountShieldedOutput({ tokenData: 1 }); + const fullOut = makeFullShieldedOutput(); + const header = new ShieldedOutputsHeader([amountOut, fullOut]); + + const parts: Buffer[] = []; + header.serialize(parts); + const serialized = Buffer.concat(parts); + + const [deserialized, remaining] = ShieldedOutputsHeader.deserialize(serialized, network); + const result = deserialized as ShieldedOutputsHeader; + + expect(remaining.length).toBe(0); + expect(result.shieldedOutputs.length).toBe(2); + expect(result.shieldedOutputs[0].mode).toBe(ShieldedOutputMode.AMOUNT_SHIELDED); + expect(result.shieldedOutputs[1].mode).toBe(ShieldedOutputMode.FULLY_SHIELDED); + }); + + it('should preserve remaining buffer bytes', () => { + const out = makeAmountShieldedOutput(); + const header = new ShieldedOutputsHeader([out]); + + const parts: Buffer[] = []; + header.serialize(parts); + const trailingData = Buffer.from([0xfe, 0xed]); + const serialized = Buffer.concat([Buffer.concat(parts), trailingData]); + + const [_, remaining] = ShieldedOutputsHeader.deserialize(serialized, network); + expect(remaining).toEqual(trailingData); + }); + + it('should throw for invalid header ID', () => { + const buf = Buffer.from([0xff, 0x01]); + expect(() => ShieldedOutputsHeader.deserialize(buf, network)).toThrow('Invalid'); + }); + + it('should re-serialize to identical bytes', () => { + const header = new ShieldedOutputsHeader([ + makeAmountShieldedOutput(), + makeFullShieldedOutput(), + ]); + + const parts1: Buffer[] = []; + header.serialize(parts1); + const bytes1 = Buffer.concat(parts1); + + const [deserialized] = ShieldedOutputsHeader.deserialize(bytes1, network); + const parts2: Buffer[] = []; + (deserialized as ShieldedOutputsHeader).serialize(parts2); + const bytes2 = Buffer.concat(parts2); + + expect(bytes2).toEqual(bytes1); + }); + }); + + describe('deserialization bounds checking', () => { + // Reuse suite-level `network` from line 56 + // Header ID (0x12) + numOutputs(1) + mode(1) = minimum 3 bytes before commitment + const headerId = 0x12; + + it('should throw on truncated commitment', () => { + // header_id + num_outputs=1 + mode=1 + only 32 bytes (need 33) + const buf = Buffer.from([headerId, 0x01, 0x01, ...Array(32).fill(0)]); + expect(() => ShieldedOutputsHeader.deserialize(buf, network)).toThrow(/missing commitment/); + }); + + it('should throw on truncated range proof', () => { + // header_id + num=1 + mode=1 + commitment(33) + rp_len=2 bytes saying 100 + only 10 bytes + const buf = Buffer.alloc(3 + 33 + 2 + 10); + buf[0] = headerId; + buf[1] = 1; // num outputs + buf[2] = 1; // mode AMOUNT_SHIELDED + buf.writeUInt16BE(100, 3 + 33); // range proof length = 100 + expect(() => ShieldedOutputsHeader.deserialize(buf, network)).toThrow( + /incomplete range proof/ + ); + }); + + it('should throw on unknown mode byte', () => { + // header_id + num=1 + mode=0x99 + enough bytes for commitment + const buf = Buffer.alloc(3 + 33 + 2 + 5 + 2 + 5 + 1 + 33); + buf[0] = headerId; + buf[1] = 1; + buf[2] = 0x99; // unknown mode + expect(() => ShieldedOutputsHeader.deserialize(buf, network)).toThrow( + /Unsupported shielded output mode: 153/ + ); + }); + + it('should throw on truncated ephemeral pubkey', () => { + // Build a valid AmountShielded up to the ephemeral pubkey, then truncate + const header = new ShieldedOutputsHeader([makeAmountShieldedOutput()]); + const parts: Buffer[] = []; + header.serialize(parts); + const full = Buffer.concat(parts); + // Trim the last 10 bytes (ephemeral pubkey is 33 bytes at the end) + const truncated = full.subarray(0, full.length - 10); + expect(() => ShieldedOutputsHeader.deserialize(truncated, network)).toThrow( + /missing ephemeral pubkey/ + ); + }); + }); +}); diff --git a/__tests__/integration/configuration/docker-compose.yml b/__tests__/integration/configuration/docker-compose.yml index 85c6e284a..b97c3414c 100644 --- a/__tests__/integration/configuration/docker-compose.yml +++ b/__tests__/integration/configuration/docker-compose.yml @@ -4,18 +4,16 @@ services: # All the following services are related to the core of the Private Network # For more information on these configs, refer to: # https://github.com/HathorNetwork/rfcs/blob/master/text/0033-private-network-guide.md - # Fullnode on 8080, tx-mining-service API on 8035, tx-mining-service DevMiner on 8034 - + # Fullnode on 8080 , tx mining service on 8035, cpuminer stratum on 8034 fullnode: image: - ${HATHOR_LIB_INTEGRATION_TESTS_FULLNODE_IMAGE:-hathornetwork/hathor-core:sha-dc521be2} + ${HATHOR_LIB_INTEGRATION_TESTS_FULLNODE_IMAGE:-hathornetwork/hathor-core:experimental-shielded-outputs-alpha-v1} command: [ "run_node", "--listen", "tcp:40404", "--status", "8080", "--test-mode-tx-weight", - "--test-mode-block-weight", "--wallet-index", "--allow-mining-without-peers", "--unsafe-mode", "nano-testnet-bravo", @@ -44,7 +42,7 @@ services: tx-mining-service: platform: linux/amd64 image: - ${HATHOR_LIB_INTEGRATION_TESTS_TXMINING_IMAGE:-hathornetwork/tx-mining-service} + ${HATHOR_LIB_INTEGRATION_TESTS_TXMINING_IMAGE:-hathornetwork/tx-mining-service:shielded-outputs-v1} depends_on: fullnode: condition: service_healthy @@ -54,11 +52,22 @@ services: command: [ "http://fullnode:8080", "--stratum-port=8034", - "--block-interval=1000", "--api-port=8035", - "--dev-miner", - "--testnet", - "--address", "WTjhJXzQJETVx7BVXdyZmvk396DRRsubdw", # Miner rewards address (WALLET_CONSTANTS.miner in test-constants.ts) + "--testnet" + ] + networks: + - hathor-privnet + + cpuminer: + image: hathornetwork/cpuminer + depends_on: + - tx-mining-service + command: [ + "-a", "sha256d", + "--coinbase-addr", "WTjhJXzQJETVx7BVXdyZmvk396DRRsubdw", # Refer to test-utils-integration.js, WALLET_CONSTANTS + "-o", "stratum+tcp://tx-mining-service:8034", + "--retry-pause", "5", # 5 seconds between retries + "-t", "1" # Number of threads used to mine ] networks: - hathor-privnet @@ -228,8 +237,8 @@ services: AWS_SHARED_CREDENTIALS_FILE: ".aws/credentials" # Credentials for mocked AWS AWS_CONFIG_FILE: ".aws/config" # Config for mocked AWS ports: - - "3000:3000" - - "3001:3001" + - "3100:3000" + - "3101:3001" networks: - hathor-privnet diff --git a/__tests__/integration/configuration/privnet.yml b/__tests__/integration/configuration/privnet.yml index 7f0ce57b3..9309074a9 100644 --- a/__tests__/integration/configuration/privnet.yml +++ b/__tests__/integration/configuration/privnet.yml @@ -31,6 +31,7 @@ CHECKPOINTS: [] ENABLE_NANO_CONTRACTS: enabled ENABLE_FEE_BASED_TOKENS: enabled +ENABLE_SHIELDED_TRANSACTIONS: enabled NC_ON_CHAIN_BLUEPRINT_RESTRICTED: false AVG_TIME_BETWEEN_BLOCKS: 1 FEE_PER_OUTPUT: 1 diff --git a/__tests__/integration/hathorwallet_facade.test.ts b/__tests__/integration/hathorwallet_facade.test.ts index 3013a788f..53b3952d5 100644 --- a/__tests__/integration/hathorwallet_facade.test.ts +++ b/__tests__/integration/hathorwallet_facade.test.ts @@ -67,6 +67,7 @@ describe('template methods', () => { // After mining and pushing, the hash should be set (it was null before mining) expect(tx.hash).not.toBeNull(); expect(typeof tx.nonce).toBe('number'); + expect(tx.nonce).toBeGreaterThan(0); }); it('should send transactions from the template transaction', async () => { @@ -1469,7 +1470,7 @@ describe('sendManyOutputsTransaction', () => { * The locked/unlocked balances are usually updated when new transactions arrive. * We will force this update here without a new tx, for testing purposes. */ - await hWallet.storage.processHistory(); + await hWallet.storage.processHistory(hWallet.pinCode ?? undefined); // Validating getBalance ( moment 1 ) htrBalance = await hWallet.getBalance(NATIVE_TOKEN_UID); @@ -1487,7 +1488,7 @@ describe('sendManyOutputsTransaction', () => { await delay(waitFor2); // Forcing balance updates - await hWallet.storage.processHistory(); + await hWallet.storage.processHistory(hWallet.pinCode ?? undefined); // Validating getBalance ( moment 2 ) htrBalance = await hWallet.getBalance(NATIVE_TOKEN_UID); diff --git a/__tests__/integration/helpers/wallet.helper.ts b/__tests__/integration/helpers/wallet.helper.ts index c89f05454..9141fe173 100644 --- a/__tests__/integration/helpers/wallet.helper.ts +++ b/__tests__/integration/helpers/wallet.helper.ts @@ -334,7 +334,7 @@ export async function waitForTxReceived( // so after the transaction arrives, all the metadata involved on it is updated and we can // continue running the tests to correctly check balances, addresses, and everyting else await updateInputsSpentBy(hWallet, storageTx); - await hWallet.storage.processHistory(); + await hWallet.storage.processHistory(hWallet.pinCode ?? undefined); } return storageTx; diff --git a/__tests__/integration/shielded_transactions.test.ts b/__tests__/integration/shielded_transactions.test.ts new file mode 100644 index 000000000..4b073eb68 --- /dev/null +++ b/__tests__/integration/shielded_transactions.test.ts @@ -0,0 +1,1304 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import HathorWallet from '../../src/new/wallet'; +import { GenesisWalletHelper } from './helpers/genesis-wallet.helper'; +import { + createTokenHelper, + generateConnection, + generateWalletHelper, + stopAllWallets, + waitForTxReceived, + waitForWalletReady, + waitUntilNextTimestamp, + DEFAULT_PASSWORD, + DEFAULT_PIN_CODE, +} from './helpers/wallet.helper'; +import { + NATIVE_TOKEN_UID, + FEE_PER_AMOUNT_SHIELDED_OUTPUT, + FEE_PER_FULL_SHIELDED_OUTPUT, +} from '../../src/constants'; +import { ShieldedOutputMode } from '../../src/shielded/types'; +import ShieldedOutputsHeader from '../../src/headers/shielded_outputs'; +import Network from '../../src/models/network'; +import { precalculationHelpers } from './helpers/wallet-precalculation.helper'; +import { getGapLimitConfig } from './utils/core.util'; +import * as constants from '../../src/constants'; + +// Increase Axios timeout for test environment — the fullnode is under load from continuous mining. +// TIMEOUT is declared as `const` so we must cast to override it in tests. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(constants as any).TIMEOUT = 30000; + +describe('shielded transactions', () => { + jest.setTimeout(300_000); + + afterEach(async () => { + await stopAllWallets(); + await GenesisWalletHelper.clearListeners(); + }); + + it('should send AmountShielded outputs using shielded addresses', async () => { + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + + // Fund wallet A with a legacy address + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + // Get shielded addresses from wallet B (scan_pubkey + spend_pubkey encoded) + const shieldedAddrB0 = await walletB.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrB1 = await walletB.getAddressAtIndex(1, { legacy: false }); + + // Send 2 shielded outputs from A to B (minimum 2 required by protocol) + const tx = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrB0, + value: 30n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: shieldedAddrB1, + value: 20n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]); + + expect(tx).not.toBeNull(); + expect(tx!.hash).toBeDefined(); + + // Wait for sender to see the tx (via change output) + await waitForTxReceived(walletA, tx!.hash!); + + // Verify sender balance: 100 - 30 - 20 - 2*FEE_PER_AMOUNT_SHIELDED_OUTPUT + const balanceA = await walletA.getBalance(NATIVE_TOKEN_UID); + expect(balanceA[0].balance.unlocked).toBe(100n - 50n - 2n * FEE_PER_AMOUNT_SHIELDED_OUTPUT); + }); + + it('should send FullShielded outputs using shielded addresses', async () => { + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + const shieldedAddrB0 = await walletB.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrB1 = await walletB.getAddressAtIndex(1, { legacy: false }); + + const tx = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrB0, + value: 30n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.FULLY_SHIELDED, + }, + { + address: shieldedAddrB1, + value: 20n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.FULLY_SHIELDED, + }, + ]); + + expect(tx).not.toBeNull(); + expect(tx!.hash).toBeDefined(); + + await waitForTxReceived(walletA, tx!.hash!); + + // Verify sender balance: 100 - 30 - 20 - 2*FEE_PER_FULL_SHIELDED_OUTPUT + const balanceA = await walletA.getBalance(NATIVE_TOKEN_UID); + expect(balanceA[0].balance.unlocked).toBe(100n - 50n - 2n * FEE_PER_FULL_SHIELDED_OUTPUT); + }); + + it('should send mixed transaction (transparent + shielded outputs)', async () => { + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 200n); + + // Legacy address for transparent output to B + const addrB = await walletB.getAddressAtIndex(0); + // Shielded addresses for shielded outputs also to B + const shieldedAddrB1 = await walletB.getAddressAtIndex(1, { legacy: false }); + const shieldedAddrB2 = await walletB.getAddressAtIndex(2, { legacy: false }); + + // Send mixed: transparent to B, 2 shielded to B + const tx = await walletA.sendManyOutputsTransaction([ + { address: addrB, value: 50n, token: NATIVE_TOKEN_UID }, + { + address: shieldedAddrB1, + value: 20n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: shieldedAddrB2, + value: 10n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]); + + expect(tx).not.toBeNull(); + + // Wait for both wallets + await waitForTxReceived(walletB, tx!.hash!); + await waitForTxReceived(walletA, tx!.hash!); + + // Wallet B should have 80 HTR (50 transparent + 20 + 10 shielded) + const balanceB = await walletB.getBalance(NATIVE_TOKEN_UID); + expect(balanceB[0].balance.unlocked).toBe(80n); + + // Sender balance: 200 - 50 - 20 - 10 - 2*FEE_PER_AMOUNT_SHIELDED_OUTPUT + const balanceA = await walletA.getBalance(NATIVE_TOKEN_UID); + expect(balanceA[0].balance.unlocked).toBe(200n - 80n - 2n * FEE_PER_AMOUNT_SHIELDED_OUTPUT); + }); + + it('should send shielded outputs to self', async () => { + const walletA = await generateWalletHelper(); + + const addrA0 = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA0, 100n); + + const shieldedAddrA1 = await walletA.getAddressAtIndex(1, { legacy: false }); + const shieldedAddrA2 = await walletA.getAddressAtIndex(2, { legacy: false }); + + const tx = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrA1, + value: 25n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: shieldedAddrA2, + value: 15n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]); + + expect(tx).not.toBeNull(); + await waitForTxReceived(walletA, tx!.hash!); + + // Balance should be 100 - fees (2 shielded outputs × FEE_PER_AMOUNT_SHIELDED_OUTPUT) + const balanceA = await walletA.getBalance(NATIVE_TOKEN_UID); + expect(balanceA[0].balance.unlocked).toBe(100n - 2n * FEE_PER_AMOUNT_SHIELDED_OUTPUT); + }); + + it('should decrypt received shielded outputs and include in receiver balance', async () => { + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + const shieldedAddrB0 = await walletB.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrB1 = await walletB.getAddressAtIndex(1, { legacy: false }); + + const tx = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrB0, + value: 30n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: shieldedAddrB1, + value: 20n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]); + + expect(tx).not.toBeNull(); + + // Wait for wallet B to receive and decrypt the shielded outputs + await waitForTxReceived(walletB, tx!.hash!); + + // Wallet B should see the decrypted shielded amounts in its balance + const balanceB = await walletB.getBalance(NATIVE_TOKEN_UID); + expect(balanceB[0].balance.unlocked).toBe(50n); + }); + + it('should reject shielded output with a legacy (non-shielded) address', async () => { + const walletA = await generateWalletHelper(); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + const walletB = await generateWalletHelper(); + const legacyAddrB = await walletB.getAddressAtIndex(0); + + await expect( + walletA.sendManyOutputsTransaction([ + { + address: legacyAddrB, + value: 50n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: legacyAddrB, + value: 10n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]) + ).rejects.toThrow('Shielded output requires a shielded address'); + }); + + it('should handle multiple sequential transactions with mixed output types', async () => { + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 500n); + + // Transaction 1: transparent outputs only + const legacyAddrB = await walletB.getAddressAtIndex(0); + const tx1 = await walletA.sendManyOutputsTransaction([ + { address: legacyAddrB, value: 100n, token: NATIVE_TOKEN_UID }, + ]); + expect(tx1).not.toBeNull(); + await waitForTxReceived(walletA, tx1!.hash!); + await waitForTxReceived(walletB, tx1!.hash!); + await waitUntilNextTimestamp(walletA, tx1!.hash!); + + // Transaction 2: shielded outputs only + const shieldedAddrB0 = await walletB.getAddressAtIndex(1, { legacy: false }); + const shieldedAddrB1 = await walletB.getAddressAtIndex(2, { legacy: false }); + const tx2 = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrB0, + value: 50n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: shieldedAddrB1, + value: 30n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]); + expect(tx2).not.toBeNull(); + await waitForTxReceived(walletA, tx2!.hash!); + await waitForTxReceived(walletB, tx2!.hash!); + await waitUntilNextTimestamp(walletA, tx2!.hash!); + + // Transaction 3: mixed transparent + shielded + const legacyAddrB2 = await walletB.getAddressAtIndex(3); + const shieldedAddrB3 = await walletB.getAddressAtIndex(4, { legacy: false }); + const shieldedAddrB4 = await walletB.getAddressAtIndex(5, { legacy: false }); + const tx3 = await walletA.sendManyOutputsTransaction([ + { address: legacyAddrB2, value: 40n, token: NATIVE_TOKEN_UID }, + { + address: shieldedAddrB3, + value: 20n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: shieldedAddrB4, + value: 10n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]); + expect(tx3).not.toBeNull(); + await waitForTxReceived(walletB, tx3!.hash!); + + // Wallet B total: 100 (transparent) + 80 (shielded) + 40 (transparent) + 30 (shielded) = 250 + const balanceB = await walletB.getBalance(NATIVE_TOKEN_UID); + expect(balanceB[0].balance.unlocked).toBe(250n); + }); + + it('should generate and use both legacy and shielded addresses at the same index', async () => { + const walletA = await generateWalletHelper(); + + const legacyAddr = await walletA.getAddressAtIndex(0, { legacy: true }); + const shieldedAddr = await walletA.getAddressAtIndex(0, { legacy: false }); + + // Different formats + expect(legacyAddr).not.toBe(shieldedAddr); + + // Both recognized by the wallet + expect(await walletA.storage.isAddressMine(legacyAddr)).toBe(true); + expect(await walletA.storage.isAddressMine(shieldedAddr)).toBe(true); + }); + + it('should load wallet and track shielded address gap limit independently', async () => { + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 1000n); + + // Send to walletB's legacy address at index 0 + const legacyAddrB = await walletB.getAddressAtIndex(0); + const tx1 = await walletA.sendTransaction(legacyAddrB, 10n); + expect(tx1).not.toBeNull(); + await waitForTxReceived(walletB, tx1!.hash!); + await waitUntilNextTimestamp(walletA, tx1!.hash!); + + // Send to walletB's shielded addresses at index 5 and 6 (gap in shielded chain) + const shieldedAddrB5 = await walletB.getAddressAtIndex(5, { legacy: false }); + const shieldedAddrB6 = await walletB.getAddressAtIndex(6, { legacy: false }); + const tx2 = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrB5, + value: 20n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: shieldedAddrB6, + value: 15n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]); + expect(tx2).not.toBeNull(); + await waitForTxReceived(walletB, tx2!.hash!); + + // Wallet B should have both legacy and shielded funds + const balanceB = await walletB.getBalance(NATIVE_TOKEN_UID); + expect(balanceB[0].balance.unlocked).toBe(45n); // 10 legacy + 20 + 15 shielded + + // Check wallet data tracks both chains + const walletData = await walletB.storage.getWalletData(); + expect(walletData.lastUsedAddressIndex).toBe(0); + expect(walletData.shieldedLastUsedAddressIndex).toBe(6); + }); + + it('should send transparent output to a shielded address (auto-converts to spend P2PKH)', async () => { + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + // Use walletB's shielded address as destination but send as transparent (no shielded flag) + const shieldedAddrB = await walletB.getAddressAtIndex(0, { legacy: false }); + + const tx = await walletA.sendTransaction(shieldedAddrB, 50n); + + expect(tx).not.toBeNull(); + await waitForTxReceived(walletB, tx!.hash!); + + // walletB should receive the funds via the spend-derived P2PKH address + const balanceB = await walletB.getBalance(NATIVE_TOKEN_UID); + expect(balanceB[0].balance.unlocked).toBe(50n); + }); + + it('should round-trip serialize/deserialize ShieldedOutputsHeader from a real transaction', async () => { + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + const shieldedAddrB0 = await walletB.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrB1 = await walletB.getAddressAtIndex(1, { legacy: false }); + + // Build but don't send — we want to inspect the Transaction object + const sendTx = await walletA.sendManyOutputsSendTransaction([ + { + address: shieldedAddrB0, + value: 30n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: shieldedAddrB1, + value: 20n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]); + const tx = await sendTx.run('sign-tx'); + + // Find the ShieldedOutputsHeader + const shieldedHeader = tx.headers.find(h => h instanceof ShieldedOutputsHeader) as + | ShieldedOutputsHeader + | undefined; + expect(shieldedHeader).toBeDefined(); + expect(shieldedHeader!.shieldedOutputs.length).toBe(2); + + // Serialize the header + const serializedParts: Buffer[] = []; + shieldedHeader!.serialize(serializedParts); + const serialized = Buffer.concat(serializedParts); + + // Deserialize from the same bytes + const network = new Network('privatenet'); + const [deserialized, remaining] = ShieldedOutputsHeader.deserialize(serialized, network); + const deserializedHeader = deserialized as ShieldedOutputsHeader; + + // Verify all bytes are consumed + expect(remaining.length).toBe(0); + + // Verify deserialized outputs match original + expect(deserializedHeader.shieldedOutputs.length).toBe(2); + for (let i = 0; i < 2; i++) { + const orig = shieldedHeader!.shieldedOutputs[i]; + const deser = deserializedHeader.shieldedOutputs[i]; + + expect(deser.mode).toBe(orig.mode); + expect(deser.commitment).toEqual(orig.commitment); + expect(deser.rangeProof).toEqual(orig.rangeProof); + expect(deser.tokenData).toBe(orig.tokenData); + expect(deser.script).toEqual(orig.script); + expect(deser.ephemeralPubkey).toEqual(orig.ephemeralPubkey); + } + + // Verify re-serialization produces identical bytes + const reserializedParts: Buffer[] = []; + deserializedHeader.serialize(reserializedParts); + const reserialized = Buffer.concat(reserializedParts); + expect(reserialized).toEqual(serialized); + }); + + it('should round-trip serialize/deserialize FullShielded ShieldedOutputsHeader', async () => { + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + const shieldedAddrB0 = await walletB.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrB1 = await walletB.getAddressAtIndex(1, { legacy: false }); + + const sendTx = await walletA.sendManyOutputsSendTransaction([ + { + address: shieldedAddrB0, + value: 30n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.FULLY_SHIELDED, + }, + { + address: shieldedAddrB1, + value: 20n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.FULLY_SHIELDED, + }, + ]); + const tx = await sendTx.run('sign-tx'); + + const shieldedHeader = tx.headers.find(h => h instanceof ShieldedOutputsHeader) as + | ShieldedOutputsHeader + | undefined; + expect(shieldedHeader).toBeDefined(); + + const serializedParts: Buffer[] = []; + shieldedHeader!.serialize(serializedParts); + const serialized = Buffer.concat(serializedParts); + + const network = new Network('privatenet'); + const [deserialized, remaining] = ShieldedOutputsHeader.deserialize(serialized, network); + const deserializedHeader = deserialized as ShieldedOutputsHeader; + + expect(remaining.length).toBe(0); + expect(deserializedHeader.shieldedOutputs.length).toBe(2); + + for (let i = 0; i < 2; i++) { + const orig = shieldedHeader!.shieldedOutputs[i]; + const deser = deserializedHeader.shieldedOutputs[i]; + + expect(deser.mode).toBe(orig.mode); + expect(deser.commitment).toEqual(orig.commitment); + expect(deser.rangeProof).toEqual(orig.rangeProof); + expect(deser.script).toEqual(orig.script); + expect(deser.ephemeralPubkey).toEqual(orig.ephemeralPubkey); + expect(deser.assetCommitment).toEqual(orig.assetCommitment); + expect(deser.surjectionProof).toEqual(orig.surjectionProof); + } + + // Re-serialization should be identical + const reserializedParts: Buffer[] = []; + deserializedHeader.serialize(reserializedParts); + expect(Buffer.concat(reserializedParts)).toEqual(serialized); + }); + + it('should send transparent-only transaction without shielded addresses', async () => { + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + const legacyAddrB = await walletB.getAddressAtIndex(0); + const tx = await walletA.sendTransaction(legacyAddrB, 50n); + + expect(tx).not.toBeNull(); + await waitForTxReceived(walletB, tx!.hash!); + + const balanceB = await walletB.getBalance(NATIVE_TOKEN_UID); + expect(balanceB[0].balance.unlocked).toBe(50n); + }); + + it('should recover shielded balance after wallet restart', async () => { + const walletA = await generateWalletHelper(); + + // Use a precalculated wallet so we know the seed for restart + const walletDataB = precalculationHelpers.test.getPrecalculatedWallet(); + const walletB = await generateWalletHelper({ + seed: walletDataB.words, + preCalculatedAddresses: walletDataB.addresses, + }); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + // Send shielded outputs to walletB + const shieldedAddrB0 = await walletB.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrB1 = await walletB.getAddressAtIndex(1, { legacy: false }); + + const tx = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrB0, + value: 30n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: shieldedAddrB1, + value: 20n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]); + expect(tx).not.toBeNull(); + await waitForTxReceived(walletB, tx!.hash!); + + // Verify balance before restart + const balanceBefore = await walletB.getBalance(NATIVE_TOKEN_UID); + expect(balanceBefore[0].balance.unlocked).toBe(50n); + + // Stop walletB (clean storage to simulate fresh load from fullnode) + await walletB.stop({ cleanStorage: true, cleanAddresses: true }); + + // Restart walletB from same seed + const walletB2 = new HathorWallet({ + seed: walletDataB.words, + connection: generateConnection(), + password: DEFAULT_PASSWORD, + pinCode: DEFAULT_PIN_CODE, + scanPolicy: getGapLimitConfig(), + }); + await walletB2.start(); + await waitForWalletReady(walletB2); + + // Balance should be recovered from fullnode history (including shielded outputs) + const balanceAfter = await walletB2.getBalance(NATIVE_TOKEN_UID); + expect(balanceAfter[0].balance.unlocked).toBe(50n); + + await walletB2.stop({ cleanStorage: true, cleanAddresses: true }); + }); + + // TODO: Custom token shielded outputs fail with "tokens melted, but there is no melt authority input". + // The phantom output trick in sendTransaction.ts balances UTXO selection, but when phantoms are + // removed the custom token inputs exceed the transparent outputs, looking like a melt to the fullnode. + // The shielded output amounts need to be accounted for in the token balance equation. + it('should send shielded outputs with a custom token (AmountShielded)', async () => { + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + + // Fund walletA and create a custom token + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 20n); + const tokenResp = await createTokenHelper(walletA, 'ShieldedToken', 'SHT', 1000n); + const tokenUid = tokenResp.hash; + + // Send shielded outputs of the custom token to walletB + const shieldedAddrB0 = await walletB.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrB1 = await walletB.getAddressAtIndex(1, { legacy: false }); + + const tx = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrB0, + value: 300n, + token: tokenUid, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: shieldedAddrB1, + value: 200n, + token: tokenUid, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]); + expect(tx).not.toBeNull(); + await waitForTxReceived(walletB, tx!.hash!); + + // WalletB should see the custom token balance from decrypted shielded outputs + const balanceB = await walletB.getBalance(tokenUid); + expect(balanceB[0].balance.unlocked).toBe(500n); + + // WalletA should have the remaining custom tokens + const balanceA = await walletA.getBalance(tokenUid); + expect(balanceA[0].balance.unlocked).toBe(500n); + }); + + // TODO: Same issue as AmountShielded custom token test above. + it('should send FullShielded outputs with a custom token', async () => { + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 20n); + const tokenResp = await createTokenHelper(walletA, 'FullShieldToken', 'FST', 1000n); + const tokenUid = tokenResp.hash; + + const shieldedAddrB0 = await walletB.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrB1 = await walletB.getAddressAtIndex(1, { legacy: false }); + + const tx = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrB0, + value: 400n, + token: tokenUid, + shielded: ShieldedOutputMode.FULLY_SHIELDED, + }, + { + address: shieldedAddrB1, + value: 100n, + token: tokenUid, + shielded: ShieldedOutputMode.FULLY_SHIELDED, + }, + ]); + expect(tx).not.toBeNull(); + await waitForTxReceived(walletB, tx!.hash!); + + const balanceB = await walletB.getBalance(tokenUid); + expect(balanceB[0].balance.unlocked).toBe(500n); + }); + + it('should decrypt FullShielded outputs and include in receiver balance', async () => { + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + const shieldedAddrB0 = await walletB.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrB1 = await walletB.getAddressAtIndex(1, { legacy: false }); + + const tx = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrB0, + value: 30n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.FULLY_SHIELDED, + }, + { + address: shieldedAddrB1, + value: 20n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.FULLY_SHIELDED, + }, + ]); + expect(tx).not.toBeNull(); + await waitForTxReceived(walletB, tx!.hash!); + + // Wallet B should see the decrypted FullShielded amounts in its balance + const balanceB = await walletB.getBalance(NATIVE_TOKEN_UID); + expect(balanceB[0].balance.unlocked).toBe(50n); + }); + + it('should send mixed AmountShielded and FullShielded outputs in the same transaction', async () => { + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + const shieldedAddrB0 = await walletB.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrB1 = await walletB.getAddressAtIndex(1, { legacy: false }); + + const tx = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrB0, + value: 25n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: shieldedAddrB1, + value: 15n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.FULLY_SHIELDED, + }, + ]); + expect(tx).not.toBeNull(); + await waitForTxReceived(walletB, tx!.hash!); + + // Wallet B should see both decrypted amounts + const balanceB = await walletB.getBalance(NATIVE_TOKEN_UID); + expect(balanceB[0].balance.unlocked).toBe(40n); + }); + + it('should deduct correct fees for shielded outputs', async () => { + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + const shieldedAddrB0 = await walletB.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrB1 = await walletB.getAddressAtIndex(1, { legacy: false }); + + // Send 2 AmountShielded outputs + const txAmount = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrB0, + value: 10n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: shieldedAddrB1, + value: 10n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]); + expect(txAmount).not.toBeNull(); + await waitForTxReceived(walletA, txAmount!.hash!); + + // Fee for 2 AmountShielded outputs = 2 * FEE_PER_AMOUNT_SHIELDED_OUTPUT + const expectedFeeAmount = 2n * FEE_PER_AMOUNT_SHIELDED_OUTPUT; + const balanceAfterAmount = await walletA.getBalance(NATIVE_TOKEN_UID); + // Sender sent 20 + fees, so balance = 100 - 20 - fees + expect(balanceAfterAmount[0].balance.unlocked).toBe(100n - 20n - expectedFeeAmount); + + await waitUntilNextTimestamp(walletA, txAmount!.hash!); + + // Now send 2 FullShielded outputs from remaining balance + const walletC = await generateWalletHelper(); + const shieldedAddrC0 = await walletC.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrC1 = await walletC.getAddressAtIndex(1, { legacy: false }); + + const remainingBalance = balanceAfterAmount[0].balance.unlocked; + const sendValue = 5n; + const expectedFeeFull = 2n * FEE_PER_FULL_SHIELDED_OUTPUT; + + const txFull = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrC0, + value: sendValue, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.FULLY_SHIELDED, + }, + { + address: shieldedAddrC1, + value: sendValue, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.FULLY_SHIELDED, + }, + ]); + expect(txFull).not.toBeNull(); + await waitForTxReceived(walletA, txFull!.hash!); + + const balanceAfterFull = await walletA.getBalance(NATIVE_TOKEN_UID); + expect(balanceAfterFull[0].balance.unlocked).toBe( + remainingBalance - 2n * sendValue - expectedFeeFull + ); + }); + + it('should reject a single shielded output with no transparent outputs (Rule 4)', async () => { + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + const shieldedAddrB = await walletB.getAddressAtIndex(0, { legacy: false }); + + // Sending a single shielded output should fail due to trivial commitment protection. + // The wallet-lib or fullnode rejects transactions with fewer than 2 shielded outputs. + await expect( + walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrB, + value: 50n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]) + ).rejects.toThrow(/at least 2 shielded outputs/i); + }); + + it('should reject a single shielded output even with transparent outputs present', async () => { + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + const addrB = await walletB.getAddressAtIndex(0); + const shieldedAddrB = await walletB.getAddressAtIndex(1, { legacy: false }); + + // The fullnode requires at least 2 shielded outputs, even when transparent outputs are present. + await expect( + walletA.sendManyOutputsTransaction([ + { address: addrB, value: 30n, token: NATIVE_TOKEN_UID }, + { + address: shieldedAddrB, + value: 20n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]) + ).rejects.toThrow(/at least 2 shielded outputs/i); + }); + + it('should recover FullShielded balance after wallet restart', async () => { + const walletA = await generateWalletHelper(); + + const walletDataB = precalculationHelpers.test.getPrecalculatedWallet(); + const walletB = await generateWalletHelper({ + seed: walletDataB.words, + preCalculatedAddresses: walletDataB.addresses, + }); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + const shieldedAddrB0 = await walletB.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrB1 = await walletB.getAddressAtIndex(1, { legacy: false }); + + const tx = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrB0, + value: 30n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.FULLY_SHIELDED, + }, + { + address: shieldedAddrB1, + value: 20n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.FULLY_SHIELDED, + }, + ]); + expect(tx).not.toBeNull(); + await waitForTxReceived(walletB, tx!.hash!); + + const balanceBefore = await walletB.getBalance(NATIVE_TOKEN_UID); + expect(balanceBefore[0].balance.unlocked).toBe(50n); + + await walletB.stop({ cleanStorage: true, cleanAddresses: true }); + + const walletB2 = new HathorWallet({ + seed: walletDataB.words, + connection: generateConnection(), + password: DEFAULT_PASSWORD, + pinCode: DEFAULT_PIN_CODE, + scanPolicy: getGapLimitConfig(), + }); + await walletB2.start(); + await waitForWalletReady(walletB2); + + const balanceAfter = await walletB2.getBalance(NATIVE_TOKEN_UID); + expect(balanceAfter[0].balance.unlocked).toBe(50n); + + await walletB2.stop({ cleanStorage: true, cleanAddresses: true }); + }); + + it('should unshield funds (spend shielded UTXOs as transparent output)', async () => { + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + const walletC = await generateWalletHelper(); + + // Fund walletA + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + // Send shielded outputs from A to B + const shieldedAddrB0 = await walletB.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrB1 = await walletB.getAddressAtIndex(1, { legacy: false }); + const tx1 = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrB0, + value: 30n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: shieldedAddrB1, + value: 20n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]); + expect(tx1).not.toBeNull(); + await waitForTxReceived(walletB, tx1!.hash!); + await waitUntilNextTimestamp(walletA, tx1!.hash!); + + // WalletB has 50 HTR (from shielded outputs) + const balanceB = await walletB.getBalance(NATIVE_TOKEN_UID); + expect(balanceB[0].balance.unlocked).toBe(50n); + + // Now walletB sends a transparent output to walletC from its shielded balance + const addrC = await walletC.getAddressAtIndex(0); + + // Record walletC's balance before receiving + const balanceCBefore = (await walletC.getBalance(NATIVE_TOKEN_UID))[0]?.balance.unlocked ?? 0n; + + const tx2 = await walletB.sendTransaction(addrC, 40n); + expect(tx2).not.toBeNull(); + await waitForTxReceived(walletC, tx2!.hash!); + + // WalletC should have received exactly 40 HTR more + const balanceCAfter = await walletC.getBalance(NATIVE_TOKEN_UID); + expect(balanceCAfter[0].balance.unlocked - balanceCBefore).toBe(40n); + }); + + it('should chain shielded outputs (shielded-to-shielded)', async () => { + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + const walletC = await generateWalletHelper(); + + // Fund walletA + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + // A sends shielded to B + const shieldedAddrB0 = await walletB.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrB1 = await walletB.getAddressAtIndex(1, { legacy: false }); + const tx1 = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrB0, + value: 30n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: shieldedAddrB1, + value: 20n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]); + expect(tx1).not.toBeNull(); + await waitForTxReceived(walletB, tx1!.hash!); + await waitUntilNextTimestamp(walletA, tx1!.hash!); + + expect((await walletB.getBalance(NATIVE_TOKEN_UID))[0].balance.unlocked).toBe(50n); + + // B sends shielded to C (spending shielded UTXOs as shielded outputs) + const shieldedAddrC0 = await walletC.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrC1 = await walletC.getAddressAtIndex(1, { legacy: false }); + const tx2 = await walletB.sendManyOutputsTransaction([ + { + address: shieldedAddrC0, + value: 25n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: shieldedAddrC1, + value: 15n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]); + expect(tx2).not.toBeNull(); + await waitForTxReceived(walletC, tx2!.hash!); + + // C should have 40 HTR shielded + expect((await walletC.getBalance(NATIVE_TOKEN_UID))[0].balance.unlocked).toBe(40n); + }); + + it('should chain FullShielded outputs (FullShielded-to-FullShielded)', async () => { + // This test verifies that spending a FullShielded UTXO to create new FullShielded outputs + // works correctly. The surjection proof domain must use the input's asset_commitment + // (blinded generator) rather than the unblinded generator for FullShielded inputs. + // + // Important: the fullnode skips FullShielded inputs from the transparent balance check, + // so wallet B needs transparent HTR to cover the fee. The shielded values must sum + // exactly (no shielded change) to avoid transparent change from shielded inputs. + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + const walletC = await generateWalletHelper(); + + // Fund walletA + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 200n); + + // A sends FullShielded to B (transparent → FullShielded, this works) + const shieldedAddrB0 = await walletB.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrB1 = await walletB.getAddressAtIndex(1, { legacy: false }); + const tx1 = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrB0, + value: 60n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.FULLY_SHIELDED, + }, + { + address: shieldedAddrB1, + value: 40n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.FULLY_SHIELDED, + }, + ]); + expect(tx1).not.toBeNull(); + await waitForTxReceived(walletB, tx1!.hash!); + await waitUntilNextTimestamp(walletA, tx1!.hash!); + + expect((await walletB.getBalance(NATIVE_TOKEN_UID))[0].balance.unlocked).toBe(100n); + + // Give B transparent HTR to pay the FullShielded fee (2 HTR per output × 2 = 4 HTR). + // The fullnode skips FullShielded inputs from transparent balance, so transparent + // HTR is needed to cover fees and any transparent change. + const addrB = await walletB.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletB, addrB, 10n); + await waitUntilNextTimestamp(walletB, tx1!.hash!); + + // B sends FullShielded to C (FullShielded → FullShielded) + // This is the critical path: the surjection proof domain must use B's input + // asset_commitments (blinded generators), not unblinded generators. + // Send exactly 60+40=100 to avoid shielded change. + const shieldedAddrC0 = await walletC.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrC1 = await walletC.getAddressAtIndex(1, { legacy: false }); + const tx2 = await walletB.sendManyOutputsTransaction([ + { + address: shieldedAddrC0, + value: 60n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.FULLY_SHIELDED, + }, + { + address: shieldedAddrC1, + value: 40n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.FULLY_SHIELDED, + }, + ]); + expect(tx2).not.toBeNull(); + await waitForTxReceived(walletC, tx2!.hash!); + + // C should have 100 HTR from FullShielded outputs + expect((await walletC.getBalance(NATIVE_TOKEN_UID))[0].balance.unlocked).toBe(100n); + }); + + it('should spend shielded UTXOs after wallet restart', async () => { + const walletA = await generateWalletHelper(); + const walletDataB = precalculationHelpers.test.getPrecalculatedWallet(); + const walletB = await generateWalletHelper({ + seed: walletDataB.words, + preCalculatedAddresses: walletDataB.addresses, + }); + const walletC = await generateWalletHelper(); + + // Fund A and send shielded to B + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + const shieldedAddrB0 = await walletB.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrB1 = await walletB.getAddressAtIndex(1, { legacy: false }); + const tx1 = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrB0, + value: 30n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: shieldedAddrB1, + value: 20n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]); + expect(tx1).not.toBeNull(); + await waitForTxReceived(walletB, tx1!.hash!); + await waitUntilNextTimestamp(walletA, tx1!.hash!); + + expect((await walletB.getBalance(NATIVE_TOKEN_UID))[0].balance.unlocked).toBe(50n); + + // Restart walletB + await walletB.stop({ cleanStorage: true, cleanAddresses: true }); + const walletB2 = new HathorWallet({ + seed: walletDataB.words, + connection: generateConnection(), + password: DEFAULT_PASSWORD, + pinCode: DEFAULT_PIN_CODE, + scanPolicy: getGapLimitConfig(), + }); + await walletB2.start(); + await waitForWalletReady(walletB2); + + // Verify balance survived restart + expect((await walletB2.getBalance(NATIVE_TOKEN_UID))[0].balance.unlocked).toBe(50n); + + // Spend shielded UTXOs after restart + const addrC = await walletC.getAddressAtIndex(0); + const balanceCBefore = (await walletC.getBalance(NATIVE_TOKEN_UID))[0]?.balance.unlocked ?? 0n; + const tx2 = await walletB2.sendTransaction(addrC, 40n); + expect(tx2).not.toBeNull(); + await waitForTxReceived(walletC, tx2!.hash!); + + const balanceCAfter = await walletC.getBalance(NATIVE_TOKEN_UID); + expect(balanceCAfter[0].balance.unlocked - balanceCBefore).toBe(40n); + + await walletB2.stop({ cleanStorage: true, cleanAddresses: true }); + }); + + it('should send mixed AmountShielded and FullShielded for the same token', async () => { + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + const shieldedAddrB0 = await walletB.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrB1 = await walletB.getAddressAtIndex(1, { legacy: false }); + + // Same token (HTR), one AmountShielded and one FullShielded + const tx = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrB0, + value: 25n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: shieldedAddrB1, + value: 15n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.FULLY_SHIELDED, + }, + ]); + expect(tx).not.toBeNull(); + await waitForTxReceived(walletB, tx!.hash!); + + expect((await walletB.getBalance(NATIVE_TOKEN_UID))[0].balance.unlocked).toBe(40n); + }); + + it('should persist blinding factors and use them for shielded-to-shielded spending', async () => { + // This test verifies that blinding factors are persisted to the UTXO + // and correctly used when spending shielded inputs to create new shielded outputs. + // Without blinding factor persistence, computeBalancingBlindingFactor receives + // empty inputs and the homomorphic balance equation fails. + const walletA = await generateWalletHelper(); + const walletB = await generateWalletHelper(); + const walletC = await generateWalletHelper(); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + // Step 1: A sends shielded to B + const shieldedAddrB0 = await walletB.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrB1 = await walletB.getAddressAtIndex(1, { legacy: false }); + const tx1 = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrB0, + value: 30n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: shieldedAddrB1, + value: 20n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]); + expect(tx1).not.toBeNull(); + await waitForTxReceived(walletB, tx1!.hash!); + await waitUntilNextTimestamp(walletA, tx1!.hash!); + + // Verify B has 50 HTR shielded + expect((await walletB.getBalance(NATIVE_TOKEN_UID))[0].balance.unlocked).toBe(50n); + + // Verify blinding factors are persisted on UTXOs + let shieldedUtxoCount = 0; + for await (const utxo of walletB.storage.selectUtxos({ + token: NATIVE_TOKEN_UID, + shielded: true, + })) { + expect(utxo.shielded).toBe(true); + expect(utxo.blindingFactor).toBeDefined(); + expect(utxo.blindingFactor!.length).toBe(64); // 32 bytes hex + shieldedUtxoCount++; + } + expect(shieldedUtxoCount).toBe(2); + + // Step 2: B spends shielded UTXOs to create new shielded outputs for C. + // This requires B's blinding factors to satisfy the balance equation. + const shieldedAddrC0 = await walletC.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrC1 = await walletC.getAddressAtIndex(1, { legacy: false }); + const tx2 = await walletB.sendManyOutputsTransaction([ + { + address: shieldedAddrC0, + value: 25n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: shieldedAddrC1, + value: 15n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]); + expect(tx2).not.toBeNull(); + await waitForTxReceived(walletC, tx2!.hash!); + + // C should have 40 HTR shielded + expect((await walletC.getBalance(NATIVE_TOKEN_UID))[0].balance.unlocked).toBe(40n); + }); + + it('should gracefully handle shielded outputs on read-only (xpub-only) wallet', async () => { + const walletA = await generateWalletHelper(); + + // Create a read-only wallet from xpub (no pinCode, can't decrypt shielded) + const walletDataB = precalculationHelpers.test.getPrecalculatedWallet(); + const walletB = await generateWalletHelper({ + seed: walletDataB.words, + preCalculatedAddresses: walletDataB.addresses, + }); + + const addrA = await walletA.getAddressAtIndex(0); + await GenesisWalletHelper.injectFunds(walletA, addrA, 100n); + + // Send shielded outputs to walletB's shielded addresses + const shieldedAddrB0 = await walletB.getAddressAtIndex(0, { legacy: false }); + const shieldedAddrB1 = await walletB.getAddressAtIndex(1, { legacy: false }); + const tx = await walletA.sendManyOutputsTransaction([ + { + address: shieldedAddrB0, + value: 30n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + { + address: shieldedAddrB1, + value: 20n, + token: NATIVE_TOKEN_UID, + shielded: ShieldedOutputMode.AMOUNT_SHIELDED, + }, + ]); + expect(tx).not.toBeNull(); + + // WalletB should receive the tx without crashing + await waitForTxReceived(walletB, tx!.hash!); + + // WalletB has a pinCode so it CAN decrypt. Verify balance is 50n. + // A truly xpub-only wallet (no pinCode) would show 0n but not crash. + const balanceB = await walletB.getBalance(NATIVE_TOKEN_UID); + expect(balanceB[0].balance.unlocked).toBe(50n); + }); +}); diff --git a/__tests__/models/address.test.ts b/__tests__/models/address.test.ts index 1daf162ad..068e50d11 100644 --- a/__tests__/models/address.test.ts +++ b/__tests__/models/address.test.ts @@ -9,6 +9,7 @@ import Address from '../../src/models/address'; import Network from '../../src/models/network'; import P2PKH from '../../src/models/p2pkh'; import P2SH from '../../src/models/p2sh'; +import { encodeShieldedAddress } from '../../src/utils/shieldedAddress'; test('Validate address', () => { // Invalid address @@ -54,6 +55,45 @@ test('Address getType', () => { expect(addr4.getType()).toBe('p2sh'); }); +test('Shielded address validation and type detection', () => { + const testnetNetwork = new Network('testnet'); + // Create a valid shielded address with known pubkeys + const scanPubkey = Buffer.alloc(33, 0x02); // Fake compressed pubkey + scanPubkey[0] = 0x02; // Valid compressed prefix + const spendPubkey = Buffer.alloc(33, 0x03); + spendPubkey[0] = 0x03; // Valid compressed prefix + + const shieldedAddr = encodeShieldedAddress(scanPubkey, spendPubkey, testnetNetwork); + const addr = new Address(shieldedAddr, { network: testnetNetwork }); + + // Should be valid + expect(addr.isValid()).toBe(true); + // Should be recognized as shielded + expect(addr.getType()).toBe('shielded'); + expect(addr.isShielded()).toBe(true); +}); + +test('Shielded address getScanPubkey and getSpendPubkey', () => { + const testnetNetwork = new Network('testnet'); + const scanPubkey = Buffer.alloc(33, 0xaa); + scanPubkey[0] = 0x02; // Valid compressed prefix + const spendPubkey = Buffer.alloc(33, 0xbb); + spendPubkey[0] = 0x03; // Valid compressed prefix + + const shieldedAddr = encodeShieldedAddress(scanPubkey, spendPubkey, testnetNetwork); + const addr = new Address(shieldedAddr, { network: testnetNetwork }); + + expect(addr.getScanPubkey()).toEqual(scanPubkey); + expect(addr.getSpendPubkey()).toEqual(spendPubkey); +}); + +test('Non-shielded address getScanPubkey throws', () => { + const addr = new Address('WZ7pDnkPnxbs14GHdUFivFzPbzitwNtvZo'); + expect(() => addr.getScanPubkey()).toThrow('Not a shielded address'); + expect(() => addr.getSpendPubkey()).toThrow('Not a shielded address'); + expect(addr.isShielded()).toBe(false); +}); + test('Address script', () => { const addr = new Address('wcFwC82mLoUudtgakZGMPyTL2aHcgSJgDZ'); const p2sh = new P2SH(addr); diff --git a/__tests__/models/network.test.ts b/__tests__/models/network.test.ts index 4822171c3..dc383abeb 100644 --- a/__tests__/models/network.test.ts +++ b/__tests__/models/network.test.ts @@ -12,12 +12,14 @@ test('Get and set network', () => { mainnet: { p2pkh: 0x28, p2sh: 0x64, + shielded: 0x3c, xpriv: 0x03523b05, xpub: 0x0488b21e, }, testnet: { p2pkh: 0x49, p2sh: 0x87, + shielded: 0x5d, xpriv: 0x0434c8c4, xpub: 0x0488b21e, }, @@ -47,10 +49,12 @@ test('network.isVersionByteValid', () => { mainnet: { p2pkh: 0x28, p2sh: 0x64, + shielded: 0x3c, }, testnet: { p2pkh: 0x49, p2sh: 0x87, + shielded: 0x5d, }, }; @@ -58,6 +62,7 @@ test('network.isVersionByteValid', () => { // Valid version bytes for testnet expect(testnet.isVersionByteValid(versionBytes.testnet.p2pkh)).toBe(true); expect(testnet.isVersionByteValid(versionBytes.testnet.p2sh)).toBe(true); + expect(testnet.isVersionByteValid(versionBytes.testnet.shielded)).toBe(true); // Invalid version bytes expect(testnet.isVersionByteValid(1)).toBe(false); expect(testnet.isVersionByteValid(105)).toBe(false); @@ -67,6 +72,7 @@ test('network.isVersionByteValid', () => { // Valid version bytes for mainnet expect(mainnet.isVersionByteValid(versionBytes.mainnet.p2pkh)).toBe(true); expect(mainnet.isVersionByteValid(versionBytes.mainnet.p2sh)).toBe(true); + expect(mainnet.isVersionByteValid(versionBytes.mainnet.shielded)).toBe(true); // Invalid version bytes expect(mainnet.isVersionByteValid(1)).toBe(false); expect(mainnet.isVersionByteValid(105)).toBe(false); diff --git a/__tests__/models/shielded_output.test.ts b/__tests__/models/shielded_output.test.ts new file mode 100644 index 000000000..3a696ac9f --- /dev/null +++ b/__tests__/models/shielded_output.test.ts @@ -0,0 +1,209 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import ShieldedOutput from '../../src/models/shielded_output'; +import { ShieldedOutputMode } from '../../src/shielded/types'; + +function makeOutput( + overrides: Partial<{ + mode: ShieldedOutputMode; + commitment: Buffer; + rangeProof: Buffer; + tokenData: number; + script: Buffer; + ephemeralPubkey: Buffer; + assetCommitment: Buffer; + surjectionProof: Buffer; + value: bigint; + }> = {} +): ShieldedOutput { + return new ShieldedOutput( + overrides.mode ?? ShieldedOutputMode.AMOUNT_SHIELDED, + overrides.commitment ?? Buffer.alloc(33, 0xaa), + overrides.rangeProof ?? Buffer.alloc(10, 0xbb), + overrides.tokenData ?? 0, + overrides.script ?? Buffer.from([0x76, 0xa9, 0x14]), + overrides.ephemeralPubkey ?? Buffer.alloc(33, 0xcc), + overrides.assetCommitment, + overrides.surjectionProof, + overrides.value ?? 100n + ); +} + +describe('ShieldedOutput', () => { + describe('constructor', () => { + it('should set all fields', () => { + const out = makeOutput({ tokenData: 1, value: 50n }); + expect(out.mode).toBe(ShieldedOutputMode.AMOUNT_SHIELDED); + expect(out.tokenData).toBe(1); + expect(out.value).toBe(50n); + expect(out.assetCommitment).toBeUndefined(); + expect(out.surjectionProof).toBeUndefined(); + }); + + it('should accept optional assetCommitment and surjectionProof', () => { + const ac = Buffer.alloc(33, 0xdd); + const sp = Buffer.alloc(5, 0xee); + const out = makeOutput({ + mode: ShieldedOutputMode.FULLY_SHIELDED, + assetCommitment: ac, + surjectionProof: sp, + }); + expect(out.assetCommitment).toBe(ac); + expect(out.surjectionProof).toBe(sp); + }); + }); + + describe('serialize (AmountShielded)', () => { + it('should produce correct wire format', () => { + const commitment = Buffer.alloc(33, 0x01); + const rangeProof = Buffer.from([0x02, 0x03, 0x04]); + const script = Buffer.from([0x76, 0xa9]); + const ephemeralPubkey = Buffer.alloc(33, 0x05); + + const out = makeOutput({ + mode: ShieldedOutputMode.AMOUNT_SHIELDED, + commitment, + rangeProof, + tokenData: 0, + script, + ephemeralPubkey, + }); + + const parts = out.serialize(); + const serialized = Buffer.concat(parts); + + let offset = 0; + // mode (1) + expect(serialized[offset]).toBe(ShieldedOutputMode.AMOUNT_SHIELDED); + offset += 1; + // commitment (33) + expect(serialized.subarray(offset, offset + 33)).toEqual(commitment); + offset += 33; + // range_proof length (2) + data + expect(serialized.readUInt16BE(offset)).toBe(3); + offset += 2; + expect(serialized.subarray(offset, offset + 3)).toEqual(rangeProof); + offset += 3; + // script length (2) + data + expect(serialized.readUInt16BE(offset)).toBe(2); + offset += 2; + expect(serialized.subarray(offset, offset + 2)).toEqual(script); + offset += 2; + // token_data (1, AmountShielded only) + expect(serialized[offset]).toBe(0); + offset += 1; + // ephemeral_pubkey (33) + expect(serialized.subarray(offset, offset + 33)).toEqual(ephemeralPubkey); + offset += 33; + + expect(offset).toBe(serialized.length); + }); + }); + + describe('serialize (FullShielded)', () => { + it('should include asset_commitment and surjection_proof', () => { + const assetCommitment = Buffer.alloc(33, 0x07); + const surjectionProof = Buffer.from([0x08, 0x09]); + + const out = makeOutput({ + mode: ShieldedOutputMode.FULLY_SHIELDED, + assetCommitment, + surjectionProof, + }); + + const parts = out.serialize(); + const serialized = Buffer.concat(parts); + + // Find the asset_commitment after script + // mode(1) + commitment(33) + rp_len(2) + rp(10) + script_len(2) + script(3) + const acOffset = 1 + 33 + 2 + 10 + 2 + 3; + expect(serialized.subarray(acOffset, acOffset + 33)).toEqual(assetCommitment); + // surjection_proof length (2) + data (2) + expect(serialized.readUInt16BE(acOffset + 33)).toBe(2); + expect(serialized.subarray(acOffset + 35, acOffset + 37)).toEqual(surjectionProof); + }); + + it('should throw when FullShielded fields are missing', () => { + const out = makeOutput({ + mode: ShieldedOutputMode.FULLY_SHIELDED, + }); + + expect(() => out.serialize()).toThrow( + 'FullShielded output requires assetCommitment and surjectionProof' + ); + expect(() => out.serializeSighash()).toThrow('FullShielded output requires assetCommitment'); + }); + }); + + describe('serializeSighash', () => { + it('should exclude proofs for AmountShielded', () => { + const commitment = Buffer.alloc(33, 0x01); + const script = Buffer.from([0x76, 0xa9]); + const ephemeralPubkey = Buffer.alloc(33, 0x05); + + const out = makeOutput({ + mode: ShieldedOutputMode.AMOUNT_SHIELDED, + commitment, + tokenData: 2, + script, + ephemeralPubkey, + }); + + const parts = out.serializeSighash(); + const serialized = Buffer.concat(parts); + + let offset = 0; + // mode (1) + expect(serialized[offset]).toBe(ShieldedOutputMode.AMOUNT_SHIELDED); + offset += 1; + // commitment (33) + expect(serialized.subarray(offset, offset + 33)).toEqual(commitment); + offset += 33; + // token_data (1) + expect(serialized[offset]).toBe(2); + offset += 1; + // script (raw, no length prefix) + expect(serialized.subarray(offset, offset + 2)).toEqual(script); + offset += 2; + // ephemeral_pubkey (33) + expect(serialized.subarray(offset, offset + 33)).toEqual(ephemeralPubkey); + offset += 33; + + expect(offset).toBe(serialized.length); + }); + + it('should include asset_commitment for FullShielded', () => { + const assetCommitment = Buffer.alloc(33, 0xdd); + + const out = makeOutput({ + mode: ShieldedOutputMode.FULLY_SHIELDED, + assetCommitment, + }); + + const parts = out.serializeSighash(); + const serialized = Buffer.concat(parts); + + // mode(1) + commitment(33) + asset_commitment(33) + script(3) + ephemeral(33) + expect(serialized.length).toBe(1 + 33 + 33 + 3 + 33); + + // asset_commitment is after mode + commitment + const acOffset = 1 + 33; + expect(serialized.subarray(acOffset, acOffset + 33)).toEqual(assetCommitment); + }); + + it('should throw when ephemeral pubkey is missing', () => { + const out = makeOutput({ ephemeralPubkey: Buffer.alloc(0) }); + expect(() => out.serializeSighash()).toThrow(/Invalid ephemeral pubkey/); + }); + + it('should throw when ephemeral pubkey has wrong size', () => { + const out = makeOutput({ ephemeralPubkey: Buffer.alloc(32) }); + expect(() => out.serializeSighash()).toThrow(/expected 33 bytes/); + }); + }); +}); diff --git a/__tests__/models/transaction.test.ts b/__tests__/models/transaction.test.ts index 7ab1fa89a..baa7fd9bf 100644 --- a/__tests__/models/transaction.test.ts +++ b/__tests__/models/transaction.test.ts @@ -7,6 +7,7 @@ import lodash from 'lodash'; import Transaction from '../../src/models/transaction'; +import ShieldedOutput from '../../src/models/shielded_output'; import CreateTokenTransaction from '../../src/models/create_token_transaction'; import Output from '../../src/models/output'; import Input from '../../src/models/input'; @@ -15,7 +16,12 @@ import Address from '../../src/models/address'; import Network from '../../src/models/network'; import { hexToBuffer, bufferToHex } from '../../src/utils/buffer'; import helpers from '../../src/utils/helpers'; -import { DEFAULT_TX_VERSION, MAX_OUTPUTS, DEFAULT_SIGNAL_BITS } from '../../src/constants'; +import { + DEFAULT_TX_VERSION, + MAX_OUTPUTS, + MAX_SHIELDED_OUTPUTS, + DEFAULT_SIGNAL_BITS, +} from '../../src/constants'; import { CreateTokenTxInvalid, MaximumNumberInputsError, @@ -612,3 +618,43 @@ describe('NFT Validation', () => { expect(() => txInstance.validateNft(network)).toThrow('mint and melt is allowed'); }); }); + +describe('Transaction.validate shielded outputs', () => { + it('should accept MAX_SHIELDED_OUTPUTS shielded outputs', () => { + const tx = new Transaction([], [], { version: DEFAULT_TX_VERSION }); + const fakeShielded = { + mode: 1, + commitment: Buffer.alloc(33), + rangeProof: Buffer.alloc(10), + tokenData: 0, + script: Buffer.alloc(25), + ephemeralPubkey: Buffer.alloc(33), + value: 1n, + serialize: () => [Buffer.alloc(1)], + serializeSighash: () => [Buffer.alloc(1)], + }; + tx.shieldedOutputs = Array.from({ length: MAX_SHIELDED_OUTPUTS }, () => ({ + ...fakeShielded, + })) as ShieldedOutput[]; + expect(() => tx.validate()).not.toThrow(); + }); + + it('should reject more than MAX_SHIELDED_OUTPUTS shielded outputs', () => { + const tx = new Transaction([], [], { version: DEFAULT_TX_VERSION }); + const fakeShielded = { + mode: 1, + commitment: Buffer.alloc(33), + rangeProof: Buffer.alloc(10), + tokenData: 0, + script: Buffer.alloc(25), + ephemeralPubkey: Buffer.alloc(33), + value: 1n, + serialize: () => [Buffer.alloc(1)], + serializeSighash: () => [Buffer.alloc(1)], + }; + tx.shieldedOutputs = Array.from({ length: MAX_SHIELDED_OUTPUTS + 1 }, () => ({ + ...fakeShielded, + })) as ShieldedOutput[]; + expect(() => tx.validate()).toThrow(MaximumNumberOutputsError); + }); +}); diff --git a/__tests__/shielded/creation.test.ts b/__tests__/shielded/creation.test.ts new file mode 100644 index 000000000..2152f34c8 --- /dev/null +++ b/__tests__/shielded/creation.test.ts @@ -0,0 +1,151 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { createShieldedOutputs } from '../../src/shielded/creation'; +import { IShieldedCryptoProvider, ShieldedOutputMode } from '../../src/shielded/types'; +import Network from '../../src/models/network'; + +function makeMockProvider( + overrides: Partial = {} +): IShieldedCryptoProvider { + return { + generateRandomBlindingFactor: jest.fn().mockReturnValue(Buffer.alloc(32, 0x01)), + createAmountShieldedOutput: jest.fn().mockImplementation(() => { + return { + ephemeralPubkey: Buffer.alloc(33, 0x02), + commitment: Buffer.alloc(33, 0x03), + rangeProof: Buffer.alloc(10, 0x04), + blindingFactor: Buffer.alloc(32, 0x05), + }; + }), + createShieldedOutputWithBothBlindings: jest.fn().mockReturnValue({ + ephemeralPubkey: Buffer.alloc(33, 0x02), + commitment: Buffer.alloc(33, 0x03), + rangeProof: Buffer.alloc(10, 0x04), + blindingFactor: Buffer.alloc(32, 0x05), + assetCommitment: Buffer.alloc(33, 0x06), + assetBlindingFactor: Buffer.alloc(32, 0x07), + }), + rewindAmountShieldedOutput: jest.fn(), + rewindFullShieldedOutput: jest.fn(), + computeBalancingBlindingFactor: jest.fn().mockReturnValue(Buffer.alloc(32, 0x08)), + deriveTag: jest.fn().mockReturnValue(Buffer.alloc(32, 0x09)), + createAssetCommitment: jest.fn().mockReturnValue(Buffer.alloc(33, 0x0a)), + createSurjectionProof: jest.fn().mockReturnValue(Buffer.alloc(20, 0x0b)), + deriveEcdhSharedSecret: jest.fn(), + ...overrides, + }; +} + +const network = new Network('testnet'); + +// Use a valid testnet P2PKH address for script generation +const TEST_ADDRESS = 'WZ7pDnkPnxbs14GHdUFivFzPbzitwNtvZo'; +const TEST_SCAN_PUBKEY = `02${'aa'.repeat(32)}`; + +function makeDef(overrides = {}) { + return { + address: TEST_ADDRESS, + value: 100n, + token: '00', + scanPubkey: TEST_SCAN_PUBKEY, + shieldedMode: ShieldedOutputMode.AMOUNT_SHIELDED, + ...overrides, + }; +} + +describe('createShieldedOutputs', () => { + it('should create AmountShielded outputs with balancing blinding factor on last output', async () => { + const provider = makeMockProvider(); + const defs = [makeDef({ value: 60n }), makeDef({ value: 40n })]; + + const results = await createShieldedOutputs(defs, provider, network); + + expect(results).toHaveLength(2); + // First output uses random vbf + expect(provider.generateRandomBlindingFactor).toHaveBeenCalled(); + // Second (last) output uses balancing vbf + expect(provider.computeBalancingBlindingFactor).toHaveBeenCalledWith( + 40n, + expect.any(Buffer), + [], + expect.any(Array) + ); + // Both outputs should have scripts + expect(results[0].script).toBeDefined(); + expect(results[1].script).toBeDefined(); + }); + + it('should propagate crypto provider error on first output', async () => { + const provider = makeMockProvider({ + createAmountShieldedOutput: jest.fn().mockImplementation(() => { + throw new Error('crypto failure on output 0'); + }), + }); + const defs = [makeDef(), makeDef()]; + + await expect(createShieldedOutputs(defs, provider, network)).rejects.toThrow( + 'crypto failure on output 0' + ); + }); + + it('should propagate crypto provider error on second output', async () => { + let callCount = 0; + const provider = makeMockProvider({ + createAmountShieldedOutput: jest.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return { + ephemeralPubkey: Buffer.alloc(33, 0x02), + commitment: Buffer.alloc(33, 0x03), + rangeProof: Buffer.alloc(10, 0x04), + blindingFactor: Buffer.alloc(32, 0x05), + }; + } + throw new Error('crypto failure on output 1'); + }), + }); + const defs = [makeDef({ value: 60n }), makeDef({ value: 40n })]; + + await expect(createShieldedOutputs(defs, provider, network)).rejects.toThrow( + 'crypto failure on output 1' + ); + }); + + it('should create FullShielded outputs with surjection proofs', async () => { + const provider = makeMockProvider(); + const defs = [ + makeDef({ value: 60n, shieldedMode: ShieldedOutputMode.FULLY_SHIELDED }), + makeDef({ value: 40n, shieldedMode: ShieldedOutputMode.FULLY_SHIELDED }), + ]; + + const results = await createShieldedOutputs(defs, provider, network, [{ tokenUid: '00' }]); + + expect(results).toHaveLength(2); + // Both FullShielded outputs should have surjection proofs + expect(results[0].surjectionProof).toBeDefined(); + expect(results[1].surjectionProof).toBeDefined(); + expect(provider.createSurjectionProof).toHaveBeenCalledTimes(2); + }); + + it('should handle single output (no balancing needed)', async () => { + const provider = makeMockProvider(); + const defs = [makeDef({ value: 100n })]; + + const results = await createShieldedOutputs(defs, provider, network); + + expect(results).toHaveLength(1); + // Single output: isLast=true but createdOutputs.length=0, so random vbf path + expect(provider.computeBalancingBlindingFactor).not.toHaveBeenCalled(); + }); + + it('should return empty array for empty defs', async () => { + const provider = makeMockProvider(); + const results = await createShieldedOutputs([], provider, network); + expect(results).toEqual([]); + }); +}); diff --git a/__tests__/shielded/processing.test.ts b/__tests__/shielded/processing.test.ts new file mode 100644 index 000000000..9101bd17f --- /dev/null +++ b/__tests__/shielded/processing.test.ts @@ -0,0 +1,297 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { HDPrivateKey } from 'bitcore-lib'; +import { resolveTokenUid, processShieldedOutputs } from '../../src/shielded/processing'; +import { NATIVE_TOKEN_UID_HEX } from '../../src/constants'; +import { + ShieldedOutputMode, + IShieldedOutput, + IShieldedCryptoProvider, +} from '../../src/shielded/types'; +import { IHistoryTx } from '../../src/types'; + +function makeShieldedOutput(overrides: Partial = {}): IShieldedOutput { + return { + mode: ShieldedOutputMode.AMOUNT_SHIELDED, + commitment: 'aa'.repeat(33), + range_proof: 'bb'.repeat(10), + script: '76a914', + token_data: 0, + ephemeral_pubkey: 'cc'.repeat(33), + decoded: { type: 'P2PKH', address: 'WZ7pDnkPnxbs14GHdUFivFzPbzitwNtvZo' }, + ...overrides, + }; +} + +function makeHistoryTx(overrides: Partial = {}): IHistoryTx { + return { + tx_id: 'abc123', + version: 1, + weight: 1, + timestamp: 1000, + is_voided: false, + nonce: 0, + inputs: [], + outputs: [], + parents: [], + tokens: [], + token_name: undefined, + token_symbol: undefined, + height: 1, + ...overrides, + } as IHistoryTx; +} + +function makeMockProvider( + overrides: Partial = {} +): IShieldedCryptoProvider { + return { + generateRandomBlindingFactor: jest.fn().mockReturnValue(Buffer.alloc(32)), + createAmountShieldedOutput: jest.fn(), + createShieldedOutputWithBothBlindings: jest.fn(), + rewindAmountShieldedOutput: jest.fn(), + rewindFullShieldedOutput: jest.fn(), + computeBalancingBlindingFactor: jest.fn(), + deriveTag: jest.fn(), + createAssetCommitment: jest.fn(), + createSurjectionProof: jest.fn(), + deriveEcdhSharedSecret: jest.fn(), + ...overrides, + }; +} + +describe('resolveTokenUid', () => { + it('should return NATIVE_TOKEN_UID_HEX for token_data 0', () => { + const so = makeShieldedOutput({ token_data: 0 }); + const tx = makeHistoryTx(); + expect(resolveTokenUid(so, tx)).toBe(NATIVE_TOKEN_UID_HEX); + }); + + it('should return NATIVE_TOKEN_UID_HEX for token_data with authority bit set but index 0', () => { + // authority bit is 0x80, so 0x80 & 0x7f = 0 + const so = makeShieldedOutput({ token_data: 0x80 }); + const tx = makeHistoryTx(); + expect(resolveTokenUid(so, tx)).toBe(NATIVE_TOKEN_UID_HEX); + }); + + it('should return token from tx.tokens for token_data 1', () => { + const customToken = 'deadbeef'.repeat(8); + const so = makeShieldedOutput({ token_data: 1 }); + const tx = makeHistoryTx({ tokens: [customToken] }); + expect(resolveTokenUid(so, tx)).toBe(customToken); + }); + + it('should return second token for token_data 2', () => { + const tokenA = 'aaaa'.repeat(16); + const tokenB = 'bbbb'.repeat(16); + const so = makeShieldedOutput({ token_data: 2 }); + const tx = makeHistoryTx({ tokens: [tokenA, tokenB] }); + expect(resolveTokenUid(so, tx)).toBe(tokenB); + }); + + it('should throw for out-of-range token_data', () => { + const so = makeShieldedOutput({ token_data: 5 }); + const tx = makeHistoryTx({ tokens: ['aa'.repeat(32)] }); + expect(() => resolveTokenUid(so, tx)).toThrow(/Invalid token_data index 5/); + }); + + it('should mask authority bit when resolving', () => { + // token_data = 0x81 => index = 1 (0x81 & 0x7f = 1) + const customToken = 'ff'.repeat(32); + const so = makeShieldedOutput({ token_data: 0x81 }); + const tx = makeHistoryTx({ tokens: [customToken] }); + expect(resolveTokenUid(so, tx)).toBe(customToken); + }); +}); + +describe('processShieldedOutputs', () => { + it('should return empty array when no shielded outputs', async () => { + const tx = makeHistoryTx(); + const storage = { + getAddressInfo: jest.fn(), + logger: { warn: jest.fn(), debug: jest.fn() }, + } as any; + const provider = makeMockProvider(); + const result = await processShieldedOutputs(storage, tx, provider, 'pin'); + expect(result).toEqual([]); + }); + + it('should skip outputs without decoded address', async () => { + const so = makeShieldedOutput({ decoded: {} }); + const tx = makeHistoryTx({ shielded_outputs: [so] }); + const storage = { + getAddressInfo: jest.fn(), + logger: { warn: jest.fn(), debug: jest.fn() }, + } as any; + const provider = makeMockProvider(); + const result = await processShieldedOutputs(storage, tx, provider, 'pin'); + expect(result).toEqual([]); + expect(storage.getAddressInfo).not.toHaveBeenCalled(); + }); + + it('should skip outputs for unknown addresses', async () => { + const so = makeShieldedOutput(); + const tx = makeHistoryTx({ shielded_outputs: [so] }); + const storage = { + getAddressInfo: jest.fn().mockResolvedValue(null), + logger: { warn: jest.fn(), debug: jest.fn() }, + } as any; + const provider = makeMockProvider(); + const result = await processShieldedOutputs(storage, tx, provider, 'pin'); + expect(result).toEqual([]); + }); + + it('should skip output when scan key derivation fails and log warning', async () => { + const so = makeShieldedOutput(); + const tx = makeHistoryTx({ + shielded_outputs: [so], + outputs: [{ value: 10n } as any], + }); + + const storage = { + getAddressInfo: jest.fn().mockResolvedValue({ bip32AddressIndex: 0 }), + getScanXPrivKey: jest.fn().mockRejectedValue(new Error('no key')), + logger: { warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, + } as any; + + const provider = makeMockProvider(); + + const result = await processShieldedOutputs(storage, tx, provider, 'pin'); + expect(result).toEqual([]); + expect(storage.logger.warn).toHaveBeenCalled(); + }); + + it('should skip output when rewind throws and log debug message', async () => { + const so = makeShieldedOutput(); + const tx = makeHistoryTx({ + shielded_outputs: [so], + outputs: [], + }); + + // Mock a valid scan xpriv so key derivation succeeds and rewind is actually called. + // Use a real-looking xpriv that bitcore can parse. + // A real xpriv at depth 1 (chain-level) so deriveNonCompliantChild(index) works + const mockXpriv = new HDPrivateKey().deriveNonCompliantChild(0).xprivkey; + + const storage = { + getAddressInfo: jest.fn().mockResolvedValue({ bip32AddressIndex: 0 }), + getScanXPrivKey: jest.fn().mockResolvedValue(mockXpriv), + logger: { warn: jest.fn(), debug: jest.fn() }, + } as any; + + const provider = makeMockProvider({ + rewindAmountShieldedOutput: jest.fn().mockImplementation(() => { + throw new Error('decryption failed'); + }), + }); + + const result = await processShieldedOutputs(storage, tx, provider, 'pin'); + expect(result).toEqual([]); + // rewind was called and threw — the debug log should capture the failure + expect(provider.rewindAmountShieldedOutput).toHaveBeenCalled(); + expect(storage.logger.debug).toHaveBeenCalled(); + }); + + it('should process multiple shielded outputs and return only decryptable ones', async () => { + const so1 = makeShieldedOutput({ + decoded: { type: 'P2PKH', address: 'addr1' }, + }); + const so2 = makeShieldedOutput({ + decoded: { type: 'P2PKH', address: 'addr2' }, + }); + const so3 = makeShieldedOutput({ + decoded: { type: 'P2PKH', address: 'addr3' }, + }); + + const tx = makeHistoryTx({ + shielded_outputs: [so1, so2, so3], + outputs: [{ value: 5n } as any], + }); + + // addr1 is ours and succeeds, addr2 is unknown, addr3 is ours but rewind fails + // A real xpriv at depth 1 (chain-level) so deriveNonCompliantChild(index) works + const mockXpriv = new HDPrivateKey().deriveNonCompliantChild(0).xprivkey; + + const storage = { + getAddressInfo: jest.fn().mockImplementation(async (addr: string) => { + if (addr === 'addr1') return { bip32AddressIndex: 0 }; + if (addr === 'addr3') return { bip32AddressIndex: 2 }; + return null; + }), + getScanXPrivKey: jest.fn().mockResolvedValue(mockXpriv), + logger: { warn: jest.fn(), debug: jest.fn() }, + } as any; + + // addr1 rewind succeeds, addr3 rewind fails + let callCount = 0; + const provider = makeMockProvider({ + rewindAmountShieldedOutput: jest.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + // addr1: success + return { value: 100n, blindingFactor: Buffer.alloc(32, 0x01) }; + } + // addr3: failure + throw new Error('decryption failed'); + }), + }); + + const result = await processShieldedOutputs(storage, tx, provider, 'pin'); + // Only addr1 succeeded + expect(result).toHaveLength(1); + expect(result[0].address).toBe('addr1'); + expect(result[0].decrypted.value).toBe(100n); + // addr2 was skipped (unknown), addr3 failed rewind + expect(storage.getAddressInfo).toHaveBeenCalledWith('addr1'); + expect(storage.getAddressInfo).toHaveBeenCalledWith('addr2'); + expect(storage.getAddressInfo).toHaveBeenCalledWith('addr3'); + expect(provider.rewindAmountShieldedOutput).toHaveBeenCalledTimes(2); + }); + + it('should skip FullShielded output when asset commitment cross-check fails', async () => { + const so = makeShieldedOutput({ + mode: ShieldedOutputMode.FULLY_SHIELDED, + asset_commitment: 'dd'.repeat(33), + decoded: { type: 'P2PKH', address: 'addr1' }, + }); + const tx = makeHistoryTx({ + shielded_outputs: [so], + outputs: [], + }); + + // Use a valid xpriv so key derivation succeeds and rewind is actually called + const mockXpriv = new HDPrivateKey().deriveNonCompliantChild(0).xprivkey; + + const storage = { + getAddressInfo: jest.fn().mockResolvedValue({ bip32AddressIndex: 0 }), + getScanXPrivKey: jest.fn().mockResolvedValue(mockXpriv), + logger: { warn: jest.fn(), debug: jest.fn() }, + } as any; + + const provider = makeMockProvider({ + rewindFullShieldedOutput: jest.fn().mockReturnValue({ + value: 100n, + blindingFactor: Buffer.alloc(32, 0x02), + tokenUid: Buffer.alloc(32, 0x03), + assetBlindingFactor: Buffer.alloc(32, 0x04), + }), + deriveTag: jest.fn().mockReturnValue(Buffer.alloc(32, 0x05)), + // Return a DIFFERENT commitment than on-chain — cross-check should fail + createAssetCommitment: jest.fn().mockReturnValue(Buffer.alloc(33, 0xff)), + }); + + const result = await processShieldedOutputs(storage, tx, provider, 'pin'); + // Cross-check failed: createAssetCommitment returned a mismatched value + expect(result).toEqual([]); + // rewind WAS called (key derivation succeeded) + expect(provider.rewindFullShieldedOutput).toHaveBeenCalled(); + // cross-check failure was logged + expect(storage.logger.warn).toHaveBeenCalledWith(expect.stringContaining('cross-check failed')); + }); +}); diff --git a/__tests__/storage/memory_store.test.ts b/__tests__/storage/memory_store.test.ts index 01f0fa1a8..063cafa03 100644 --- a/__tests__/storage/memory_store.test.ts +++ b/__tests__/storage/memory_store.test.ts @@ -48,7 +48,7 @@ test('addresses methods', async () => { await expect(store.getAddressMeta('b')).resolves.toEqual(5); await expect(store.addressCount()).resolves.toEqual(3); - await expect(store.getCurrentAddress()).rejects.toThrow('Current address is not loaded'); + await expect(store.getCurrentAddress()).rejects.toThrow('Current legacy address is not loaded'); expect(store.walletData.currentAddressIndex).toEqual(-1); expect(store.walletData.lastLoadedAddressIndex).toEqual(0); await store.saveAddress({ @@ -283,6 +283,46 @@ test('utxo methods', async () => { } expect(buf).toHaveLength(1); expect(buf[0].txId).toEqual('tx02'); + + // Add a shielded UTXO + await store.saveUtxo({ + txId: 'tx03', + index: 0, + token: '00', + address: 'addr3', + value: 30n, + authorities: 0n, + timelock: null, + type: 1, + height: null, + shielded: true, + blindingFactor: 'aa'.repeat(32), + }); + + // Default (no shielded filter) returns all including shielded + buf = []; + for await (const u of store.selectUtxos({})) { + buf.push(u); + } + expect(buf).toHaveLength(2); // tx01 (HTR) + tx03 (HTR shielded) + + // shielded: true returns only shielded + buf = []; + for await (const u of store.selectUtxos({ shielded: true })) { + buf.push(u); + } + expect(buf).toHaveLength(1); + expect(buf[0].txId).toEqual('tx03'); + expect(buf[0].shielded).toBe(true); + + // shielded: false returns only transparent + buf = []; + for await (const u of store.selectUtxos({ shielded: false })) { + buf.push(u); + } + expect(buf).toHaveLength(1); + expect(buf[0].txId).toEqual('tx01'); + expect(buf[0].shielded).toBeUndefined(); }); test('access data methods', async () => { diff --git a/__tests__/storage/storage.test.ts b/__tests__/storage/storage.test.ts index 873b70946..8a2f2c601 100644 --- a/__tests__/storage/storage.test.ts +++ b/__tests__/storage/storage.test.ts @@ -94,7 +94,7 @@ describe('handleStop', () => { await storage.registerNanoContract('abc', testNano); // We have 1 transaction await expect(store.historyCount()).resolves.toEqual(1); - // 20 addresses + // addressCount returns only legacy addresses (shielded/shielded-spend are filtered) await expect(store.addressCount()).resolves.toEqual(20); // And 1 registered token let tokens = await toArray(storage.getRegisteredTokens()); @@ -134,7 +134,7 @@ describe('handleStop', () => { // handleStop with cleanStorage = true await storage.handleStop({ cleanStorage: true }); - // Will clean the history bit not addresses or registered tokens + // Will clean the history but not addresses or registered tokens await expect(store.historyCount()).resolves.toEqual(0); await expect(store.addressCount()).resolves.toEqual(20); await expect(store.isTokenRegistered(testToken.uid)).resolves.toBeTruthy(); @@ -177,7 +177,7 @@ describe('handleStop', () => { // handleStop with cleanAddresses = true await loadAddresses(0, 20, storage); await storage.handleStop({ cleanTokens: true }); - // Will clean the history bit not addresses + // Will clean the tokens but not addresses await expect(store.historyCount()).resolves.toEqual(1); await expect(store.addressCount()).resolves.toEqual(20); await expect(store.isTokenRegistered(testToken.uid)).resolves.toBeFalsy(); diff --git a/__tests__/utils/address.test.ts b/__tests__/utils/address.test.ts index ae968f30b..0aedd78a7 100644 --- a/__tests__/utils/address.test.ts +++ b/__tests__/utils/address.test.ts @@ -175,3 +175,24 @@ test('Get address from pubkey', async () => { expect(address.base58).toBe(base58); expect(address.validateAddress()).toBeTruthy(); }); + +test('deriveShieldedAddressFromStorage returns null when shielded keys unavailable', async () => { + const { HDPrivateKey } = await import('bitcore-lib'); + const { deriveShieldedAddressFromStorage } = await import('../../src/utils/address'); + const { encryptData } = await import('../../src/utils/crypto'); + const store = new MemoryStore(); + const storage = new Storage(store); + + // Provide minimal access data WITHOUT shielded keys (legacy-only wallet) + const xpriv = new HDPrivateKey(); + await store.saveAccessData({ + xpubkey: xpriv.xpubkey, + mainKey: encryptData(xpriv.xprivkey, '123'), + walletType: 'p2pkh' as const, + walletFlags: 0, + // No scanXpubkey, no spendXpubkey — shielded keys absent + }); + + const result = await deriveShieldedAddressFromStorage(0, storage); + expect(result).toBeNull(); +}); diff --git a/__tests__/utils/transaction.test.ts b/__tests__/utils/transaction.test.ts index 572e7b01b..daaaeb9ce 100644 --- a/__tests__/utils/transaction.test.ts +++ b/__tests__/utils/transaction.test.ts @@ -733,3 +733,51 @@ test('convertTransactionToHistoryTx', async () => { getTxSpy.mockRestore(); } }); + +test('getSignatureForTx uses spend key chain for shielded-spend addresses', async () => { + const legacyXpriv = new HDPrivateKey(); + const spendXpriv = new HDPrivateKey(); + const store = new MemoryStore(); + const storage = new Storage(store); + + jest.spyOn(storage, 'getMainXPrivKey').mockResolvedValue(legacyXpriv.xprivkey); + jest + .spyOn(storage, 'getSpendXPrivKey') + .mockResolvedValue(spendXpriv.deriveNonCompliantChild(0).xprivkey); + + // Use a non-zero index to verify the derivation path actually uses bip32AddressIndex + // (index 0 can pass even if the production path ignores the index). + const shieldedAddr = 'shielded-spend-addr'; + const addressIndex = 3; + jest.spyOn(storage, 'getAddressInfo').mockImplementation(async addr => { + if (addr === shieldedAddr) { + return { + base58: addr, + bip32AddressIndex: addressIndex, + addressType: 'shielded-spend' as const, + }; + } + return null; + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async function* getSpentMock(inputs: any) { + yield { + index: 0, + input: inputs[0], + tx: { outputs: [{ decoded: { address: shieldedAddr } }] }, + }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(storage, 'getSpentTxs').mockImplementation(getSpentMock as any); + + const input = new Input('cafe'.repeat(8), 0); + const tx = new Transaction([input], []); + + const sigData = await transaction.getSignatureForTx(tx, storage, '123'); + + expect(sigData.inputSignatures).toHaveLength(1); + expect(sigData.inputSignatures[0].addressIndex).toBe(addressIndex); + // getSpendXPrivKey should have been called (not just getMainXPrivKey) + expect(storage.getSpendXPrivKey).toHaveBeenCalledWith('123'); +}); diff --git a/__tests__/wallet/walletServiceStorageProxy.test.ts b/__tests__/wallet/walletServiceStorageProxy.test.ts index 6981cebd0..6fe6f937e 100644 --- a/__tests__/wallet/walletServiceStorageProxy.test.ts +++ b/__tests__/wallet/walletServiceStorageProxy.test.ts @@ -17,6 +17,7 @@ import { AddressInfoObject } from '../../src/wallet/types'; jest.mock('../../src/utils/transaction', () => ({ getSignatureForTx: jest.fn(), + isShieldedOutputEntry: jest.fn().mockReturnValue(false), })); describe('WalletServiceStorageProxy', () => { diff --git a/package-lock.json b/package-lock.json index 3d1091fcd..5af4ca278 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "@hathor/wallet-lib", - "version": "2.17.0", + "version": "0.0.1-shielded", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hathor/wallet-lib", - "version": "2.17.0", + "version": "0.0.1-shielded", "license": "MIT", "dependencies": { + "@hathor/ct-crypto-node": "0.3.0", "axios": "1.7.7", "bitcore-lib": "8.25.10", "bitcore-mnemonic": "8.25.10", @@ -51,7 +52,7 @@ "patch-package": "8.0.0", "prettier": "3.3.2", "typescript": "5.4.5", - "winston": "^3.19.0" + "winston": "3.19.0" }, "engines": { "node": ">=22.0.0", @@ -2270,6 +2271,24 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@hathor/ct-crypto-node": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@hathor/ct-crypto-node/-/ct-crypto-node-0.3.0.tgz", + "integrity": "sha512-DYt0sEjxdPRB9dvVadCNHkWIKaRDK7IcWquLBicqXHg45YKW3X/ukjDz5Zqal3Pj+XwUgkHllphD5mept/Q1vA==", + "cpu": [ + "x64", + "arm64" + ], + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", diff --git a/package.json b/package.json index 217d20d2f..379251bad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hathor/wallet-lib", - "version": "2.17.0", + "version": "0.0.1-shielded", "description": "Library used by Hathor Wallet", "main": "lib/index.js", "engines": { @@ -14,6 +14,7 @@ "/lib" ], "dependencies": { + "@hathor/ct-crypto-node": "0.3.0", "axios": "1.7.7", "bitcore-lib": "8.25.10", "bitcore-mnemonic": "8.25.10", diff --git a/setupTests-integration.js b/setupTests-integration.js index 0c9804416..7fdf6e9bc 100644 --- a/setupTests-integration.js +++ b/setupTests-integration.js @@ -28,7 +28,6 @@ Transaction.prototype.calculateWeight = function () { return 1; }; - /** * Disable HTTP keep-alive for axios to prevent "socket hang up" errors in Jest. * diff --git a/src/constants.ts b/src/constants.ts index 8d53a963c..bc445c25b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -145,6 +145,17 @@ export const AUTHORITY_TOKEN_DATA = TOKEN_AUTHORITY_MASK | 1; */ export const NATIVE_TOKEN_UID: string = '00'; +/** + * Native token uid as 32-byte hex (used in cryptographic operations such as shielded outputs) + */ +export const NATIVE_TOKEN_UID_HEX: string = '00'.repeat(32); + +/** + * Zero blinding factor (32 zero bytes) representing transparent (unblinded) + * inputs/outputs in Pedersen commitment balance equations. + */ +export const ZERO_TWEAK: Buffer = Buffer.alloc(32, 0); + /** * Default HTR token config */ @@ -213,10 +224,15 @@ export const TX_WEIGHT_CONSTANTS = { export const MAX_INPUTS: number = 255; /** - * Maximum number of outputs + * Maximum number of transparent outputs */ export const MAX_OUTPUTS: number = 255; +/** + * Maximum number of shielded outputs + */ +export const MAX_SHIELDED_OUTPUTS: number = 32; + /** * Maximum number of fee entries in a FeeHeader */ @@ -280,6 +296,21 @@ export const P2SH_ACCT_PATH = `m/45'/${HATHOR_BIP44_CODE}'/0'`; */ export const P2PKH_ACCT_PATH = `m/44'/${HATHOR_BIP44_CODE}'/0'`; +/** + * Account path for shielded scan keys (ECDH / view-only access to shielded outputs). + * Uses a separate account from legacy P2PKH (account 0') so that the scan key only + * grants read access to shielded outputs, not spending authority over legacy funds. + * This separation is critical for view key delegation (e.g., scanning services) and + * prevents compromising legacy address signing keys when debugging shielded decryption. + */ +export const SHIELDED_SCAN_ACCT_PATH = `m/44'/${HATHOR_BIP44_CODE}'/1'`; + +/** + * Account path for shielded spend keys (signing/spending authority). + * Uses account 2' to remain separate from both legacy (0') and scan (1') keys. + */ +export const SHIELDED_SPEND_ACCT_PATH = `m/44'/${HATHOR_BIP44_CODE}'/2'`; + /** * String to be prefixed before signed messages using bitcore-message */ @@ -295,6 +326,18 @@ export const DEFAULT_ADDRESS_SCANNING_POLICY: AddressScanPolicy = SCANNING_POLIC */ export const FEE_PER_OUTPUT: bigint = 1n; +/** + * Fee per AmountShielded output (in HTR base units). + * Defined by hathor-core shielded transaction protocol (no external docs yet). + */ +export const FEE_PER_AMOUNT_SHIELDED_OUTPUT: bigint = 1n; + +/** + * Fee per FullShielded output (in HTR base units). + * Defined by hathor-core shielded transaction protocol (no external docs yet). + */ +export const FEE_PER_FULL_SHIELDED_OUTPUT: bigint = 2n; + /** * Fee divisor */ diff --git a/src/headers/parser.ts b/src/headers/parser.ts index 3b6745bdb..79009a468 100644 --- a/src/headers/parser.ts +++ b/src/headers/parser.ts @@ -9,12 +9,14 @@ import { VertexHeaderId } from './types'; import { HeaderStaticType } from './base'; import NanoContractHeader from '../nano_contracts/header'; import FeeHeader from './fee'; +import ShieldedOutputsHeader from './shielded_outputs'; export default class HeaderParser { static getSupportedHeaders(): Record { return { [VertexHeaderId.NANO_HEADER]: NanoContractHeader, [VertexHeaderId.FEE_HEADER]: FeeHeader, + [VertexHeaderId.SHIELDED_OUTPUTS_HEADER]: ShieldedOutputsHeader, }; } diff --git a/src/headers/shielded_outputs.ts b/src/headers/shielded_outputs.ts new file mode 100644 index 000000000..44c0031a4 --- /dev/null +++ b/src/headers/shielded_outputs.ts @@ -0,0 +1,148 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { getVertexHeaderIdBuffer, getVertexHeaderIdFromBuffer, VertexHeaderId } from './types'; +import Header from './base'; +import Network from '../models/network'; +import ShieldedOutput from '../models/shielded_output'; +import { ShieldedOutputMode } from '../shielded/types'; +import { intToBytes, unpackToInt } from '../utils/buffer'; + +/** + * ShieldedOutputsHeader contains shielded outputs for a transaction. + * + * Wire format (matching hathor-core): + * [header_id: 1 byte (0x12)] + * [num_outputs: 1 byte] + * [shielded_output_0...] + * [shielded_output_1...] + * ... + */ +class ShieldedOutputsHeader extends Header { + shieldedOutputs: ShieldedOutput[]; + + constructor(shieldedOutputs: ShieldedOutput[]) { + super(); + this.shieldedOutputs = shieldedOutputs; + } + + private serializeWith(array: Buffer[], outputSerializer: (output: ShieldedOutput) => Buffer[]) { + array.push(getVertexHeaderIdBuffer(VertexHeaderId.SHIELDED_OUTPUTS_HEADER)); + array.push(intToBytes(this.shieldedOutputs.length, 1)); + for (const output of this.shieldedOutputs) { + array.push(...outputSerializer(output)); + } + } + + serializeFields(array: Buffer[]) { + this.serializeWith(array, o => o.serialize()); + } + + serialize(array: Buffer[]) { + this.serializeFields(array); + } + + serializeSighash(array: Buffer[]) { + this.serializeWith(array, o => o.serializeSighash()); + } + + static deserialize(srcBuf: Buffer, _network: Network): [Header, Buffer] { + let buf = Buffer.from(srcBuf); + + // Validate header ID + if (getVertexHeaderIdFromBuffer(buf) !== VertexHeaderId.SHIELDED_OUTPUTS_HEADER) { + throw new Error('Invalid vertex header id for shielded outputs header.'); + } + buf = buf.subarray(1); + + // Number of shielded outputs (1 byte) + let numOutputs: number; + // eslint-disable-next-line prefer-const + [numOutputs, buf] = unpackToInt(1, false, buf); + + const outputs: ShieldedOutput[] = []; + for (let i = 0; i < numOutputs; i++) { + // Mode (1 byte) + if (buf.length < 1) throw new Error('Truncated shielded output: missing mode byte'); + let mode: number; + [mode, buf] = unpackToInt(1, false, buf); + + // Commitment (33 bytes) + if (buf.length < 33) throw new Error('Truncated shielded output: missing commitment'); + const commitment = Buffer.from(buf.subarray(0, 33)); + buf = buf.subarray(33); + + // Range proof (2 bytes length + variable) + if (buf.length < 2) throw new Error('Truncated shielded output: missing range proof length'); + let rpLen: number; + [rpLen, buf] = unpackToInt(2, false, buf); + if (buf.length < rpLen) throw new Error('Truncated shielded output: incomplete range proof'); + const rangeProof = Buffer.from(buf.subarray(0, rpLen)); + buf = buf.subarray(rpLen); + + // Script (2 bytes length + variable) + if (buf.length < 2) throw new Error('Truncated shielded output: missing script length'); + let scriptLen: number; + [scriptLen, buf] = unpackToInt(2, false, buf); + if (buf.length < scriptLen) throw new Error('Truncated shielded output: incomplete script'); + const script = Buffer.from(buf.subarray(0, scriptLen)); + buf = buf.subarray(scriptLen); + + let tokenData = 0; + let assetCommitment: Buffer | undefined; + let surjectionProof: Buffer | undefined; + + if (mode === ShieldedOutputMode.AMOUNT_SHIELDED) { + // Token data (1 byte) + if (buf.length < 1) throw new Error('Truncated AmountShielded output: missing token_data'); + [tokenData, buf] = unpackToInt(1, false, buf); + } else if (mode === ShieldedOutputMode.FULLY_SHIELDED) { + // Asset commitment (33 bytes) + if (buf.length < 33) + throw new Error('Truncated FullShielded output: missing asset commitment'); + assetCommitment = Buffer.from(buf.subarray(0, 33)); + buf = buf.subarray(33); + + // Surjection proof (2 bytes length + variable) + if (buf.length < 2) + throw new Error('Truncated FullShielded output: missing surjection proof length'); + let spLen: number; + [spLen, buf] = unpackToInt(2, false, buf); + if (buf.length < spLen) + throw new Error('Truncated FullShielded output: incomplete surjection proof'); + surjectionProof = Buffer.from(buf.subarray(0, spLen)); + buf = buf.subarray(spLen); + } else { + throw new Error(`Unsupported shielded output mode: ${mode}`); + } + + // Ephemeral pubkey (33 bytes) + if (buf.length < 33) throw new Error('Truncated shielded output: missing ephemeral pubkey'); + const ephemeralPubkey = Buffer.from(buf.subarray(0, 33)); + buf = buf.subarray(33); + + outputs.push( + new ShieldedOutput( + mode, + commitment, + rangeProof, + tokenData, + script, + ephemeralPubkey, + assetCommitment, + surjectionProof, + 0n // value is not stored on-chain; it's recovered via rewind + ) + ); + } + + const header = new ShieldedOutputsHeader(outputs); + return [header, buf]; + } +} + +export default ShieldedOutputsHeader; diff --git a/src/headers/types.ts b/src/headers/types.ts index 9b6067919..156b209d0 100644 --- a/src/headers/types.ts +++ b/src/headers/types.ts @@ -13,6 +13,7 @@ export const enum VertexHeaderId { NANO_HEADER = '10', FEE_HEADER = '11', + SHIELDED_OUTPUTS_HEADER = '12', } export function getVertexHeaderIdBuffer(id: VertexHeaderId): Buffer { @@ -26,6 +27,8 @@ export function getVertexHeaderIdFromBuffer(buf: Buffer): VertexHeaderId { return VertexHeaderId.NANO_HEADER; case VertexHeaderId.FEE_HEADER: return VertexHeaderId.FEE_HEADER; + case VertexHeaderId.SHIELDED_OUTPUTS_HEADER: + return VertexHeaderId.SHIELDED_OUTPUTS_HEADER; default: throw new Error('Invalid VertexHeaderId'); } diff --git a/src/lib.ts b/src/lib.ts index 9c7e70d62..9856468b1 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -60,6 +60,7 @@ import { import { stopGLLBackgroundTask } from './sync/gll'; import * as enums from './models/enum'; import { Fee } from './utils/fee'; +import * as shielded from './shielded'; export { PartialTx, @@ -124,6 +125,7 @@ export { WalletTxTemplateInterpreter, stopGLLBackgroundTask, enums, + shielded, }; // Re-export all types from every module. @@ -134,6 +136,7 @@ export * from './nano_contracts/types'; export * from './models/types'; export * from './template/transaction/types'; export * from './headers/types'; +export * from './shielded/types'; export * from './models/enum'; export * from './wallet/types'; export * from './new/types'; diff --git a/src/models/address.ts b/src/models/address.ts index f290a7f9a..6c0f8273c 100644 --- a/src/models/address.ts +++ b/src/models/address.ts @@ -5,7 +5,12 @@ * LICENSE file in the root directory of this source tree. */ -import { encoding, util } from 'bitcore-lib'; +import { + encoding, + util, + Address as BitcoreAddress, + PublicKey as bitcorePublicKey, +} from 'bitcore-lib'; import _ from 'lodash'; import { AddressError } from '../errors'; import Network from './network'; @@ -13,6 +18,14 @@ import P2PKH from './p2pkh'; import P2SH from './p2sh'; import helpers from '../utils/helpers'; +/** Valid address types */ +export type AddressType = 'p2pkh' | 'p2sh' | 'shielded'; + +/** Shielded address payload length: 1B version + 33B scan + 33B spend = 67B + 4B checksum = 71B */ +const SHIELDED_ADDR_LENGTH = 71; +/** Legacy address payload length: 1B version + 20B hash + 4B checksum = 25B */ +const LEGACY_ADDR_LENGTH = 25; + class Address { // String with address as base58 base58: string; @@ -67,9 +80,10 @@ class Address { /** * Validate address * - * 1. Address must have 25 bytes + * Supports both legacy 25-byte addresses and 71-byte shielded addresses. + * 1. Address must have 25 bytes (legacy) or 71 bytes (shielded) * 2. Address checksum must be valid - * 3. Address first byte must match one of the options for P2PKH or P2SH + * 3. Address first byte must match one of the valid version bytes * * @throws {AddressError} Will throw an error if address is not valid * @@ -82,9 +96,12 @@ class Address { const errorMessage = `Invalid address: ${this.base58}.`; // Validate address length - if (addressBytes.length !== 25) { + if ( + addressBytes.length !== LEGACY_ADDR_LENGTH && + addressBytes.length !== SHIELDED_ADDR_LENGTH + ) { throw new AddressError( - `${errorMessage} Address has ${addressBytes.length} bytes and should have 25.` + `${errorMessage} Address has ${addressBytes.length} bytes and should have ${LEGACY_ADDR_LENGTH} or ${SHIELDED_ADDR_LENGTH}.` ); } @@ -102,11 +119,11 @@ class Address { return true; } - // Validate version byte. Should be the p2pkh or p2sh + // Validate version byte const firstByte = addressBytes[0]; if (!this.network.isVersionByteValid(firstByte)) { throw new AddressError( - `${errorMessage} Invalid network byte. Expected: ${this.network.versionBytes.p2pkh} or ${this.network.versionBytes.p2sh} and received ${firstByte}.` + `${errorMessage} Invalid network byte. Expected: ${this.network.versionBytes.p2pkh}, ${this.network.versionBytes.p2sh}, or ${this.network.versionBytes.shielded} and received ${firstByte}.` ); } return true; @@ -116,19 +133,22 @@ class Address { * Get address type * * Will check the version byte of the address against the network's version bytes. - * Valid types are p2pkh and p2sh. + * Valid types are p2pkh, p2sh, and shielded. * * @throws {AddressError} Will throw an error if address is not valid * - * @return {string} + * @return {AddressType} * @memberof Address * @inner */ - getType(): 'p2pkh' | 'p2sh' { + getType(): AddressType { this.validateAddress(); const addressBytes = this.decode(); const firstByte = addressBytes[0]; + if (firstByte === this.network.versionBytes.shielded) { + return 'shielded'; + } if (firstByte === this.network.versionBytes.p2pkh) { return 'p2pkh'; } @@ -138,11 +158,79 @@ class Address { throw new AddressError('Invalid address type.'); } + /** + * Check if this is a shielded address (71-byte format with scan + spend pubkeys) + * + * @return {boolean} + * @memberof Address + * @inner + */ + isShielded(): boolean { + try { + return this.getType() === 'shielded'; + } catch { + return false; + } + } + + /** + * Extract the 33-byte scan pubkey from a shielded address. + * Bytes [1..34) of the decoded address. + * + * @throws {AddressError} If address is not shielded + * @return {Buffer} 33-byte compressed EC public key + * @memberof Address + * @inner + */ + getScanPubkey(): Buffer { + if (!this.isShielded()) { + throw new AddressError('Not a shielded address'); + } + const addressBytes = this.decode(); + return Buffer.from(addressBytes.subarray(1, 34)); + } + + /** + * Extract the 33-byte spend pubkey from a shielded address. + * Bytes [34..67) of the decoded address. + * + * @throws {AddressError} If address is not shielded + * @return {Buffer} 33-byte compressed EC public key + * @memberof Address + * @inner + */ + getSpendPubkey(): Buffer { + if (!this.isShielded()) { + throw new AddressError('Not a shielded address'); + } + const addressBytes = this.decode(); + return Buffer.from(addressBytes.subarray(34, 67)); + } + + /** + * Derive the on-chain P2PKH address from the spend_pubkey of a shielded address. + * This is the address that appears on-chain in the shielded output script. + * + * @throws {AddressError} If address is not shielded + * @return {Address} The P2PKH address derived from HASH160(spend_pubkey) + * @memberof Address + * @inner + */ + getSpendAddress(): Address { + const spendPubkey = this.getSpendPubkey(); + const base58 = new BitcoreAddress( + bitcorePublicKey(spendPubkey), + this.network.bitcoreNetwork + ).toString(); + return new Address(base58, { network: this.network }); + } + /** * Get address script * - * Will get the type of the address (p2pkh or p2sh) - * then create the script + * Will get the type of the address (p2pkh, p2sh, or shielded) + * then create the script. + * For shielded addresses, creates a P2PKH script from the spend_pubkey. * * @throws {AddressError} Will throw an error if address is not valid * @@ -152,6 +240,12 @@ class Address { */ getScript(): Buffer { const addressType = this.getType(); + if (addressType === 'shielded') { + // For shielded addresses, derive P2PKH script from spend_pubkey + const spendAddress = this.getSpendAddress(); + const p2pkh = new P2PKH(spendAddress); + return p2pkh.createScript(); + } if (addressType === 'p2pkh') { const p2pkh = new P2PKH(this); return p2pkh.createScript(); diff --git a/src/models/network.ts b/src/models/network.ts index 00b6ff2ef..fe87c52ef 100644 --- a/src/models/network.ts +++ b/src/models/network.ts @@ -14,18 +14,21 @@ const versionBytes = { mainnet: { p2pkh: 0x28, p2sh: 0x64, + shielded: 0x3c, xpriv: 0x03523b05, // htpr xpub: 0x0488b21e, // xpub // 0x03523a9c -> htpb }, testnet: { p2pkh: 0x49, p2sh: 0x87, + shielded: 0x5d, xpriv: 0x0434c8c4, // tnpr xpub: 0x0488b21e, // xpub // 0x0434c85b -> tnpb }, privatenet: { p2pkh: 0x49, p2sh: 0x87, + shielded: 0x5d, xpriv: 0x0434c8c4, // tnpr xpub: 0x0488b21e, // xpub // 0x0434c85b -> tnpb }, @@ -115,6 +118,7 @@ const networkOptions = { type versionBytesType = { p2pkh: number; p2sh: number; + shielded: number; }; class Network { @@ -164,7 +168,11 @@ class Network { */ isVersionByteValid(version: number): boolean { const instanceVersionBytes = this.getVersionBytes(); - return version === instanceVersionBytes.p2pkh || version === instanceVersionBytes.p2sh; + return ( + version === instanceVersionBytes.p2pkh || + version === instanceVersionBytes.p2sh || + version === instanceVersionBytes.shielded + ); } /** diff --git a/src/models/shielded_output.ts b/src/models/shielded_output.ts new file mode 100644 index 000000000..955f3e61d --- /dev/null +++ b/src/models/shielded_output.ts @@ -0,0 +1,150 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { intToBytes } from '../utils/buffer'; +import { ShieldedOutputMode } from '../shielded/types'; +import { OutputValueType } from '../types'; + +const EPHEMERAL_PUBKEY_SIZE = 33; + +/** + * Represents a shielded output in a transaction. + * + * Wire format (matching hathor-core serialization order): + * mode(1) | commitment(33) | rp_len(2) | range_proof(var) | + * script_len(2) | script(var) | + * [if AMOUNT_SHIELDED]: token_data(1) + * [if FULLY_SHIELDED]: asset_commitment(33) | sp_len(2) | surjection_proof(var) + * ephemeral_pubkey(33) + */ +class ShieldedOutput { + mode: ShieldedOutputMode; + + commitment: Buffer; + + rangeProof: Buffer; + + tokenData: number; + + script: Buffer; + + ephemeralPubkey: Buffer; + + assetCommitment?: Buffer; + + surjectionProof?: Buffer; + + /** The plaintext value, used for weight calculation. Not serialized on-chain. */ + value: OutputValueType; + + // eslint-disable-next-line default-param-last + constructor( + mode: ShieldedOutputMode, + commitment: Buffer, + rangeProof: Buffer, + tokenData: number, + script: Buffer, + ephemeralPubkey: Buffer, + assetCommitment?: Buffer, + surjectionProof?: Buffer, + value: OutputValueType = 0n + ) { + this.mode = mode; + this.commitment = commitment; + this.rangeProof = rangeProof; + this.tokenData = tokenData; + this.script = script; + this.ephemeralPubkey = ephemeralPubkey; + this.assetCommitment = assetCommitment; + this.value = value; + this.surjectionProof = surjectionProof; + } + + /** + * Serialize a shielded output to bytes (wire format matching hathor-core). + */ + serialize(): Buffer[] { + const arr: Buffer[] = []; + + // Mode (1 byte) + arr.push(intToBytes(this.mode, 1)); + + // Commitment (33 bytes) + arr.push(this.commitment); + + // Range proof (2 bytes length + variable) + arr.push(intToBytes(this.rangeProof.length, 2)); + arr.push(this.rangeProof); + + // Script (2 bytes length + variable) + arr.push(intToBytes(this.script.length, 2)); + arr.push(this.script); + + if (this.mode === ShieldedOutputMode.AMOUNT_SHIELDED) { + // Token data (1 byte, AmountShielded only) + arr.push(intToBytes(this.tokenData, 1)); + } else if (this.mode === ShieldedOutputMode.FULLY_SHIELDED) { + if (!this.assetCommitment || !this.surjectionProof) { + throw new Error('FullShielded output requires assetCommitment and surjectionProof'); + } + // Asset commitment (33 bytes, FullShielded only) + arr.push(this.assetCommitment); + // Surjection proof (2 bytes length + variable, FullShielded only) + arr.push(intToBytes(this.surjectionProof.length, 2)); + arr.push(this.surjectionProof); + } else { + throw new Error(`Unsupported shielded output mode: ${this.mode}`); + } + + // Ephemeral pubkey (always 33 bytes) + if (!this.ephemeralPubkey || this.ephemeralPubkey.length !== EPHEMERAL_PUBKEY_SIZE) { + throw new Error( + `Invalid ephemeral pubkey: expected ${EPHEMERAL_PUBKEY_SIZE} bytes, ` + + `got ${this.ephemeralPubkey?.length ?? 0}` + ); + } + arr.push(this.ephemeralPubkey); + + return arr; + } + + /** + * Serialize for sighash (excludes proofs). + */ + serializeSighash(): Buffer[] { + const arr: Buffer[] = []; + + arr.push(intToBytes(this.mode, 1)); + arr.push(this.commitment); + + if (this.mode === ShieldedOutputMode.AMOUNT_SHIELDED) { + arr.push(intToBytes(this.tokenData, 1)); + } else if (this.mode === ShieldedOutputMode.FULLY_SHIELDED) { + if (!this.assetCommitment) { + throw new Error('FullShielded output requires assetCommitment'); + } + arr.push(this.assetCommitment); + } else { + throw new Error(`Unsupported shielded output mode: ${this.mode}`); + } + + arr.push(this.script); + + // Always include ephemeral pubkey in sighash + if (!this.ephemeralPubkey || this.ephemeralPubkey.length !== EPHEMERAL_PUBKEY_SIZE) { + throw new Error( + `Invalid ephemeral pubkey: expected ${EPHEMERAL_PUBKEY_SIZE} bytes, ` + + `got ${this.ephemeralPubkey?.length ?? 0}` + ); + } + arr.push(this.ephemeralPubkey); + + return arr; + } +} + +export default ShieldedOutput; diff --git a/src/models/transaction.ts b/src/models/transaction.ts index 2ed89ea9f..b58fafb6d 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -17,6 +17,7 @@ import { DEFAULT_TX_VERSION, MAX_INPUTS, MAX_OUTPUTS, + MAX_SHIELDED_OUTPUTS, MERGED_MINED_BLOCK_VERSION, TX_HASH_SIZE_BYTES, TX_WEIGHT_CONSTANTS, @@ -32,6 +33,7 @@ import { } from '../utils/buffer'; import Input from './input'; import Output from './output'; +import ShieldedOutput from './shielded_output'; import Network from './network'; import { MaximumNumberInputsError, MaximumNumberOutputsError } from '../errors'; import { OutputValueType } from '../types'; @@ -39,6 +41,7 @@ import type Header from '../headers/base'; import NanoContractHeader from '../nano_contracts/header'; import FeeHeader from '../headers/fee'; import HeaderParser from '../headers/parser'; +import ShieldedOutputsHeader from '../headers/shielded_outputs'; import { getVertexHeaderIdFromBuffer } from '../headers/types'; enum txType { @@ -74,6 +77,8 @@ class Transaction { outputs: Output[]; + shieldedOutputs: ShieldedOutput[]; + signalBits: number; version: number; @@ -112,6 +117,7 @@ class Transaction { this.inputs = inputs; this.outputs = outputs; + this.shieldedOutputs = []; this.signalBits = signalBits!; this.version = version!; this.weight = weight!; @@ -219,7 +225,7 @@ class Transaction { // Len inputs array.push(intToBytes(this.inputs.length, 1)); - // Len outputs + // Len outputs (transparent only; shielded go in the ShieldedOutputsHeader) array.push(intToBytes(this.outputs.length, 1)); } @@ -237,6 +243,7 @@ class Transaction { for (const outputTx of this.outputs) { array.push(...outputTx.serialize()); } + // Shielded outputs are serialized in the ShieldedOutputsHeader, not here. } /** @@ -360,6 +367,10 @@ class Transaction { } sumOutputs += output.value; } + // Shielded outputs also contribute to the weight calculation + for (const shieldedOut of this.shieldedOutputs) { + sumOutputs += shieldedOut.value; + } return sumOutputs; } @@ -410,6 +421,12 @@ class Transaction { `Transaction has ${this.outputs.length} outputs and can have at most ${MAX_OUTPUTS}.` ); } + + if (this.shieldedOutputs.length > MAX_SHIELDED_OUTPUTS) { + throw new MaximumNumberOutputsError( + `Transaction has ${this.shieldedOutputs.length} shielded outputs and can have at most ${MAX_SHIELDED_OUTPUTS}.` + ); + } } /** @@ -649,6 +666,15 @@ class Transaction { // or we will throw an error tx.getHeadersFromBytes(txBuffer, network); + // Hydrate shieldedOutputs from the ShieldedOutputsHeader (if present) + // so validate() and weight calculations use the correct count. + for (const header of tx.headers) { + if (header instanceof ShieldedOutputsHeader) { + tx.shieldedOutputs = header.shieldedOutputs; + break; + } + } + tx.updateHash(); return tx; diff --git a/src/new/sendTransaction.ts b/src/new/sendTransaction.ts index 338198978..c9e2b2c99 100644 --- a/src/new/sendTransaction.ts +++ b/src/new/sendTransaction.ts @@ -8,10 +8,18 @@ import EventEmitter from 'events'; import { shuffle } from 'lodash'; import txApi from '../api/txApi'; -import { NATIVE_TOKEN_UID, SELECT_OUTPUTS_TIMEOUT } from '../constants'; +import { + NATIVE_TOKEN_UID, + MAX_SHIELDED_OUTPUTS, + SELECT_OUTPUTS_TIMEOUT, + ZERO_TWEAK, + FEE_PER_AMOUNT_SHIELDED_OUTPUT, + FEE_PER_FULL_SHIELDED_OUTPUT, +} from '../constants'; import { ErrorMessages } from '../errorMessages'; import { SendTxError, WalletError } from '../errors'; import Address from '../models/address'; +import { getAddressType } from '../utils/address'; import CreateTokenTransaction from '../models/create_token_transaction'; import { Fee } from '../utils/fee'; import Transaction from '../models/transaction'; @@ -19,6 +27,7 @@ import { IDataInput, IDataOutput, IDataOutputWithToken, + IDataShieldedOutput, IDataTx, isDataOutputCreateToken, IStorage, @@ -26,6 +35,8 @@ import { OutputValueType, WalletType, } from '../types'; +import { ShieldedOutputMode } from '../shielded/types'; +import { createShieldedOutputs, InputGeneratorInfo } from '../shielded/creation'; import helpers from '../utils/helpers'; import { addCreatedTokenFromTx } from '../utils/storage'; import tokens from '../utils/tokens'; @@ -65,7 +76,20 @@ export interface ISendTokenOutput { timelock?: number | null; } -export type ISendOutput = ISendDataOutput | ISendTokenOutput; +export interface ISendShieldedOutput { + type: OutputType.P2PKH | OutputType.P2SH; + address: string; + value: OutputValueType; + token: string; + scanPubkey: string; // hex, 33 bytes compressed EC scan pubkey for ECDH + shieldedMode: ShieldedOutputMode; +} + +export function isShieldedOutput(output: ISendOutput): output is ISendShieldedOutput { + return 'shieldedMode' in output; +} + +export type ISendOutput = ISendDataOutput | ISendTokenOutput | ISendShieldedOutput; /** * This is transaction mining class responsible for: @@ -174,6 +198,11 @@ export default class SendTransaction extends EventEmitter implements ISendTransa // Map of token uid to the chooseInputs value of this token const tokenMap = new Map(); + // Collect shielded output definitions separately + const shieldedOutputDefs: ISendShieldedOutput[] = []; + // Track phantom outputs for removal after UTXO selection and shuffle + const phantomOutputs = new Set(); + for (const output of this.outputs) { if (isDataOutput(output)) { tokenMap.set(HTR_UID, true); @@ -187,8 +216,33 @@ export default class SendTransaction extends EventEmitter implements ISendTransa authorities: 0n, token: output.token, }); + } else if (isShieldedOutput(output)) { + // Shielded output: store definition and add a phantom transparent output + // so UTXO selection accounts for this value + shieldedOutputDefs.push(output); + tokenMap.set(output.token, true); + + // Phantom output for UTXO selection (removed after shuffle). + // output.address is already the spend-derived P2PKH (resolved in sendManyOutputsSendTransaction). + const phantom: IDataOutput = { + address: output.address, + value: output.value, + timelock: null, + authorities: 0n, + token: output.token, + type: getAddressType(output.address, network), + }; + phantomOutputs.add(phantom); + txData.outputs.push(phantom); } else { const addressObj = new Address(output.address, { network }); + let outputAddrType = addressObj.getType(); + if (outputAddrType === 'shielded') { + // Shielded address used for transparent output — use the spend-derived P2PKH + const spendAddress = addressObj.getSpendAddress(); + output.address = spendAddress.base58; + outputAddrType = 'p2pkh'; + } // We set chooseInputs true as default and may be overwritten by the inputs. // chooseInputs should be true if no inputs are given tokenMap.set(output.token, true); @@ -199,7 +253,7 @@ export default class SendTransaction extends EventEmitter implements ISendTransa timelock: output.timelock ? output.timelock : null, authorities: 0n, token: output.token, - type: addressObj.getType(), + type: outputAddrType, }); } } @@ -270,9 +324,21 @@ export default class SendTransaction extends EventEmitter implements ISendTransa throw err; } + // Calculate shielded output fee + let shieldedFee = 0n; + for (const def of shieldedOutputDefs) { + if (def.shieldedMode === ShieldedOutputMode.FULLY_SHIELDED) { + shieldedFee += FEE_PER_FULL_SHIELDED_OUTPUT; + } else { + shieldedFee += FEE_PER_AMOUNT_SHIELDED_OUTPUT; + } + } + + const totalFee = fee + shieldedFee; + const headers: Header[] = []; - if (fee > 0) { - headers.push(new FeeHeader([{ tokenIndex: 0, amount: fee }])); + if (totalFee > 0) { + headers.push(new FeeHeader([{ tokenIndex: 0, amount: totalFee }])); // if the token map doesn't have HTR, it means that the user didn't provide any HTR input or output, so we need to choose inputs for HTR to pay fees if (!tokenMapHasHTR) { shouldChooseHTRInputs = true; @@ -295,7 +361,7 @@ export default class SendTransaction extends EventEmitter implements ISendTransa outputs: partialOutputs, }, options, - fee + totalFee ); const shouldShuffleOutputs = @@ -307,6 +373,89 @@ export default class SendTransaction extends EventEmitter implements ISendTransa outputs = shuffle([...partialOutputs, ...partialHtrTxData.outputs]); } + // Remove phantom outputs (shielded) from the final outputs list. + // This relies on reference equality — Set.has() matches the same object instances + // created above. The spread operators and shuffle preserve object references. + if (phantomOutputs.size > 0) { + outputs = outputs.filter(out => !phantomOutputs.has(out)); + } + + // Create shielded outputs with cryptographic commitments and proofs + let shieldedOutputs: IDataShieldedOutput[] = []; + if (shieldedOutputDefs.length > 0) { + const cryptoProvider = this.storage.shieldedCryptoProvider; + if (!cryptoProvider) { + throw new SendTxError( + 'Shielded crypto provider is not set. Cannot create shielded outputs.' + ); + } + + // Validate shielded output count before expensive crypto work + if (shieldedOutputDefs.length === 1) { + throw new SendTxError( + 'At least 2 shielded outputs are required to prevent trivial commitment matching.' + ); + } + if (shieldedOutputDefs.length > MAX_SHIELDED_OUTPUTS) { + throw new SendTxError( + `Cannot create more than ${MAX_SHIELDED_OUTPUTS} shielded outputs per transaction ` + + `(requested ${shieldedOutputDefs.length}).` + ); + } + + // Collect per-input generator info for surjection proof domain and + // blinding factors for the homomorphic balance equation. + // The fullnode verifies surjection proofs against ALL input generators, so + // the wallet must create proofs with the same domain. + const allInputs = [...partialInputs, ...partialHtrTxData.inputs]; + const inputGenerators: InputGeneratorInfo[] = []; + const blindedInputsArr: Array<{ value: bigint; vbf: Buffer; gbf: Buffer }> = []; + + for (const inp of allInputs) { + const utxo = await this.storage.getUtxo({ + txId: inp.txId, + index: inp.index, + }); + + // Build generator info for surjection proof domain + if (inp.token) { + const genInfo: InputGeneratorInfo = { tokenUid: inp.token as string }; + // For FullShielded inputs, pass the asset blinding factor so the + // surjection proof domain uses the blinded generator (asset_commitment) + // matching what the fullnode verifies against. + if (utxo?.shielded && utxo.assetBlindingFactor) { + genInfo.assetBlindingFactor = Buffer.from(utxo.assetBlindingFactor, 'hex'); + } + inputGenerators.push(genInfo); + } + + // Extract blinding factors from shielded inputs for the homomorphic balance equation. + if (utxo?.shielded) { + if (!utxo.blindingFactor) { + throw new SendTxError( + `Shielded input ${inp.txId}:${inp.index} is missing blindingFactor — ` + + 'cannot satisfy the homomorphic balance equation.' + ); + } + blindedInputsArr.push({ + value: utxo.value, + vbf: Buffer.from(utxo.blindingFactor, 'hex'), + gbf: utxo.assetBlindingFactor + ? Buffer.from(utxo.assetBlindingFactor, 'hex') + : ZERO_TWEAK, + }); + } + } + + shieldedOutputs = await createShieldedOutputs( + shieldedOutputDefs, + cryptoProvider, + network, + inputGenerators, + blindedInputsArr + ); + } + // This new IDataTx should be complete with the requested funds this.fullTxData = { outputs, @@ -314,6 +463,7 @@ export default class SendTransaction extends EventEmitter implements ISendTransa // We already removed HTR from the tokenMap tokens: Array.from(tokenMap.keys()), headers, + ...(shieldedOutputs.length > 0 ? { shieldedOutputs } : {}), }; return this.fullTxData; diff --git a/src/new/types.ts b/src/new/types.ts index 02d4f8b89..bcacee79f 100644 --- a/src/new/types.ts +++ b/src/new/types.ts @@ -13,6 +13,7 @@ import { OutputValueType, TokenVersion, } from '../types'; +import { ShieldedOutputMode } from '../shielded/types'; import { NanoContractAction } from '../nano_contracts/types'; import WalletConnection from './connection'; import Address from '../models/address'; @@ -328,16 +329,18 @@ export interface UtxoDetails { /** * Proposed output for a transaction - * @property address Destination address for the output + * @property address Destination address for the output. Must be a shielded address when shielded mode is set. * @property value Value of the output * @property timelock Optional timelock for the output * @property token Token UID for the output + * @property shielded Optional shielded output mode (AMOUNT_SHIELDED or FULLY_SHIELDED) */ export interface ProposedOutput { address: string; value: OutputValueType; timelock?: number; token: string; + shielded?: ShieldedOutputMode; } /** diff --git a/src/new/wallet.ts b/src/new/wallet.ts index 55081201d..c222fa2af 100644 --- a/src/new/wallet.ts +++ b/src/new/wallet.ts @@ -30,6 +30,7 @@ import { ON_CHAIN_BLUEPRINTS_VERSION, P2PKH_ACCT_PATH, P2SH_ACCT_PATH, + SHIELDED_SPEND_ACCT_PATH, GAP_LIMIT, } from '../constants'; import tokenUtils from '../utils/tokens'; @@ -75,6 +76,7 @@ import { WalletState, WalletType, WalletAddressMode, + IAddressChainOptions, } from '../types'; import transactionUtils from '../utils/transaction'; import Queue from '../models/queue'; @@ -88,7 +90,12 @@ import { } from '../utils/storage'; import txApi from '../api/txApi'; import { MemoryStore, Storage } from '../storage'; -import { deriveAddressP2PKH, deriveAddressP2SH, getAddressFromPubkey } from '../utils/address'; +import { + deriveAddressP2PKH, + deriveAddressP2SH, + deriveShieldedAddressFromStorage, + getAddressFromPubkey, +} from '../utils/address'; import NanoContractTransactionBuilder from '../nano_contracts/builder'; import { prepareNanoSendTransaction, setNanoHeaderCallerFromWallet } from '../nano_contracts/utils'; import OnChainBlueprint, { Code, CodeKind } from '../nano_contracts/on_chain_blueprint'; @@ -101,7 +108,14 @@ import { IHistoryTxSchema } from '../schemas'; import GLL from '../sync/gll'; import { TransactionTemplate, WalletTxTemplateInterpreter } from '../template/transaction'; import Address from '../models/address'; -import { ConnectionState, FullNodeVersionData, IHathorWallet, Utxo } from '../wallet/types'; +import type { IShieldedCryptoProvider } from '../shielded/types'; +import { + ConnectionState, + FullNodeVersionData, + IHathorWallet, + OutputType, + Utxo, +} from '../wallet/types'; import Transaction from '../models/transaction'; import { CreateNFTOptions, @@ -638,7 +652,12 @@ class HathorWallet extends EventEmitter { } } const addressesToLoad = await scanPolicyStartAddresses(this.storage); - await this.syncHistory(addressesToLoad.nextIndex, addressesToLoad.count); + await this.syncHistory( + addressesToLoad.nextIndex, + addressesToLoad.count, + false, + this.pinCode ?? undefined + ); } else { if (this.beforeReloadCallback) { this.beforeReloadCallback(); @@ -804,15 +823,27 @@ class HathorWallet extends EventEmitter { * @memberof HathorWallet * @inner */ - async getAddressAtIndex(index: number): Promise { - let address = await this.storage.getAddressAtIndex(index); + async getAddressAtIndex(index: number, opts?: IAddressChainOptions): Promise { + let address = await this.storage.getAddressAtIndex(index, opts); if (address === null) { + if (opts?.legacy === false) { + // Shielded address not yet derived at this index — derive and save + const result = await deriveShieldedAddressFromStorage(index, this.storage); + if (!result) { + throw new Error('Shielded keys not available'); + } + await this.storage.saveAddress(result.shieldedAddress); + await this.storage.saveAddress(result.spendAddress); + return result.shieldedAddress.base58; + } + // Legacy address derivation if ((await this.storage.getWalletType()) === 'p2pkh') { address = await deriveAddressP2PKH(index, this.storage); } else { address = await deriveAddressP2SH(index, this.storage); } + await this.storage.saveAddress(address); } return address.base58; } @@ -826,7 +857,13 @@ class HathorWallet extends EventEmitter { * @memberof HathorWallet * @inner */ - async getAddressPathForIndex(index: number): Promise { + async getAddressPathForIndex(index: number, opts?: IAddressChainOptions): Promise { + if (opts?.legacy === false) { + // Shielded addresses are formed by scanPubkey (account 1') + spendPubkey (account 2'). + // We return the spend path since it's used for on-chain scripts and signing. + return `${SHIELDED_SPEND_ACCT_PATH}/0/${index}`; + } + const walletType = await this.storage.getWalletType(); if (walletType === WalletType.MULTISIG) { // P2SH @@ -846,14 +883,18 @@ class HathorWallet extends EventEmitter { * @memberof HathorWallet * @inner */ - async getCurrentAddress({ markAsUsed = false } = {}): Promise<{ + async getCurrentAddress( + // eslint-disable-next-line default-param-last + { markAsUsed = false } = {}, + opts?: IAddressChainOptions + ): Promise<{ address: string; index: number | null; addressPath: string; }> { - const address = await this.storage.getCurrentAddress(markAsUsed); + const address = await this.storage.getCurrentAddress(markAsUsed, opts); const index = await this.getAddressIndex(address); - const addressPath = await this.getAddressPathForIndex(index!); + const addressPath = await this.getAddressPathForIndex(index!, opts); return { address, index, addressPath }; } @@ -861,10 +902,12 @@ class HathorWallet extends EventEmitter { /** * Get the next address after the current available */ - async getNextAddress(): Promise<{ address: string; index: number | null; addressPath: string }> { + async getNextAddress( + opts?: IAddressChainOptions + ): Promise<{ address: string; index: number | null; addressPath: string }> { // First we mark the current address as used, then return the next - await this.getCurrentAddress({ markAsUsed: true }); - return this.getCurrentAddress(); + await this.getCurrentAddress({ markAsUsed: true }, opts); + return this.getCurrentAddress({}, opts); } /** @@ -875,8 +918,7 @@ class HathorWallet extends EventEmitter { handleWebsocketMsg(wsData: WalletWebSocketData): void { if (wsData.type === 'wallet:address_history') { if (this.state !== HathorWallet.READY) { - // Cannot process new transactions from ws when the wallet is not ready. - // So we will enqueue this message to be processed later + // Not ready yet — queue for processTxQueue during startup this.wsTxQueue.enqueue(wsData); } else { this.enqueueOnNewTx(wsData); @@ -1423,7 +1465,7 @@ class HathorWallet extends EventEmitter { }); } - await this.storage.processHistory(); + await this.storage.processHistory(this.pinCode ?? undefined); } /** @@ -1436,7 +1478,12 @@ class HathorWallet extends EventEmitter { // check address scanning policy and load more addresses if needed const loadMoreAddresses = await checkScanningPolicy(this.storage); if (loadMoreAddresses !== null) { - await this.syncHistory(loadMoreAddresses.nextIndex, loadMoreAddresses.count, processHistory); + await this.syncHistory( + loadMoreAddresses.nextIndex, + loadMoreAddresses.count, + processHistory, + this.pinCode ?? undefined + ); } } @@ -1488,6 +1535,11 @@ class HathorWallet extends EventEmitter { return; } const newTx = parseResult.data; + + // Normalize: extract shielded entries from outputs[] into shielded_outputs[], + // converting base64 fields to hex. + transactionUtils.normalizeShieldedOutputs(newTx); + // Later we will compare the storageTx and the received tx. // To avoid reference issues we clone the current storageTx. const storageTx = cloneDeep(await this.storage.getTx(newTx.tx_id)); @@ -1504,11 +1556,11 @@ class HathorWallet extends EventEmitter { if (isNewTx) { // Process this single transaction. // Handling new metadatas and deleting utxos that are not available anymore - await this.storage.processNewTx(newTx); + await this.storage.processNewTx(newTx, this.pinCode ?? undefined); } else if (storageTx.is_voided !== newTx.is_voided) { // This is a voided transaction update event. // voided transactions require a full history reprocess. - await this.storage.processHistory(); + await this.storage.processHistory(this.pinCode ?? undefined); } else if (!newTx.is_voided) { // Process other types of metadata updates. await processMetadataChanged(this.storage, newTx); @@ -1606,9 +1658,39 @@ class HathorWallet extends EventEmitter { throw new Error(ERROR_MESSAGE_PIN_REQUIRED); } const { inputs, changeAddress } = newOptions; + + // Map ProposedOutput[] to ISendOutput[], handling shielded outputs + const network = this.storage.config.getNetwork(); + const sendOutputs = outputs.map(o => { + if (o.shielded) { + const addressObj = new Address(o.address, { network }); + if (!addressObj.isShielded()) { + throw new Error('Shielded output requires a shielded address'); + } + // Extract scan pubkey from shielded address for ECDH + // Use the spend-derived P2PKH as the on-chain address + const spendAddress = addressObj.getSpendAddress(); + return { + type: OutputType.P2PKH as OutputType.P2PKH, + address: spendAddress.base58, + value: o.value, + token: o.token, + scanPubkey: addressObj.getScanPubkey().toString('hex'), + shieldedMode: o.shielded, + ...(o.timelock != null ? { timelock: o.timelock } : {}), + }; + } + return { + address: o.address, + value: o.value, + token: o.token, + ...(o.timelock ? { timelock: o.timelock } : {}), + }; + }); + return new SendTransaction({ wallet: this, - outputs, + outputs: sendOutputs, inputs, changeAddress, pin, @@ -1640,6 +1722,7 @@ class HathorWallet extends EventEmitter { const options = { pinCode: null, password: null, ...optionsParams }; const pinCode = options.pinCode || this.pinCode; const password = options.password || this.password; + if (!this.xpub && !pinCode) { throw new Error(ERROR_MESSAGE_PIN_REQUIRED); } @@ -1711,6 +1794,22 @@ class HathorWallet extends EventEmitter { if (info.network.indexOf(this.conn.getCurrentNetwork()) >= 0) { this.storage.setApiVersion(info); await this.storage.saveNativeToken(); + + // Auto-detect shielded crypto provider if not already set. + // Dynamic import because @hathor/ct-crypto-node is an optional dependency — + // a static import would crash the wallet module if the native package isn't installed. + if (!this.storage.shieldedCryptoProvider) { + try { + const { createDefaultShieldedCryptoProvider } = await import('../shielded/provider'); + this.storage.setShieldedCryptoProvider(createDefaultShieldedCryptoProvider()); + } catch (e) { + this.logger.info( + 'Shielded crypto not available. Install @hathor/ct-crypto-node for shielded output support.', + e + ); + } + } + this.conn.start(); } else { this.setState(HathorWallet.CLOSED); @@ -2952,10 +3051,16 @@ class HathorWallet extends EventEmitter { } fullTx.tx.outputs = fullTx.tx.outputs.map(output => - hydrateWithTokenUid(output, fullTx.tx.tokens) + transactionUtils.isShieldedOutputEntry(output) + ? output + : hydrateWithTokenUid(output, fullTx.tx.tokens) ); fullTx.tx.inputs = fullTx.tx.inputs.map(input => hydrateWithTokenUid(input, fullTx.tx.tokens)); + // Normalize shielded outputs before balance calculation so raw shielded + // entries are moved to shielded_outputs[] and don't break getTxBalance. + transactionUtils.normalizeShieldedOutputs(fullTx.tx as unknown as IHistoryTx); + // Get the balance of each token in the transaction that belongs to this wallet // sample output: { 'A': 100, 'B': 10 }, where 'A' and 'B' are token UIDs const tokenBalances = await this.getTxBalance(fullTx.tx as unknown as IHistoryTx); @@ -3297,6 +3402,16 @@ class HathorWallet extends EventEmitter { this.storage.setTxSignatureMethod(method); } + /** + * Set the shielded crypto provider for confidential transaction support. + * Use this for explicit injection (e.g., mobile apps using UniFFI bindings). + * + * @param provider The shielded crypto provider, or undefined to disable + */ + setShieldedCryptoProvider(provider?: IShieldedCryptoProvider): void { + this.storage.setShieldedCryptoProvider(provider); + } + /** * Set the history sync mode. * @@ -3316,7 +3431,8 @@ class HathorWallet extends EventEmitter { async syncHistory( startIndex: number, count: number, - shouldProcessHistory: boolean = false + shouldProcessHistory?: boolean, + pinCode?: string ): Promise { if (!(await getSupportedSyncMode(this.storage)).includes(this.historySyncMode)) { throw new Error('Trying to use an unsupported sync method for this wallet.'); @@ -3340,7 +3456,7 @@ class HathorWallet extends EventEmitter { // This will add the task to the GLL queue and return a promise that // resolves when the task finishes executing await GLL.add(async () => { - await syncMethod(startIndex, count, this.storage, this.conn, shouldProcessHistory); + await syncMethod(startIndex, count, this.storage, this.conn, shouldProcessHistory, pinCode); }); } @@ -3362,7 +3478,12 @@ class HathorWallet extends EventEmitter { await this.storage.saveAccessData(accessData); } const addressesToLoad = await scanPolicyStartAddresses(this.storage); - await this.syncHistory(addressesToLoad.nextIndex, addressesToLoad.count); + await this.syncHistory( + addressesToLoad.nextIndex, + addressesToLoad.count, + false, + this.pinCode ?? undefined + ); } /** diff --git a/src/schemas.ts b/src/schemas.ts index 50c4e46eb..dc451353a 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -74,17 +74,19 @@ export const IHistoryOutputDecodedSchema: ZodSchema = z export const IHistoryInputSchema: ZodSchema = z .object({ - value: bigIntCoercibleSchema, - token_data: z.number(), - script: z.string(), - decoded: IHistoryOutputDecodedSchema, - token: z.string(), + // These fields may be absent for shielded inputs (spent output value/token hidden) + value: bigIntCoercibleSchema.optional(), + token_data: z.number().optional(), + script: z.string().optional(), + decoded: IHistoryOutputDecodedSchema.optional(), + token: z.string().optional(), + // Always present: tx_id: txIdSchema, index: z.number(), }) - .passthrough(); + .passthrough() as ZodSchema; -export const IHistoryOutputSchema: ZodSchema = z +const TransparentOutputSchema = z .object({ value: bigIntCoercibleSchema, token_data: z.number(), @@ -96,6 +98,33 @@ export const IHistoryOutputSchema: ZodSchema = z }) .passthrough(); +const AmountShieldedOutputSchema = z + .object({ + type: z.string(), + commitment: z.string(), + range_proof: z.string(), + ephemeral_pubkey: z.string(), + token_data: z.number(), + }) + .passthrough(); + +const FullShieldedOutputSchema = z + .object({ + type: z.string(), + commitment: z.string(), + range_proof: z.string(), + ephemeral_pubkey: z.string(), + asset_commitment: z.string(), + surjection_proof: z.string(), + }) + .passthrough(); + +export const IHistoryOutputSchema: ZodSchema = z.union([ + TransparentOutputSchema, + AmountShieldedOutputSchema, + FullShieldedOutputSchema, +]) as ZodSchema; + export const IHistoryNanoContractBaseAction = z.object({ token_uid: z.string(), }); @@ -142,6 +171,20 @@ export const IHistoryNanoContractContextSchema = z }) .passthrough(); +const IHistoryShieldedOutputSchema = z + .object({ + mode: z.number(), + commitment: z.string(), + range_proof: z.string(), + script: z.string(), + token_data: z.number(), + ephemeral_pubkey: z.string(), + decoded: IHistoryOutputDecodedSchema, + asset_commitment: z.string().optional(), + surjection_proof: z.string().optional(), + }) + .passthrough(); + export const IHistoryTxSchema: ZodSchema = z .object({ tx_id: txIdSchema, @@ -158,6 +201,7 @@ export const IHistoryTxSchema: ZodSchema = z token_symbol: z.string().optional(), tokens: z.string().array().optional(), height: z.number().optional(), + shielded_outputs: IHistoryShieldedOutputSchema.array().optional(), processingStatus: z.nativeEnum(TxHistoryProcessingStatus).optional(), nc_id: z.string().optional(), nc_blueprint_id: z.string().optional(), diff --git a/src/shielded/creation.ts b/src/shielded/creation.ts new file mode 100644 index 000000000..f2e6a558f --- /dev/null +++ b/src/shielded/creation.ts @@ -0,0 +1,236 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { NATIVE_TOKEN_UID, NATIVE_TOKEN_UID_HEX, ZERO_TWEAK } from '../constants'; +import { IDataShieldedOutput } from '../types'; +import { getAddressType } from '../utils/address'; +import transactionUtils from '../utils/transaction'; +import Network from '../models/network'; +import { IShieldedCryptoProvider, ShieldedOutputMode } from './types'; + +interface ShieldedOutputDef { + address: string; + value: bigint; + token: string; + scanPubkey: string; + shieldedMode: ShieldedOutputMode; + timelock?: number; +} + +export interface ShieldedInputBlinding { + value: bigint; + vbf: Buffer; // value blinding factor + gbf: Buffer; // generator/asset blinding factor (ZERO_TWEAK for AmountShielded) +} + +/** + * Per-input generator info for surjection proof domain construction. + * For transparent/AmountShielded inputs, only tokenUid is needed (unblinded generator). + * For FullShielded inputs, the assetBlindingFactor is required to reconstruct + * the blinded generator (asset_commitment) that the fullnode uses for verification. + */ +export interface InputGeneratorInfo { + tokenUid: string; + assetBlindingFactor?: Buffer; // present only for FullShielded inputs +} + +/** + * Create shielded outputs with cryptographic commitments and proofs. + * + * The homomorphic balance equation requires blinding factors to sum to zero. + * For transparent-only inputs, all input blinding factors are zero. + * For shielded inputs, their blinding factors are passed via `blindedInputs`. + * N-1 outputs use random blinding factors; the last output's blinding factor + * is computed via computeBalancingBlindingFactor to satisfy the constraint. + */ +export async function createShieldedOutputs( + defs: ShieldedOutputDef[], + cryptoProvider: IShieldedCryptoProvider, + network: Network, + inputGenerators: InputGeneratorInfo[] = [], + blindedInputs: ShieldedInputBlinding[] = [] +): Promise { + if (defs.length === 0) return []; + + // Validate inputs upfront before expensive crypto work + const hasFullShielded = defs.some(d => d.shieldedMode === ShieldedOutputMode.FULLY_SHIELDED); + for (const [idx, def] of defs.entries()) { + const pubkeyBuf = Buffer.from(def.scanPubkey, 'hex'); + if (pubkeyBuf.length !== 33) { + throw new Error( + `Shielded output ${idx}: scanPubkey must be 33 bytes, got ${pubkeyBuf.length}` + ); + } + const tokenBuf = Buffer.from( + def.token === NATIVE_TOKEN_UID ? NATIVE_TOKEN_UID_HEX : def.token, + 'hex' + ); + if (tokenBuf.length !== 32) { + throw new Error(`Shielded output ${idx}: token UID must be 32 bytes, got ${tokenBuf.length}`); + } + } + if (hasFullShielded && inputGenerators.length === 0) { + throw new Error( + 'FullShielded outputs require at least one input token UID for surjection proof domain' + ); + } + + const results: IDataShieldedOutput[] = []; + const createdOutputs: Array<{ value: bigint; vbf: Buffer; gbf: Buffer }> = []; + + for (let i = 0; i < defs.length; i++) { + const def = defs[i]; + const fullyShielded = def.shieldedMode === ShieldedOutputMode.FULLY_SHIELDED; + const recipientPubkeyBuf = Buffer.from(def.scanPubkey, 'hex'); + const tokenUidBuf = Buffer.from( + def.token === NATIVE_TOKEN_UID ? NATIVE_TOKEN_UID_HEX : def.token, + 'hex' + ); + + let cryptoResult; + const isLast = i === defs.length - 1; + + try { + if (isLast && createdOutputs.length > 0) { + if (fullyShielded) { + // FullShielded last output: generate abf, compute balancing vbf, create with both + const lastAbf = await cryptoProvider.generateRandomBlindingFactor(); + const balancingBf = await cryptoProvider.computeBalancingBlindingFactor( + def.value, + lastAbf, + blindedInputs, + createdOutputs + ); + cryptoResult = await cryptoProvider.createShieldedOutputWithBothBlindings( + def.value, + recipientPubkeyBuf, + tokenUidBuf, + balancingBf, + lastAbf + ); + createdOutputs.push({ + value: def.value, + vbf: cryptoResult.blindingFactor, + gbf: cryptoResult.assetBlindingFactor ?? ZERO_TWEAK, + }); + } else { + // AmountShielded last output: compute balancing vbf, create with it + const balancingBf = await cryptoProvider.computeBalancingBlindingFactor( + def.value, + ZERO_TWEAK, + blindedInputs, + createdOutputs + ); + cryptoResult = await cryptoProvider.createAmountShieldedOutput( + def.value, + recipientPubkeyBuf, + tokenUidBuf, + balancingBf + ); + createdOutputs.push({ + value: def.value, + vbf: cryptoResult.blindingFactor, + gbf: ZERO_TWEAK, + }); + } + } else if (fullyShielded) { + // FullShielded non-last output: generate both blinding factors + const vbf = await cryptoProvider.generateRandomBlindingFactor(); + const abf = await cryptoProvider.generateRandomBlindingFactor(); + cryptoResult = await cryptoProvider.createShieldedOutputWithBothBlindings( + def.value, + recipientPubkeyBuf, + tokenUidBuf, + vbf, + abf + ); + createdOutputs.push({ + value: def.value, + vbf: cryptoResult.blindingFactor, + gbf: cryptoResult.assetBlindingFactor ?? ZERO_TWEAK, + }); + } else { + // AmountShielded non-last output: generate random vbf + const vbf = await cryptoProvider.generateRandomBlindingFactor(); + cryptoResult = await cryptoProvider.createAmountShieldedOutput( + def.value, + recipientPubkeyBuf, + tokenUidBuf, + vbf + ); + createdOutputs.push({ + value: def.value, + vbf: cryptoResult.blindingFactor, + gbf: ZERO_TWEAK, + }); + } + + // Create the output script for the on-chain address (spend-derived P2PKH). + // def.address is already the spend-derived P2PKH (resolved in sendManyOutputsSendTransaction). + const scriptBuf = transactionUtils.createOutputScript( + { + address: def.address, + value: def.value, + timelock: def.timelock ?? null, + authorities: 0n, + token: def.token, + type: getAddressType(def.address, network), + }, + network + ); + + // For FullShielded outputs, generate a surjection proof. + // The domain must include ALL input generators (matching the fullnode's verification). + // For transparent/AmountShielded inputs: use unblinded generator (ZERO_TWEAK). + // For FullShielded inputs: use the blinded generator (asset_commitment) reconstructed + // from the input's asset blinding factor — the fullnode verifies against this. + let surjectionProof: Buffer | undefined; + if (fullyShielded && cryptoResult.assetBlindingFactor) { + const codomainTag = await cryptoProvider.deriveTag(tokenUidBuf); + const domain: Array<{ generator: Buffer; tag: Buffer; blindingFactor: Buffer }> = []; + for (const inputInfo of inputGenerators) { + const inputTokenBuf = Buffer.from( + inputInfo.tokenUid === NATIVE_TOKEN_UID ? NATIVE_TOKEN_UID_HEX : inputInfo.tokenUid, + 'hex' + ); + const inputTag = await cryptoProvider.deriveTag(inputTokenBuf); + const abf = inputInfo.assetBlindingFactor ?? ZERO_TWEAK; + const inputGen = await cryptoProvider.createAssetCommitment(inputTag, abf); + domain.push({ generator: inputGen, tag: inputTag, blindingFactor: abf }); + } + surjectionProof = await cryptoProvider.createSurjectionProof( + codomainTag, + cryptoResult.assetBlindingFactor, + domain + ); + } + + results.push({ + address: def.address, + value: def.value, + token: def.token, + scanPubkey: def.scanPubkey, + mode: def.shieldedMode, + ephemeralPubkey: cryptoResult.ephemeralPubkey, + commitment: cryptoResult.commitment, + rangeProof: cryptoResult.rangeProof, + blindingFactor: cryptoResult.blindingFactor, + assetCommitment: cryptoResult.assetCommitment, + assetBlindingFactor: cryptoResult.assetBlindingFactor, + surjectionProof, + script: scriptBuf.toString('hex'), + }); + } catch (e) { + const mode = fullyShielded ? 'FullShielded' : 'AmountShielded'; + throw new Error( + `Failed to create shielded output ${i}/${defs.length} (mode=${mode}, token=${def.token}): ${e}` + ); + } + } + + return results; +} diff --git a/src/shielded/index.ts b/src/shielded/index.ts new file mode 100644 index 000000000..94d42fad9 --- /dev/null +++ b/src/shielded/index.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export { ShieldedOutputMode } from './types'; + +export type { + IShieldedOutput, + IShieldedOutputDecoded, + IDecryptedShieldedOutput, + ICreatedShieldedOutput, + IShieldedCryptoProvider, + IProcessedShieldedOutput, +} from './types'; + +export { createDefaultShieldedCryptoProvider } from './provider'; + +export { processShieldedOutputs } from './processing'; diff --git a/src/shielded/processing.ts b/src/shielded/processing.ts new file mode 100644 index 000000000..54a687b48 --- /dev/null +++ b/src/shielded/processing.ts @@ -0,0 +1,218 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { HDPrivateKey } from 'bitcore-lib'; +import { IStorage, IHistoryTx, ILogger } from '../types'; +import { NATIVE_TOKEN_UID, NATIVE_TOKEN_UID_HEX } from '../constants'; +import tokenUtils from '../utils/tokens'; +import { + IShieldedCryptoProvider, + IShieldedOutput, + IProcessedShieldedOutput, + ShieldedOutputMode, +} from './types'; + +/** + * Resolve the token UID for a shielded output. + * + * Uses the same token_data convention as transparent outputs (via getTokenIndexFromData). + * Returns the 32-byte hex UID needed by the crypto layer (NATIVE_TOKEN_UID_HEX for HTR). + * For FullShielded outputs, the token is unknown until decrypted. + */ +export function resolveTokenUid(shieldedOutput: IShieldedOutput, tx: IHistoryTx): string { + // FullShielded outputs have hidden tokens — token UID is recovered from rewind, not token_data + if (shieldedOutput.mode === ShieldedOutputMode.FULLY_SHIELDED) { + throw new Error( + 'resolveTokenUid must not be called for FullShielded outputs — ' + + 'token UID is recovered from the range proof during rewind, not from token_data' + ); + } + const tokenIndex = tokenUtils.getTokenIndexFromData(shieldedOutput.token_data); + if (tokenIndex === 0) { + return NATIVE_TOKEN_UID_HEX; + } + if (tx.tokens && tokenIndex <= tx.tokens.length) { + const uid = tx.tokens[tokenIndex - 1]; + return uid === NATIVE_TOKEN_UID ? NATIVE_TOKEN_UID_HEX : uid; + } + throw new Error( + `Invalid token_data index ${tokenIndex} for tx ${tx.tx_id} ` + + `(transaction has ${tx.tokens?.length ?? 0} custom tokens)` + ); +} + +/** + * Derive the scan private key for an address. + * + * The scan key uses a separate account (m/44'/280'/1'/0) from legacy P2PKH (account 0'). + * Returns the raw 32-byte private key for ECDH, or undefined if not derivable. + */ +async function deriveScanPrivkeyForAddress( + storage: IStorage, + addressIndex: number, + pinCode: string, + logger: ILogger +): Promise { + try { + const xprivStr = await storage.getScanXPrivKey(pinCode); + const hdPrivKey = new HDPrivateKey(xprivStr); + // deriveNonCompliantChild is required for private keys due to a historical + // bitcore-lib serialization bug. Public key derivation (in shieldedAddress.ts) + // uses standard deriveChild because it was always correct. Do not align these. + const childKey = hdPrivKey.deriveNonCompliantChild(addressIndex); + // The native crypto provider (ECDH) needs raw 32-byte private key bytes. + // Other wallet-lib code passes bitcore PrivateKey objects directly to bitcore + // signing functions, but here we cross into the native ct-crypto boundary. + // { size: 32 } ensures zero-padding for keys with leading zeros. + return childKey.privateKey.toBuffer({ size: 32 }); + } catch (e) { + logger.warn('Failed to derive scan private key for shielded output at index', addressIndex, e); + return undefined; + } +} + +/** + * Process shielded outputs from a transaction. + * Attempts to decrypt each shielded output using wallet keys. + * Returns decrypted outputs that belong to this wallet. + * + * @param storage - The wallet storage instance + * @param tx - The transaction containing shielded outputs + * @param cryptoProvider - The shielded crypto provider to use for decryption + * @param pinCode - PIN code to unlock wallet keys for decryption + * @returns Array of successfully decrypted outputs belonging to this wallet + */ +export async function processShieldedOutputs( + storage: IStorage, + tx: IHistoryTx, + cryptoProvider: IShieldedCryptoProvider, + pinCode: string +): Promise { + const shieldedOutputs = tx.shielded_outputs ?? []; + if (shieldedOutputs.length === 0) return []; + + const results: IProcessedShieldedOutput[] = []; + const transparentCount = tx.outputs.length; + + for (const [idx, shieldedOutput] of shieldedOutputs.entries()) { + const address = shieldedOutput.decoded?.address; + if (!address) continue; + + // Check if this address belongs to our wallet + const addressInfo = await storage.getAddressInfo(address); + if (!addressInfo) continue; + + // Derive the scan private key for this address (ECDH) + const privkey = await deriveScanPrivkeyForAddress( + storage, + addressInfo.bip32AddressIndex, + pinCode, + storage.logger + ); + if (!privkey) continue; + + const ephPk = Buffer.from(shieldedOutput.ephemeral_pubkey, 'hex'); + const commitment = Buffer.from(shieldedOutput.commitment, 'hex'); + const rangeProof = Buffer.from(shieldedOutput.range_proof, 'hex'); + const isFullShielded = shieldedOutput.mode === ShieldedOutputMode.FULLY_SHIELDED; + + try { + let recoveredValue: bigint; + let recoveredBf: Buffer; + let recoveredTokenUid: string; + let recoveredAbf: Buffer | undefined; + let outputType: ShieldedOutputMode; + + if (isFullShielded) { + // FullShielded: rewind recovers token UID and asset blinding factor + // asset_commitment is guaranteed for FullShielded outputs (protocol invariant) + const assetCommitment = Buffer.from(shieldedOutput.asset_commitment!, 'hex'); + const result = await cryptoProvider.rewindFullShieldedOutput( + privkey, + ephPk, + commitment, + rangeProof, + assetCommitment + ); + recoveredValue = result.value; + recoveredBf = result.blindingFactor; + recoveredAbf = result.assetBlindingFactor; + recoveredTokenUid = result.tokenUid.toString('hex'); + outputType = ShieldedOutputMode.FULLY_SHIELDED; + + // Cross-check token UID (Section 4.3 of the client guide): + // https://github.com/HathorNetwork/hathor-core/blob/feat/ct-amount-token-privacy/hathor-ct-crypto/SHIELDED-OUTPUTS-CLIENT-GUIDE.md + // Verify that the recovered token_uid is consistent with the on-chain asset_commitment. + const expectedTag = await cryptoProvider.deriveTag(result.tokenUid); + const expectedAc = await cryptoProvider.createAssetCommitment( + expectedTag, + result.assetBlindingFactor + ); + if (!assetCommitment.equals(expectedAc)) { + storage.logger.warn( + `FullShielded token UID cross-check failed for tx ${tx.tx_id} ` + + `output ${transparentCount + idx} — asset commitment mismatch` + ); + continue; + } + } else { + // AmountShielded: token UID is known from the visible token_data field + const tokenUid = resolveTokenUid(shieldedOutput, tx); + const result = await cryptoProvider.rewindAmountShieldedOutput( + privkey, + ephPk, + commitment, + rangeProof, + Buffer.from(tokenUid, 'hex') + ); + recoveredValue = result.value; + recoveredBf = result.blindingFactor; + recoveredTokenUid = tokenUid; + outputType = ShieldedOutputMode.AMOUNT_SHIELDED; + } + + // Validate recovered value — a corrupted rewind could return garbage + if (recoveredValue <= 0n) { + storage.logger.warn( + `Shielded output rewind returned non-positive value ${recoveredValue} ` + + `for tx ${tx.tx_id} output ${transparentCount + idx} — skipping` + ); + continue; + } + + results.push({ + txId: tx.tx_id, + index: transparentCount + idx, + decrypted: { + value: recoveredValue, + blindingFactor: recoveredBf, + tokenUid: recoveredTokenUid, + assetBlindingFactor: recoveredAbf, + outputType, + }, + address, + tokenUid: recoveredTokenUid, + }); + } catch (e) { + // Rewind failed — output doesn't belong to us or data is corrupt + storage.logger.debug( + 'Shielded output rewind failed for tx', + tx.tx_id, + 'index', + transparentCount + idx, + e + ); + continue; + } finally { + // Zero the private key buffer to reduce the window for memory-scraping attacks. + // Not guaranteed by JS GC but a defense-in-depth best practice. + privkey.fill(0); + } + } + + return results; +} diff --git a/src/shielded/provider.ts b/src/shielded/provider.ts new file mode 100644 index 000000000..b6e298140 --- /dev/null +++ b/src/shielded/provider.ts @@ -0,0 +1,157 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + IShieldedCryptoProvider, + ICreatedShieldedOutput, + IRewoundAmountShieldedOutput, + IRewoundFullShieldedOutput, +} from './types'; + +/** + * Creates the default shielded crypto provider using @hathor/ct-crypto-node. + * All function names follow the SHIELDED-OUTPUTS-CLIENT-GUIDE.md specification. + * Throws if the native addon is not installed. + */ +export function createDefaultShieldedCryptoProvider(): IShieldedCryptoProvider { + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require, import/no-unresolved + const ct = require('@hathor/ct-crypto-node'); + + return { + generateRandomBlindingFactor(): Buffer { + return ct.generateRandomBlindingFactor(); + }, + + createAmountShieldedOutput( + value: bigint, + recipientPubkey: Buffer, + tokenUid: Buffer, + valueBlindingFactor: Buffer + ): ICreatedShieldedOutput { + const result = ct.createAmountShieldedOutput( + value, + recipientPubkey, + tokenUid, + valueBlindingFactor + ); + return { + ephemeralPubkey: result.ephemeralPubkey, + commitment: result.commitment, + rangeProof: result.rangeProof, + blindingFactor: result.blindingFactor, + }; + }, + + createShieldedOutputWithBothBlindings( + value: bigint, + recipientPubkey: Buffer, + tokenUid: Buffer, + vbf: Buffer, + abf: Buffer + ): ICreatedShieldedOutput { + const result = ct.createShieldedOutputWithBothBlindings( + value, + recipientPubkey, + tokenUid, + vbf, + abf + ); + return { + ephemeralPubkey: result.ephemeralPubkey, + commitment: result.commitment, + rangeProof: result.rangeProof, + blindingFactor: result.blindingFactor, + assetCommitment: result.assetCommitment ?? undefined, + assetBlindingFactor: result.assetBlindingFactor ?? undefined, + }; + }, + + rewindAmountShieldedOutput( + privateKey: Buffer, + ephemeralPubkey: Buffer, + commitment: Buffer, + rangeProof: Buffer, + tokenUid: Buffer + ): IRewoundAmountShieldedOutput { + const result = ct.rewindAmountShieldedOutput( + privateKey, + ephemeralPubkey, + commitment, + rangeProof, + tokenUid + ); + return { + value: result.value, + blindingFactor: result.blindingFactor, + }; + }, + + rewindFullShieldedOutput( + privateKey: Buffer, + ephemeralPubkey: Buffer, + commitment: Buffer, + rangeProof: Buffer, + assetCommitment: Buffer + ): IRewoundFullShieldedOutput { + const result = ct.rewindFullShieldedOutput( + privateKey, + ephemeralPubkey, + commitment, + rangeProof, + assetCommitment + ); + return { + value: result.value, + blindingFactor: result.blindingFactor, + tokenUid: result.tokenUid, + assetBlindingFactor: result.assetBlindingFactor, + }; + }, + + computeBalancingBlindingFactor( + value: bigint, + generatorBlindingFactor: Buffer, + inputs: Array<{ value: bigint; vbf: Buffer; gbf: Buffer }>, + otherOutputs: Array<{ value: bigint; vbf: Buffer; gbf: Buffer }> + ): Buffer { + return ct.computeBalancingBlindingFactor( + value, + generatorBlindingFactor, + inputs.map(i => ({ + value: i.value, + valueBlindingFactor: i.vbf, + generatorBlindingFactor: i.gbf, + })), + otherOutputs.map(o => ({ + value: o.value, + valueBlindingFactor: o.vbf, + generatorBlindingFactor: o.gbf, + })) + ); + }, + + deriveTag(tokenUid: Buffer): Buffer { + return ct.deriveTag(tokenUid); + }, + + createAssetCommitment(tag: Buffer, blindingFactor: Buffer): Buffer { + return ct.createAssetCommitment(tag, blindingFactor); + }, + + createSurjectionProof( + codomainTag: Buffer, + codomainBlindingFactor: Buffer, + domain: Array<{ generator: Buffer; tag: Buffer; blindingFactor: Buffer }> + ): Buffer { + return ct.createSurjectionProof(codomainTag, codomainBlindingFactor, domain); + }, + + deriveEcdhSharedSecret(privkey: Buffer, pubkey: Buffer): Buffer { + return ct.deriveEcdhSharedSecret(privkey, pubkey); + }, + }; +} diff --git a/src/shielded/types.ts b/src/shielded/types.ts new file mode 100644 index 000000000..b2db8d8e8 --- /dev/null +++ b/src/shielded/types.ts @@ -0,0 +1,195 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Shielded output modes matching the Hathor protocol. + */ +export enum ShieldedOutputMode { + AMOUNT_SHIELDED = 1, + FULLY_SHIELDED = 2, +} + +/** + * A shielded output as received from the full node API. + * This is the on-chain data before decryption. + */ +export interface IShieldedOutput { + mode: ShieldedOutputMode; + commitment: string; // hex, 33 bytes + range_proof: string; // hex, variable (~675 bytes) + script: string; // hex, output script (P2PKH/P2SH) + token_data: number; // token index (AmountShielded only) + ephemeral_pubkey: string; // hex, 33 bytes + decoded: IShieldedOutputDecoded; + // FullShielded only: + asset_commitment?: string; // hex, 33 bytes + surjection_proof?: string; // hex, variable +} + +export interface IShieldedOutputDecoded { + type?: string; + address?: string; + timelock?: number | null; +} + +/** + * The result of successfully decrypting a shielded output. + */ +export interface IDecryptedShieldedOutput { + value: bigint; + blindingFactor: Buffer; + tokenUid: string; // hex, 32 bytes + assetBlindingFactor?: Buffer; + outputType: ShieldedOutputMode; +} + +/** + * Result of creating a shielded output via the crypto provider. + */ +export interface ICreatedShieldedOutput { + ephemeralPubkey: Buffer; + commitment: Buffer; + rangeProof: Buffer; + blindingFactor: Buffer; + assetCommitment?: Buffer; + assetBlindingFactor?: Buffer; + surjectionProof?: Buffer; +} + +/** + * Swappable crypto provider interface. + * Function names follow the SHIELDED-OUTPUTS-CLIENT-GUIDE.md specification. + * + * Implementations: + * - Node.js: @hathor/ct-crypto-node (napi-rs, default) + * - iOS: Swift wrapper via UniFFI + * - Android: Kotlin wrapper via UniFFI + */ +export interface IShieldedCryptoProvider { + /** + * Generate a random 32-byte blinding factor (valid secp256k1 scalar). + * MUST use the Rust crypto RNG — never use JS crypto.randomBytes. + */ + generateRandomBlindingFactor(): Buffer | Promise; + + /** + * Create an AmountShielded output (amount hidden, token visible). + * Caller provides the value blinding factor (from generateRandomBlindingFactor + * or computeBalancingBlindingFactor). + */ + createAmountShieldedOutput( + value: bigint, + recipientPubkey: Buffer, + tokenUid: Buffer, + valueBlindingFactor: Buffer + ): ICreatedShieldedOutput | Promise; + + /** + * Create a FullShielded output (amount AND token hidden). + * Caller provides both the value and asset blinding factors. + */ + createShieldedOutputWithBothBlindings( + value: bigint, + recipientPubkey: Buffer, + tokenUid: Buffer, + valueBlindingFactor: Buffer, + assetBlindingFactor: Buffer + ): ICreatedShieldedOutput | Promise; + + /** + * Rewind an AmountShielded output to recover value and blinding factor. + * The token UID is known from the visible token_data field. + */ + rewindAmountShieldedOutput( + privateKey: Buffer, + ephemeralPubkey: Buffer, + commitment: Buffer, + rangeProof: Buffer, + tokenUid: Buffer + ): IRewoundAmountShieldedOutput | Promise; + + /** + * Rewind a FullShielded output to recover value, blinding factor, token UID, + * and asset blinding factor. Does NOT take tokenUid — it's recovered from the proof message. + */ + rewindFullShieldedOutput( + privateKey: Buffer, + ephemeralPubkey: Buffer, + commitment: Buffer, + rangeProof: Buffer, + assetCommitment: Buffer + ): IRewoundFullShieldedOutput | Promise; + + /** + * Compute the balancing blinding factor for the last shielded output. + * Uses secp256k1-zkp's compute_adaptive_blinding_factor. + * + * @param value - The value of the last output + * @param generatorBlindingFactor - Generator bf for the last output (zero for AmountShielded) + * @param inputs - Array of {value, vbf, gbf} for all inputs + * @param otherOutputs - Array of {value, vbf, gbf} for all other outputs (not the last) + */ + computeBalancingBlindingFactor( + value: bigint, + generatorBlindingFactor: Buffer, + inputs: Array<{ value: bigint; vbf: Buffer; gbf: Buffer }>, + otherOutputs: Array<{ value: bigint; vbf: Buffer; gbf: Buffer }> + ): Buffer | Promise; + + /** + * Derive a raw Tag from a token UID (for surjection proofs and cross-checks). + */ + deriveTag(tokenUid: Buffer): Buffer | Promise; + + /** + * Derive a blinded asset generator from a raw tag and blinding factor. + */ + createAssetCommitment(tag: Buffer, blindingFactor: Buffer): Buffer | Promise; + + /** + * Create a surjection proof proving the output asset derives from one of the input assets. + */ + createSurjectionProof( + codomainTag: Buffer, + codomainBlindingFactor: Buffer, + domain: Array<{ generator: Buffer; tag: Buffer; blindingFactor: Buffer }> + ): Buffer | Promise; + + /** + * ECDH shared secret derivation (for scanning optimization). + */ + deriveEcdhSharedSecret(privkey: Buffer, pubkey: Buffer): Buffer | Promise; +} + +/** + * Result of rewinding an AmountShielded output. + */ +export interface IRewoundAmountShieldedOutput { + value: bigint; + blindingFactor: Buffer; +} + +/** + * Result of rewinding a FullShielded output. + */ +export interface IRewoundFullShieldedOutput { + value: bigint; + blindingFactor: Buffer; + tokenUid: Buffer; // 32 bytes, recovered from proof message + assetBlindingFactor: Buffer; // 32 bytes, recovered from proof message +} + +/** + * Result of processing shielded outputs for a single transaction. + */ +export interface IProcessedShieldedOutput { + txId: string; + index: number; + decrypted: IDecryptedShieldedOutput; + address: string; + tokenUid: string; +} diff --git a/src/storage/memory_store.ts b/src/storage/memory_store.ts index f701f85aa..a7e3c7d9a 100644 --- a/src/storage/memory_store.ts +++ b/src/storage/memory_store.ts @@ -25,6 +25,8 @@ import { SCANNING_POLICY, INcData, TokenVersion, + IAddressChainOptions, + IUtxoId, } from '../types'; import { GAP_LIMIT, NATIVE_TOKEN_UID } from '../constants'; import transactionUtils from '../utils/transaction'; @@ -33,6 +35,9 @@ const DEFAULT_ADDRESSES_WALLET_DATA = { lastLoadedAddressIndex: 0, lastUsedAddressIndex: -1, currentAddressIndex: -1, + shieldedLastLoadedAddressIndex: 0, + shieldedLastUsedAddressIndex: -1, + shieldedCurrentAddressIndex: -1, }; const DEFAULT_SCAN_POLICY_DATA: AddressScanPolicyData = { @@ -97,9 +102,16 @@ export class MemoryStore implements IStore { /** * Map * where index is the address index and base58 is the address in base58 + * Tracks legacy (P2PKH/P2SH) addresses. */ addressIndexes: Map; + /** + * Map + * Tracks shielded (user-facing) addresses by BIP32 index. + */ + shieldedAddressIndexes: Map; + /** * Map * where base58 is the address in base58 @@ -176,6 +188,7 @@ export class MemoryStore implements IStore { constructor() { this.addresses = new Map(); this.addressIndexes = new Map(); + this.shieldedAddressIndexes = new Map(); this.addressesMetadata = new Map(); this.seqnumMetadata = new Map(); this.tokens = new Map(); @@ -214,6 +227,12 @@ export class MemoryStore implements IStore { */ async *addressIter(): AsyncGenerator { for (const addrInfo of this.addresses.values()) { + // Only yield legacy addresses (p2pkh, p2sh, or untyped). + // Shielded and shielded-spend addresses are internal and should not + // be exposed through the general address iteration. + if (addrInfo.addressType === 'shielded' || addrInfo.addressType === 'shielded-spend') { + continue; + } yield addrInfo; } } @@ -257,7 +276,13 @@ export class MemoryStore implements IStore { * @returns {Promise} A promise with the number of addresses */ async addressCount(): Promise { - return this.addresses.size; + let count = 0; + for (const addrInfo of this.addresses.values()) { + if (addrInfo.addressType !== 'shielded' && addrInfo.addressType !== 'shielded-spend') { + count++; + } + } + return count; } /** @@ -266,8 +291,13 @@ export class MemoryStore implements IStore { * @async * @returns {Promise} The address info or null if not in storage */ - async getAddressAtIndex(index: number): Promise { - const addr = this.addressIndexes.get(index); + async getAddressAtIndex( + index: number, + opts?: IAddressChainOptions + ): Promise { + const isLegacy = opts?.legacy !== false; + const indexMap = isLegacy ? this.addressIndexes : this.shieldedAddressIndexes; + const addr = indexMap.get(index); if (addr === undefined) { // We do not have this index loaded on storage, it should be generated instead return null; @@ -291,14 +321,34 @@ export class MemoryStore implements IStore { // Saving address info this.addresses.set(info.base58, info); - this.addressIndexes.set(info.bip32AddressIndex, info.base58); - if (this.walletData.currentAddressIndex === -1) { - await this.setCurrentAddressIndex(info.bip32AddressIndex); + // Route to the correct index map based on address type. + // Each BIP32 index can have up to 3 addresses: legacy, shielded, and shielded-spend. + if (info.addressType === 'shielded') { + this.shieldedAddressIndexes.set(info.bip32AddressIndex, info.base58); + } else if (info.addressType !== 'shielded-spend') { + // Legacy P2PKH/P2SH (addressType undefined, 'p2pkh', or 'p2sh') + this.addressIndexes.set(info.bip32AddressIndex, info.base58); } - if (info.bip32AddressIndex > this.walletData.lastLoadedAddressIndex) { - this.walletData.lastLoadedAddressIndex = info.bip32AddressIndex; + // Update per-chain tracking: currentAddressIndex and lastLoadedAddressIndex. + // Only the 'shielded' type (not 'shielded-spend') advances the cursor, since + // getCurrentAddress looks up shieldedAddressIndexes which only contains 'shielded' entries. + if (info.addressType === 'shielded') { + if (this.walletData.shieldedCurrentAddressIndex === -1) { + await this.setCurrentAddressIndex(info.bip32AddressIndex, { legacy: false }); + } + if (info.bip32AddressIndex > this.walletData.shieldedLastLoadedAddressIndex) { + this.walletData.shieldedLastLoadedAddressIndex = info.bip32AddressIndex; + } + } else if (info.addressType !== 'shielded-spend') { + // Legacy P2PKH/P2SH only — shielded-spend does not track its own cursor + if (this.walletData.currentAddressIndex === -1) { + await this.setCurrentAddressIndex(info.bip32AddressIndex); + } + if (info.bip32AddressIndex > this.walletData.lastLoadedAddressIndex) { + this.walletData.lastLoadedAddressIndex = info.bip32AddressIndex; + } } } @@ -319,30 +369,46 @@ export class MemoryStore implements IStore { * @async * @returns {Promise} The address in base58 format */ - async getCurrentAddress(markAsUsed?: boolean): Promise { - const addressInfo = await this.getAddressAtIndex(this.walletData.currentAddressIndex); - if (!addressInfo) { - throw new Error('Current address is not loaded'); + async getCurrentAddress(markAsUsed?: boolean, opts?: IAddressChainOptions): Promise { + const isLegacy = opts?.legacy !== false; + + const currentIndex = isLegacy + ? this.walletData.currentAddressIndex + : this.walletData.shieldedCurrentAddressIndex; + const lastLoaded = isLegacy + ? this.walletData.lastLoadedAddressIndex + : this.walletData.shieldedLastLoadedAddressIndex; + const indexMap = isLegacy ? this.addressIndexes : this.shieldedAddressIndexes; + + const base58 = indexMap.get(currentIndex); + if (!base58) { + const chain = isLegacy ? 'legacy' : 'shielded'; + throw new Error( + `Current ${chain} address is not loaded (index=${currentIndex}). ` + + `Derive at least one ${chain} address before calling getCurrentAddress.` + ); } if (markAsUsed) { - // Will move the address index only if we have not reached the gap limit - await this.setCurrentAddressIndex( - Math.min(this.walletData.lastLoadedAddressIndex, this.walletData.currentAddressIndex + 1) - ); + const nextIndex = Math.min(lastLoaded, currentIndex + 1); + await this.setCurrentAddressIndex(nextIndex, opts); } - return addressInfo.base58; + return base58; } /** * Set the value of the current address index. * @param {number} index The index to set */ - async setCurrentAddressIndex(index: number): Promise { + async setCurrentAddressIndex(index: number, opts?: IAddressChainOptions): Promise { if (this.walletData.scanPolicyData?.policy === SCANNING_POLICY.SINGLE_ADDRESS && index > 0) { return; } - this.walletData.currentAddressIndex = index; + if (opts?.legacy === false) { + this.walletData.shieldedCurrentAddressIndex = index; + } else { + this.walletData.currentAddressIndex = index; + } } /** @@ -398,7 +464,7 @@ export class MemoryStore implements IStore { let found = false; for (const input of tx.inputs) { if ( - input.decoded.address && + input.decoded?.address && this.addresses.has(input.decoded.address) && input.token === tokenUid ) { @@ -456,21 +522,38 @@ export class MemoryStore implements IStore { this.history.set(tx.tx_id, tx); - let maxIndex = this.walletData.lastUsedAddressIndex; + let legacyMaxIndex = this.walletData.lastUsedAddressIndex; + let shieldedMaxIndex = this.walletData.shieldedLastUsedAddressIndex; for (const el of [...tx.inputs, ...tx.outputs]) { - if (el.decoded.address && (await this.addressExists(el.decoded.address))) { - const index = this.addresses.get(el.decoded.address)!.bip32AddressIndex; - if (index > maxIndex) { - maxIndex = index; + if (el.decoded?.address && (await this.addressExists(el.decoded.address))) { + const addrInfo = this.addresses.get(el.decoded.address)!; + const index = addrInfo.bip32AddressIndex; + if (addrInfo.addressType === 'shielded-spend') { + if (index > shieldedMaxIndex) shieldedMaxIndex = index; + } else if ( + !addrInfo.addressType || + addrInfo.addressType === 'p2pkh' || + addrInfo.addressType === 'p2sh' + ) { + if (index > legacyMaxIndex) legacyMaxIndex = index; } } } - if (this.walletData.currentAddressIndex < maxIndex) { + // Update legacy chain tracking + if (this.walletData.currentAddressIndex < legacyMaxIndex) { + await this.setCurrentAddressIndex( + Math.min(legacyMaxIndex + 1, this.walletData.lastLoadedAddressIndex) + ); + } + this.walletData.lastUsedAddressIndex = legacyMaxIndex; + // Update shielded chain tracking + if (this.walletData.shieldedCurrentAddressIndex < shieldedMaxIndex) { await this.setCurrentAddressIndex( - Math.min(maxIndex + 1, this.walletData.lastLoadedAddressIndex) + Math.min(shieldedMaxIndex + 1, this.walletData.shieldedLastLoadedAddressIndex), + { legacy: false } ); } - this.walletData.lastUsedAddressIndex = maxIndex; + this.walletData.shieldedLastUsedAddressIndex = shieldedMaxIndex; } /** @@ -682,6 +765,8 @@ export class MemoryStore implements IStore { (options.amount_bigger_than && utxo.value <= options.amount_bigger_than) || (options.amount_smaller_than && utxo.value >= options.amount_smaller_than) || (options.filter_address && utxo.address !== options.filter_address) || + (options.shielded === true && !utxo.shielded) || + (options.shielded === false && !!utxo.shielded) || !authority_match || utxo.token !== token ) { @@ -723,6 +808,10 @@ export class MemoryStore implements IStore { this.utxos.set(`${utxo.txId}:${utxo.index}`, utxo); } + async getUtxo(utxoId: IUtxoId): Promise { + return this.utxos.get(`${utxoId.txId}:${utxoId.index}`) ?? null; + } + /** * Save a locked utxo. * Used when a new utxo is received but it is either time locked or height locked. @@ -781,7 +870,10 @@ export class MemoryStore implements IStore { * @async * @returns {Promise} */ - async getLastLoadedAddressIndex(): Promise { + async getLastLoadedAddressIndex(opts?: IAddressChainOptions): Promise { + if (opts?.legacy === false) { + return this.walletData.shieldedLastLoadedAddressIndex; + } return this.walletData.lastLoadedAddressIndex; } @@ -790,7 +882,10 @@ export class MemoryStore implements IStore { * @async * @returns {Promise} */ - async getLastUsedAddressIndex(): Promise { + async getLastUsedAddressIndex(opts?: IAddressChainOptions): Promise { + if (opts?.legacy === false) { + return this.walletData.shieldedLastUsedAddressIndex; + } return this.walletData.lastUsedAddressIndex; } @@ -807,8 +902,12 @@ export class MemoryStore implements IStore { * Set the last bip32 address index used on storage. * @param {number} index The index to set as last used address. */ - async setLastUsedAddressIndex(index: number): Promise { - this.walletData.lastUsedAddressIndex = index; + async setLastUsedAddressIndex(index: number, opts?: IAddressChainOptions): Promise { + if (opts?.legacy === false) { + this.walletData.shieldedLastUsedAddressIndex = index; + } else { + this.walletData.lastUsedAddressIndex = index; + } } /** @@ -941,6 +1040,7 @@ export class MemoryStore implements IStore { if (cleanAddresses) { this.addresses = new Map(); this.addressIndexes = new Map(); + this.shieldedAddressIndexes = new Map(); this.addressesMetadata = new Map(); this.seqnumMetadata = new Map(); this.walletData = { ...this.walletData, ...DEFAULT_ADDRESSES_WALLET_DATA }; diff --git a/src/storage/storage.ts b/src/storage/storage.ts index 4bbfc4bb4..4623bf36a 100644 --- a/src/storage/storage.ts +++ b/src/storage/storage.ts @@ -40,7 +40,9 @@ import { getDefaultLogger, AuthorityType, TokenVersion, + IAddressChainOptions, } from '../types'; +import type { IShieldedCryptoProvider } from '../shielded/types'; import transactionUtils from '../utils/transaction'; import { processHistory as processHistoryUtil, @@ -79,6 +81,8 @@ export class Storage implements IStorage { txSignFunc: EcdsaTxSign | null; + shieldedCryptoProvider?: IShieldedCryptoProvider; + /** * This promise is used to chain the calls to process unlocked utxos. * This way we can avoid concurrent calls. @@ -98,6 +102,7 @@ export class Storage implements IStorage { this.version = null; this.utxoUnlockWait = Promise.resolve(); this.txSignFunc = null; + this.shieldedCryptoProvider = undefined; this.logger = getDefaultLogger(); } @@ -167,6 +172,14 @@ export class Storage implements IStorage { this.txSignFunc = txSign; } + /** + * Set the shielded crypto provider for confidential transaction support. + * @param provider The crypto provider, or undefined to clear it + */ + setShieldedCryptoProvider(provider?: IShieldedCryptoProvider): void { + this.shieldedCryptoProvider = provider; + } + /** * Sign the transaction * @param {Transaction} tx The transaction to sign @@ -232,8 +245,11 @@ export class Storage implements IStorage { * @async * @returns {Promise} The address info or null if not found */ - async getAddressAtIndex(index: number): Promise { - return this.store.getAddressAtIndex(index); + async getAddressAtIndex( + index: number, + opts?: IAddressChainOptions + ): Promise { + return this.store.getAddressAtIndex(index, opts); } /** @@ -280,8 +296,8 @@ export class Storage implements IStorage { * @param {boolean|undefined} markAsUsed If we should set the next address as current * @returns {Promise} The address in base58 encoding */ - async getCurrentAddress(markAsUsed?: boolean): Promise { - return this.store.getCurrentAddress(markAsUsed); + async getCurrentAddress(markAsUsed?: boolean, opts?: IAddressChainOptions): Promise { + return this.store.getCurrentAddress(markAsUsed, opts); } /** @@ -361,6 +377,9 @@ export class Storage implements IStorage { * @returns {Promise} */ async addTx(tx: IHistoryTx): Promise { + // Normalize: extract shielded entries from outputs[] into shielded_outputs[], + // converting base64 fields to hex. + transactionUtils.normalizeShieldedOutputs(tx); await this.store.saveTx(tx); } @@ -368,20 +387,23 @@ export class Storage implements IStorage { * Process the transaction history to calculate the metadata. * @returns {Promise} */ - async processHistory(): Promise { + async processHistory(pinCode?: string): Promise { await this.store.preProcessHistory(); - await processHistoryUtil(this, { rewardLock: this.version?.reward_spend_min_blocks }); + await processHistoryUtil(this, { rewardLock: this.version?.reward_spend_min_blocks, pinCode }); } /** * Process the transaction history to calculate the metadata. * @returns {Promise} */ - async processNewTx(tx: IHistoryTx): Promise { + async processNewTx(tx: IHistoryTx, pinCode?: string): Promise { // Keep tx-timestamp index sorted await this.store.preProcessHistory(); // Process the single tx we received - await processSingleTxUtil(this, tx, { rewardLock: this.version?.reward_spend_min_blocks }); + await processSingleTxUtil(this, tx, { + rewardLock: this.version?.reward_spend_min_blocks, + pinCode, + }); } /** @@ -474,6 +496,13 @@ export class Storage implements IStorage { * @param {Omit} [options={}] Options to filter utxos and stop when the target is found. * @returns {AsyncGenerator} */ + /** + * Look up a specific UTXO by txId and index. + */ + async getUtxo(utxoId: IUtxoId): Promise { + return this.store.getUtxo(utxoId); + } + async *selectUtxos( options: Omit = {} ): AsyncGenerator { @@ -903,6 +932,50 @@ export class Storage implements IStorage { return decryptData(accessData.acctPathKey, pinCode); } + /** + * Get the scan chain xprivkey for shielded ECDH. + * Uses account 1' (m/44'/280'/1'/0), separate from legacy (account 0'). + */ + async getScanXPrivKey(pinCode: string): Promise { + const accessData = await this._getValidAccessData(); + if (!accessData.scanMainKey) { + throw new Error('Scan private key is not present on this wallet.'); + } + return decryptData(accessData.scanMainKey, pinCode); + } + + /** + * Get the spend chain xprivkey for shielded UTXO signing. + * Uses account 2' (m/44'/280'/2'/0). + */ + async getSpendXPrivKey(pinCode: string): Promise { + const accessData = await this._getValidAccessData(); + if (!accessData.spendMainKey) { + throw new Error('Spend private key is not present on this wallet.'); + } + return decryptData(accessData.spendMainKey, pinCode); + } + + /** + * Get the scan chain xpubkey for shielded address derivation. + * Uses account 1' (m/44'/280'/1'/0). + * Returns undefined if wallet was created before shielded feature. + */ + async getScanXPubKey(): Promise { + const accessData = await this._getValidAccessData(); + return accessData.scanXpubkey; + } + + /** + * Get the spend chain xpubkey for shielded address derivation. + * Uses account 2' (m/44'/280'/2'/0). + * Returns undefined if wallet was created before shielded feature. + */ + async getSpendXPubKey(): Promise { + const accessData = await this._getValidAccessData(); + return accessData.spendXpubkey; + } + /** * Decrypt and return the auth private key of the wallet. * diff --git a/src/sync/stream.ts b/src/sync/stream.ts index 957a60340..8be9a46c2 100644 --- a/src/sync/stream.ts +++ b/src/sync/stream.ts @@ -18,6 +18,7 @@ import { import Network from '../models/network'; import Queue from '../models/queue'; import { IHistoryTxSchema } from '../schemas'; +import { deriveShieldedAddressFromStorage } from '../utils/address'; /* eslint max-classes-per-file: ["error", 2] */ const QUEUE_GRACEFUL_SHUTDOWN_LIMIT = 10000; @@ -261,7 +262,8 @@ export async function xpubStreamSyncHistory( _count: number, storage: IStorage, connection: FullNodeConnection, - shouldProcessHistory: boolean = false + shouldProcessHistory?: boolean, + pinCode?: string ) { let firstIndex = startIndex; const scanPolicyData = await storage.getScanningPolicyData(); @@ -278,7 +280,7 @@ export async function xpubStreamSyncHistory( connection, HistorySyncMode.XPUB_STREAM_WS ); - await streamSyncHistory(manager, shouldProcessHistory); + await streamSyncHistory(manager, shouldProcessHistory, pinCode); } export async function manualStreamSyncHistory( @@ -286,7 +288,8 @@ export async function manualStreamSyncHistory( _count: number, storage: IStorage, connection: FullNodeConnection, - shouldProcessHistory: boolean = false + shouldProcessHistory?: boolean, + pinCode?: string ) { let firstIndex = startIndex; const scanPolicyData = await storage.getScanningPolicyData(); @@ -303,7 +306,7 @@ export async function manualStreamSyncHistory( connection, HistorySyncMode.MANUAL_STREAM_WS ); - await streamSyncHistory(manager, shouldProcessHistory); + await streamSyncHistory(manager, shouldProcessHistory, pinCode); } /** @@ -560,6 +563,28 @@ export class StreamManager extends AbortController { if (!alreadyExists) { await this.storage.saveAddress(addr); } + // Generate shielded address pair at the same index (if keys are available). + // Wrapped in try/catch so derivation failures don't crash the queue. + try { + const shieldedResult = await deriveShieldedAddressFromStorage( + addr.bip32AddressIndex, + this.storage + ); + if (shieldedResult) { + if (!(await this.storage.isAddressMine(shieldedResult.shieldedAddress.base58))) { + await this.storage.saveAddress(shieldedResult.shieldedAddress); + } + if (!(await this.storage.isAddressMine(shieldedResult.spendAddress.base58))) { + await this.storage.saveAddress(shieldedResult.spendAddress); + } + } + } catch (e) { + this.logger.error( + 'Failed to derive shielded address at index', + addr.bip32AddressIndex, + e + ); + } } else if (isStreamItemVertex(item)) { await this.storage.addTx(item.vertex); } @@ -756,7 +781,8 @@ function buildListener(manager: StreamManager, resolve: () => void) { */ export async function streamSyncHistory( manager: StreamManager, - shouldProcessHistory: boolean + shouldProcessHistory?: boolean, + pinCode?: string ): Promise { await manager.setupStream(); @@ -803,7 +829,7 @@ export async function streamSyncHistory( await manager.shutdown(); if (manager.foundAnyTx && shouldProcessHistory) { - await manager.storage.processHistory(); + await manager.storage.processHistory(pinCode); } } finally { // Always abort on finally to avoid memory leaks diff --git a/src/types.ts b/src/types.ts index 4a89d9721..d91ae9b0b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,7 @@ import Transaction from './models/transaction'; import Input from './models/input'; import FullNodeConnection from './new/connection'; import Header from './headers/base'; +import type { IShieldedCryptoProvider, ShieldedOutputMode } from './shielded/types'; /** * Token version used to identify the type of token during the token creation process. @@ -93,7 +94,8 @@ export type HistorySyncFunction = ( count: number, storage: IStorage, connection: FullNodeConnection, - shouldProcessHistory?: boolean + shouldProcessHistory?: boolean, + pinCode?: string ) => Promise; export interface IAddressInfo { @@ -101,6 +103,16 @@ export interface IAddressInfo { bip32AddressIndex: number; // Only for p2pkh, undefined for multisig publicKey?: string; + // Address type: undefined = legacy, 'shielded' = full shielded address, 'shielded-spend' = on-chain P2PKH from spend key + addressType?: 'p2pkh' | 'p2sh' | 'shielded' | 'shielded-spend'; +} + +/** + * Options for address methods that can operate on either the legacy or shielded address chain. + * Defaults to legacy (true) for backward compatibility. + */ +export interface IAddressChainOptions { + legacy?: boolean; // default: true } export interface IAddressMetadata { @@ -225,6 +237,21 @@ export interface IHistoryTx { nc_context?: IHistoryNanoContractContext; nc_seqnum?: number; // For nano contract first_block?: string | null; + shielded_outputs?: IHistoryShieldedOutput[]; // For confidential transactions +} + +// Equivalent to IShieldedOutput in shielded/types.ts but uses IHistoryOutputDecoded +// (which includes the extra `data?` field). Keep both in sync when modifying. +export interface IHistoryShieldedOutput { + mode: ShieldedOutputMode; + commitment: string; // hex + range_proof: string; // hex + script: string; // hex + token_data: number; + ephemeral_pubkey: string; // hex + decoded: IHistoryOutputDecoded; + asset_commitment?: string; // hex (FullShielded only) + surjection_proof?: string; // hex (FullShielded only) } export enum TxHistoryProcessingStatus { @@ -233,11 +260,15 @@ export enum TxHistoryProcessingStatus { } export interface IHistoryInput { - value: OutputValueType; - token_data: number; - script: string; - decoded: IHistoryOutputDecoded; - token: string; + // These fields are resolved from the spent output. + // For shielded inputs (spending shielded outputs), they may be absent + // because the spent output's value/token are hidden in commitments. + value?: OutputValueType; + token_data?: number; + script?: string; + decoded?: IHistoryOutputDecoded; + token?: string; + // Always present: tx_id: string; index: number; } @@ -250,7 +281,7 @@ export interface IHistoryOutputDecoded { data?: string; } -export interface IHistoryOutput { +export interface ITransparentOutput { value: OutputValueType; token_data: number; script: string; @@ -260,6 +291,31 @@ export interface IHistoryOutput { selected_as_input?: boolean; } +/** + * Shielded output entry as it appears in tx.outputs after decryption. + * Before decryption (from fullnode), value/token/decoded are absent. + * After decryption (by processNewTx), they are populated so the output + * can be processed uniformly with transparent outputs. + */ +export interface IShieldedOutputEntry { + type: 'shielded'; + value: OutputValueType; + token_data: number; + script: string; + decoded: IHistoryOutputDecoded; + token: string; + spent_by: string | null; + commitment: string; + range_proof: string; + ephemeral_pubkey: string; + asset_commitment?: string; + surjection_proof?: string; + blindingFactor?: string; // hex, 32 bytes — value blinding factor (populated after decryption) + assetBlindingFactor?: string; // hex, 32 bytes — asset blinding factor (FullShielded only) +} + +export type IHistoryOutput = ITransparentOutput | IShieldedOutputEntry; + export interface IDataOutputData { type: 'data'; token: string; @@ -323,6 +379,27 @@ interface IDataTokenCreationTx { tokenVersion?: TokenVersion; // `tokenVersion` cannot be named `version` because it conflicts with the `version` property of the `IDataTx` interface } +/** + * Intermediary representation of a shielded output during transaction building. + * Contains the output parameters and, after crypto processing, the cryptographic fields. + */ +export interface IDataShieldedOutput { + address: string; + value: OutputValueType; + token: string; + scanPubkey: string; // hex, 33 bytes compressed EC pubkey for ECDH + mode: ShieldedOutputMode; // 1 = AmountShielded, 2 = FullShielded + // Populated after crypto processing: + ephemeralPubkey?: Buffer; + commitment?: Buffer; + rangeProof?: Buffer; + blindingFactor?: Buffer; + assetCommitment?: Buffer; + assetBlindingFactor?: Buffer; + surjectionProof?: Buffer; + script?: string; // hex, the P2PKH/P2SH output script +} + // XXX: This type is meant to be used as an intermediary for building transactions // It should have everything we need to build and push transactions. export interface IDataTx extends Partial { @@ -331,6 +408,7 @@ export interface IDataTx extends Partial { inputs: IDataInput[]; outputs: IDataOutput[]; tokens: string[]; + shieldedOutputs?: IDataShieldedOutput[]; weight?: number; nonce?: number; timestamp?: number; @@ -353,6 +431,9 @@ export interface IUtxo { timelock: number | null; type: number; // tx.version, is the value of the transaction version byte height: number | null; // only for block outputs + shielded?: boolean; // marks this as a shielded UTXO (confidential transaction) + blindingFactor?: string; // hex, 32 bytes — value blinding factor from decryption + assetBlindingFactor?: string; // hex, 32 bytes — asset blinding factor (FullShielded only) } export interface ILockedUtxo { @@ -379,6 +460,11 @@ export interface IWalletAccessData { multisigData?: IMultisigData; walletType: WalletType; walletFlags: number; + // Shielded address key material (optional, absent on wallets created before shielded feature) + scanXpubkey?: string; // xpub at m/44'/280'/1'/0 (scan chain — view-only access) + scanMainKey?: IEncryptedData; // encrypted xpriv at m/44'/280'/1'/0 + spendXpubkey?: string; // xpub at m/44'/280'/2'/0 (spend chain — signing authority) + spendMainKey?: IEncryptedData; // encrypted xpriv at m/44'/280'/2'/0 } export enum SCANNING_POLICY { @@ -442,6 +528,10 @@ export interface IWalletData { lastLoadedAddressIndex: number; lastUsedAddressIndex: number; currentAddressIndex: number; + // Shielded address chain tracking (separate gap-limit scanning) + shieldedLastLoadedAddressIndex: number; + shieldedLastUsedAddressIndex: number; + shieldedCurrentAddressIndex: number; bestBlockHeight: number; scanPolicyData: AddressScanPolicyData; } @@ -475,6 +565,11 @@ export interface IUtxoFilterOptions { // Will order utxos by value, asc or desc // If not set, will not order order_by_value?: 'asc' | 'desc'; + // Filter by shielded status: + // undefined (default) → all UTXOs (transparent + shielded) + // true → only shielded UTXOs + // false → only transparent UTXOs + shielded?: boolean; } export type UtxoSelectionAlgorithm = ( @@ -520,7 +615,7 @@ export interface IStore { getAddress(base58: string): Promise; getAddressMeta(base58: string): Promise; getSeqnumMeta(base58: string): Promise; - getAddressAtIndex(index: number): Promise; + getAddressAtIndex(index: number, opts?: IAddressChainOptions): Promise; saveAddress(info: IAddressInfo): Promise; addressExists(base58: string): Promise; addressCount(): Promise; @@ -548,6 +643,7 @@ export interface IStore { utxoIter(): AsyncGenerator; selectUtxos(options: IUtxoFilterOptions): AsyncGenerator; saveUtxo(utxo: IUtxo): Promise; + getUtxo(utxoId: IUtxoId): Promise; saveLockedUtxo(lockedUtxo: ILockedUtxo): Promise; iterateLockedUtxos(): AsyncGenerator; unlockUtxo(lockedUtxo: ILockedUtxo): Promise; @@ -557,13 +653,13 @@ export interface IStore { getAccessData(): Promise; saveAccessData(data: IWalletAccessData): Promise; getWalletData(): Promise; - getLastLoadedAddressIndex(): Promise; - getLastUsedAddressIndex(): Promise; - setLastUsedAddressIndex(index: number): Promise; + getLastLoadedAddressIndex(opts?: IAddressChainOptions): Promise; + getLastUsedAddressIndex(opts?: IAddressChainOptions): Promise; + setLastUsedAddressIndex(index: number, opts?: IAddressChainOptions): Promise; getCurrentHeight(): Promise; setCurrentHeight(height: number): Promise; - getCurrentAddress(markAsUsed?: boolean): Promise; - setCurrentAddressIndex(index: number): Promise; + getCurrentAddress(markAsUsed?: boolean, opts?: IAddressChainOptions): Promise; + setCurrentAddressIndex(index: number, opts?: IAddressChainOptions): Promise; setGapLimit(value: number): Promise; getGapLimit(): Promise; getIndexLimit(): Promise | null>; @@ -598,6 +694,10 @@ export interface IStorage { version: ApiVersion | null; logger: ILogger; + // Shielded (confidential transaction) crypto provider + shieldedCryptoProvider?: IShieldedCryptoProvider; + setShieldedCryptoProvider(provider?: IShieldedCryptoProvider): void; + setApiVersion(version: ApiVersion): void; getDecimalPlaces(): number; saveNativeToken(): Promise; @@ -611,11 +711,11 @@ export interface IStorage { // Address methods getAllAddresses(): AsyncGenerator; getAddressInfo(base58: string): Promise<(IAddressInfo & IAddressMetadata) | null>; - getAddressAtIndex(index: number): Promise; + getAddressAtIndex(index: number, opts?: IAddressChainOptions): Promise; getAddressPubkey(index: number): Promise; saveAddress(info: IAddressInfo): Promise; isAddressMine(base58: string): Promise; - getCurrentAddress(markAsUsed?: boolean): Promise; + getCurrentAddress(markAsUsed?: boolean, opts?: IAddressChainOptions): Promise; getChangeAddress(options?: { changeAddress?: null | string }): Promise; // Transaction methods @@ -624,8 +724,9 @@ export interface IStorage { getTx(txId: string): Promise; getSpentTxs(inputs: Input[]): AsyncGenerator<{ tx: IHistoryTx; input: Input; index: number }>; addTx(tx: IHistoryTx): Promise; - processHistory(): Promise; - processNewTx(tx: IHistoryTx): Promise; + processHistory(pinCode?: string): Promise; + processNewTx(tx: IHistoryTx, pinCode?: string): Promise; + getUtxo(utxoId: IUtxoId): Promise; // Tokens addToken(data: ITokenData): Promise; @@ -656,6 +757,12 @@ export interface IStorage { getMainXPrivKey(pinCode: string): Promise; getAcctPathXPrivKey(pinCode: string): Promise; getAuthPrivKey(pinCode: string): Promise; + + // Shielded key methods (return undefined if wallet was created before shielded feature) + getScanXPrivKey(pinCode: string): Promise; + getSpendXPrivKey(pinCode: string): Promise; + getScanXPubKey(): Promise; + getSpendXPubKey(): Promise; getWalletData(): Promise; getWalletType(): Promise; getCurrentHeight(): Promise; diff --git a/src/utils/address.ts b/src/utils/address.ts index ee4c17d45..2e6bef7fc 100644 --- a/src/utils/address.ts +++ b/src/utils/address.ts @@ -18,18 +18,38 @@ import Network from '../models/network'; import { hexToBuffer } from './buffer'; import { IMultisigData, IStorage, IAddressInfo } from '../types'; import { createP2SHRedeemScript } from './scripts'; +import { deriveShieldedAddress } from './shieldedAddress'; /** - * Parse address and return the address type + * Parse address and return the address type. + * Returns 'p2pkh' or 'p2sh' for legacy addresses. + * Throws for shielded addresses — callers expecting an output script type + * should not receive shielded addresses directly. * * @param {string} address * @param {Network} network * - * @returns {string} output type of the address (p2pkh or p2sh) + * @returns {'p2pkh' | 'p2sh'} output type of the address */ export function getAddressType(address: string, network: Network): 'p2pkh' | 'p2sh' { const addressObj = new Address(address, { network }); - return addressObj.getType(); + const addrType = addressObj.getType(); + if (addrType === 'shielded') { + throw new Error( + 'Shielded addresses cannot be used directly as output script type. Use the spend-derived P2PKH address instead.' + ); + } + return addrType; +} + +/** + * Convert a bitcore PublicKey to a base58 P2PKH address string. + */ +export function publicKeyToP2PKH( + publicKey: InstanceType, + network: Network +): string { + return new BitcoreAddress(publicKey, network.bitcoreNetwork).toString(); } export function deriveAddressFromXPubP2PKH( @@ -41,7 +61,7 @@ export function deriveAddressFromXPubP2PKH( const hdpubkey = new HDPublicKey(xpubkey); const key = hdpubkey.deriveChild(index); return { - base58: new BitcoreAddress(key.publicKey, network.bitcoreNetwork).toString(), + base58: publicKeyToP2PKH(key.publicKey, network), bip32AddressIndex: index, publicKey: key.publicKey.toString('hex'), }; @@ -121,6 +141,12 @@ export function createOutputScriptFromAddress(address: string, network: Network) const p2pkh = new P2PKH(addressObj); return p2pkh.createScript(); } + if (addressType === 'shielded') { + // For shielded addresses, derive P2PKH script from spend_pubkey + const spendAddress = addressObj.getSpendAddress(); + const p2pkh = new P2PKH(spendAddress); + return p2pkh.createScript(); + } throw new Error('Invalid address type'); } @@ -139,3 +165,47 @@ export function getAddressFromPubkey(pubkey: string, network: Network): Address ).toString(); return new Address(base58, { network }); } + +/** + * Derive shielded address and its on-chain spend address from storage at a given index. + * + * Returns two IAddressInfo entries: + * 1. The shielded address (user-facing, 71-byte format) + * 2. The spend-derived P2PKH address (on-chain, for matching incoming txs) + * + * Returns null if the wallet doesn't have shielded key material. + */ +export async function deriveShieldedAddressFromStorage( + index: number, + storage: IStorage +): Promise<{ shieldedAddress: IAddressInfo; spendAddress: IAddressInfo } | null> { + const scanXpub = await storage.getScanXPubKey(); + const spendXpub = await storage.getSpendXPubKey(); + if (!scanXpub || !spendXpub) { + return null; + } + + const networkName = storage.config.getNetwork().name; + const info = deriveShieldedAddress(scanXpub, spendXpub, index, networkName); + + // The user-facing shielded address encodes both scan and spend pubkeys. + // This is what users share with senders to receive shielded outputs. + const shieldedAddress: IAddressInfo = { + base58: info.base58, + bip32AddressIndex: index, + publicKey: info.scanPubkey, + addressType: 'shielded', + }; + + // The on-chain P2PKH derived from the spend pubkey (spend_pubkey → HASH160 → P2PKH). + // Stored separately so the wallet can match incoming transactions by decoded.address, + // since on-chain scripts reference this P2PKH, not the shielded address. + const spendAddress: IAddressInfo = { + base58: info.spendAddress, + bip32AddressIndex: index, + publicKey: info.spendPubkey, + addressType: 'shielded-spend', + }; + + return { shieldedAddress, spendAddress }; +} diff --git a/src/utils/bigint.ts b/src/utils/bigint.ts index 7cbb3c586..6f509c4d1 100644 --- a/src/utils/bigint.ts +++ b/src/utils/bigint.ts @@ -44,7 +44,7 @@ export const JSONBigInt = { } catch (e) { if ( e instanceof SyntaxError && - (e.message === `Cannot convert ${context.source} to a BigInt` || + (e.message === `Cannot convert ${context?.source} to a BigInt` || e.message === `invalid BigInt syntax`) ) { // When this error happens, it means the number cannot be converted to a BigInt, diff --git a/src/utils/shieldedAddress.ts b/src/utils/shieldedAddress.ts new file mode 100644 index 000000000..11a280604 --- /dev/null +++ b/src/utils/shieldedAddress.ts @@ -0,0 +1,101 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { encoding, HDPublicKey } from 'bitcore-lib'; +import Network from '../models/network'; +import helpers from './helpers'; +import { publicKeyToP2PKH } from './address'; + +export interface IShieldedAddressInfo { + /** Full shielded address in base58 */ + base58: string; + /** BIP32 index used to derive both scan and spend keys */ + bip32AddressIndex: number; + /** 33-byte compressed scan pubkey (hex) */ + scanPubkey: string; + /** 33-byte compressed spend pubkey (hex) */ + spendPubkey: string; + /** P2PKH address derived from HASH160(spend_pubkey) — the on-chain address */ + spendAddress: string; +} + +/** + * Encode a shielded address from two public keys. + * Format: Base58(version_byte(1B) || scan_pubkey(33B) || spend_pubkey(33B) || checksum(4B)) + * + * @param scanPubkey 33-byte compressed EC public key for scanning (ECDH) + * @param spendPubkey 33-byte compressed EC public key for spending + * @param network Network instance + * @returns Base58-encoded shielded address (~97 characters) + */ +export function encodeShieldedAddress( + scanPubkey: Buffer, + spendPubkey: Buffer, + network: Network +): string { + if (scanPubkey.length !== 33 || (scanPubkey[0] !== 0x02 && scanPubkey[0] !== 0x03)) { + throw new Error( + `Invalid scan pubkey: expected 33-byte compressed EC point (02/03 prefix), ` + + `got ${scanPubkey.length} bytes with prefix 0x${scanPubkey[0]?.toString(16).padStart(2, '0')}` + ); + } + if (spendPubkey.length !== 33 || (spendPubkey[0] !== 0x02 && spendPubkey[0] !== 0x03)) { + throw new Error( + `Invalid spend pubkey: expected 33-byte compressed EC point (02/03 prefix), ` + + `got ${spendPubkey.length} bytes with prefix 0x${spendPubkey[0]?.toString(16).padStart(2, '0')}` + ); + } + + const versionByte = Buffer.from([network.versionBytes.shielded]); + const payload = Buffer.concat([versionByte, scanPubkey, spendPubkey]); + const checksum = helpers.getChecksum(payload); + const full = Buffer.concat([payload, checksum]); + return encoding.Base58.encode(full); +} + +/** + * Derive a shielded address at a given BIP32 index from xpub keys. + * + * @param scanXpubkey xpub at the scan chain (m/44'/280'/1'/0) + * @param spendXpubkey xpub at the spend chain (m/44'/280'/2'/0) + * @param index BIP32 address index + * @param networkName Network name (mainnet, testnet, privatenet) + * @returns Shielded address info + */ +export function deriveShieldedAddress( + scanXpubkey: string, + spendXpubkey: string, + index: number, + networkName: string +): IShieldedAddressInfo { + const network = new Network(networkName); + + const scanHdPub = new HDPublicKey(scanXpubkey); + const spendHdPub = new HDPublicKey(spendXpubkey); + + // Standard BIP32 derivation for public keys. + // Note: private key derivation (in processing.ts) uses deriveNonCompliantChild + // due to a historical bitcore-lib bug. Do not align these — the asymmetry is intentional. + const scanKey = scanHdPub.deriveChild(index); + const spendKey = spendHdPub.deriveChild(index); + + const scanPubkeyBuf: Buffer = scanKey.publicKey.toBuffer(); + const spendPubkeyBuf: Buffer = spendKey.publicKey.toBuffer(); + + const base58 = encodeShieldedAddress(scanPubkeyBuf, spendPubkeyBuf, network); + + // Derive on-chain P2PKH from spend_pubkey + const spendAddress = publicKeyToP2PKH(spendKey.publicKey, network); + + return { + base58, + bip32AddressIndex: index, + scanPubkey: scanPubkeyBuf.toString('hex'), + spendPubkey: spendPubkeyBuf.toString('hex'), + spendAddress, + }; +} diff --git a/src/utils/storage.ts b/src/utils/storage.ts index abcda264d..0b7ecd67d 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -25,14 +25,22 @@ import { IUtxo, ITokenData, TokenVersion, + IShieldedOutputEntry, } from '../types'; import walletApi from '../api/wallet'; import helpers from './helpers'; import transactionUtils from './transaction'; -import { deriveAddressP2PKH, deriveAddressP2SH, getAddressFromPubkey } from './address'; +import { + deriveAddressP2PKH, + deriveAddressP2SH, + deriveShieldedAddressFromStorage, + getAddressFromPubkey, +} from './address'; +import { processShieldedOutputs } from '../shielded/processing'; import { xpubStreamSyncHistory, manualStreamSyncHistory } from '../sync/stream'; import { NATIVE_TOKEN_UID, + NATIVE_TOKEN_UID_HEX, MAX_ADDRESSES_GET, LOAD_WALLET_MAX_RETRY, LOAD_WALLET_RETRY_SLEEP, @@ -92,19 +100,36 @@ export async function loadAddresses( for (let i = startIndex; i < stopIndex; i++) { const storageAddr = await storage.getAddressAtIndex(i); if (storageAddr !== null) { - // This address is already generated, we can skip derivation + // This address is already generated, we can skip legacy derivation addresses.push(storageAddr.base58); - continue; - } - // derive address at index i - let address: IAddressInfo; - if ((await storage.getWalletType()) === 'p2pkh') { - address = await deriveAddressP2PKH(i, storage); } else { - address = await deriveAddressP2SH(i, storage); + // derive legacy address at index i + let address: IAddressInfo; + if ((await storage.getWalletType()) === 'p2pkh') { + address = await deriveAddressP2PKH(i, storage); + } else { + address = await deriveAddressP2SH(i, storage); + } + await storage.saveAddress(address); + addresses.push(address.base58); + } + + // Always generate shielded address pair at the same index (if keys are available). + // Check existence first to avoid "Already have this address" error on re-loads. + const shieldedResult = await deriveShieldedAddressFromStorage(i, storage); + if (shieldedResult) { + if (!(await storage.isAddressMine(shieldedResult.shieldedAddress.base58))) { + await storage.saveAddress(shieldedResult.shieldedAddress); + } + if (!(await storage.isAddressMine(shieldedResult.spendAddress.base58))) { + await storage.saveAddress(shieldedResult.spendAddress); + } + // Only the spend-derived P2PKH is subscribed for tx notifications. + // The user-facing shielded address (scan+spend pubkeys) is NOT subscribed + // because the fullnode indexes transactions by on-chain script address, + // not by the shielded address format. + addresses.push(shieldedResult.spendAddress.base58); } - await storage.saveAddress(address); - addresses.push(address.base58); } return addresses; @@ -125,7 +150,8 @@ export async function apiSyncHistory( count: number, storage: IStorage, connection: FullnodeConnection, - shouldProcessHistory: boolean = false + shouldProcessHistory?: boolean, + pinCode?: string ) { let itStartIndex = startIndex; let itCount = count; @@ -158,7 +184,7 @@ export async function apiSyncHistory( itCount = loadMoreAddresses.count; } if (foundAnyTx && shouldProcessHistory) { - await storage.processHistory(); + await storage.processHistory(pinCode); } } @@ -318,7 +344,8 @@ export async function checkIndexLimit(storage: IStorage): Promise lastLoadedAddressIndex) { - // we need to generate more addresses to fill the gap limit - return { - nextIndex: lastLoadedAddressIndex + 1, - count: lastUsedAddressIndex + gapLimit - lastLoadedAddressIndex, - }; + + // Check both legacy and shielded chains independently. + const legacyNeedMore = lastUsedAddressIndex + gapLimit > lastLoadedAddressIndex; + // Only check shielded gap if shielded keys are available (spendXpubkey exists). + const hasShieldedKeys = !!(await storage.getAccessData())?.spendXpubkey; + const shieldedNeedMore = + hasShieldedKeys && shieldedLastUsedAddressIndex + gapLimit > shieldedLastLoadedAddressIndex; + + if (!legacyNeedMore && !shieldedNeedMore) { + return null; } - return null; + + // loadAddresses generates both legacy and shielded at each BIP32 index, + // so we need to satisfy whichever chain is furthest behind. + // Use the minimum of the two lastLoaded as the starting point, so that + // the lagging chain gets its addresses loaded. + const legacyTarget = legacyNeedMore ? lastUsedAddressIndex + gapLimit : lastLoadedAddressIndex; + let shieldedTarget: number; + if (!hasShieldedKeys) { + shieldedTarget = legacyTarget; + } else if (shieldedNeedMore) { + shieldedTarget = shieldedLastUsedAddressIndex + gapLimit; + } else { + shieldedTarget = shieldedLastLoadedAddressIndex; + } + const maxTarget = Math.max(legacyTarget, shieldedTarget); + const minLastLoaded = hasShieldedKeys + ? Math.min(lastLoadedAddressIndex, shieldedLastLoadedAddressIndex) + : lastLoadedAddressIndex; + + const nextIndex = minLastLoaded + 1; + const count = Math.max(maxTarget - minLastLoaded, 1); + + return { nextIndex, count }; } /** @@ -387,7 +449,7 @@ export async function checkGapLimit(storage: IStorage): Promise { const { store } = storage; // We have an additive method to update metadata so we need to clean the current metadata before processing. @@ -397,32 +459,49 @@ export async function processHistory( const currentHeight = await store.getCurrentHeight(); const tokens = new Set(); - let maxIndexUsed = -1; + let legacyMaxIndexUsed = -1; + let shieldedMaxIndexUsed = -1; // Iterate on all txs of the history updating the metadata as we go for await (const tx of store.historyIter()) { - const processedData = await processNewTx(storage, tx, { rewardLock, nowTs, currentHeight }); - maxIndexUsed = Math.max(maxIndexUsed, processedData.maxAddressIndex); + const processedData = await processNewTx(storage, tx, { + rewardLock, + nowTs, + currentHeight, + pinCode, + }); + legacyMaxIndexUsed = Math.max(legacyMaxIndexUsed, processedData.legacyMaxAddressIndex); + shieldedMaxIndexUsed = Math.max(shieldedMaxIndexUsed, processedData.shieldedMaxAddressIndex); for (const token of processedData.tokens) { tokens.add(token); } } // Update wallet data - await updateWalletMetadataFromProcessedTxData(storage, { maxIndexUsed, tokens }); + await updateWalletMetadataFromProcessedTxData(storage, { + legacyMaxIndexUsed, + shieldedMaxIndexUsed, + tokens, + }); } export async function processSingleTx( storage: IStorage, tx: IHistoryTx, - { rewardLock }: { rewardLock?: number } = {} + { rewardLock, pinCode }: { rewardLock?: number; pinCode?: string } = {} ): Promise { const { store } = storage; const nowTs = Math.floor(Date.now() / 1000); const currentHeight = await store.getCurrentHeight(); const tokens = new Set(); - const processedData = await processNewTx(storage, tx, { rewardLock, nowTs, currentHeight }); - const maxIndexUsed = processedData.maxAddressIndex; + const processedData = await processNewTx(storage, tx, { + rewardLock, + nowTs, + currentHeight, + pinCode, + }); + const legacyMaxIndexUsed = processedData.legacyMaxAddressIndex; + const shieldedMaxIndexUsed = processedData.shieldedMaxAddressIndex; for (const token of processedData.tokens) { tokens.add(token); } @@ -434,12 +513,19 @@ export async function processSingleTx( continue; } - if (origTx.outputs.length <= input.index) { + const totalOutputs = origTx.outputs.length + (origTx.shielded_outputs?.length ?? 0); + if (totalOutputs <= input.index) { throw new Error('Spending an unexistent output'); } + // If the index refers to a shielded output that hasn't been decoded yet, + // skip it — it will be handled when the decoded output is appended. + if (input.index >= origTx.outputs.length) { + continue; + } + const output = origTx.outputs[input.index]; - if (!output.decoded.address) { + if (!output.decoded?.address) { // Tx is ours but output is not from an address. continue; } @@ -467,7 +553,11 @@ export async function processSingleTx( } // Update wallet data in the store - await updateWalletMetadataFromProcessedTxData(storage, { maxIndexUsed, tokens }); + await updateWalletMetadataFromProcessedTxData(storage, { + legacyMaxIndexUsed, + shieldedMaxIndexUsed, + tokens, + }); } /** @@ -587,20 +677,37 @@ export async function _updateTokensData(storage: IStorage, tokens: Set): */ async function updateWalletMetadataFromProcessedTxData( storage: IStorage, - { maxIndexUsed, tokens }: { maxIndexUsed: number; tokens: Set } + { + legacyMaxIndexUsed, + shieldedMaxIndexUsed, + tokens, + }: { legacyMaxIndexUsed: number; shieldedMaxIndexUsed: number; tokens: Set } ): Promise { const { store } = storage; - // Update wallet data const walletData = await store.getWalletData(); - if (maxIndexUsed > -1) { - // If maxIndexUsed is -1 it means we didn't find any tx, so we don't need to update the wallet data - if (walletData.lastUsedAddressIndex <= maxIndexUsed) { - if (walletData.currentAddressIndex <= maxIndexUsed) { + + // Update legacy chain tracking + if (legacyMaxIndexUsed > -1) { + if (walletData.lastUsedAddressIndex <= legacyMaxIndexUsed) { + if (walletData.currentAddressIndex <= legacyMaxIndexUsed) { await store.setCurrentAddressIndex( - Math.min(maxIndexUsed + 1, walletData.lastLoadedAddressIndex) + Math.min(legacyMaxIndexUsed + 1, walletData.lastLoadedAddressIndex) ); } - await store.setLastUsedAddressIndex(maxIndexUsed); + await store.setLastUsedAddressIndex(legacyMaxIndexUsed); + } + } + + // Update shielded chain tracking + if (shieldedMaxIndexUsed > -1) { + if (walletData.shieldedLastUsedAddressIndex <= shieldedMaxIndexUsed) { + if (walletData.shieldedCurrentAddressIndex <= shieldedMaxIndexUsed) { + await store.setCurrentAddressIndex( + Math.min(shieldedMaxIndexUsed + 1, walletData.shieldedLastLoadedAddressIndex), + { legacy: false } + ); + } + await store.setLastUsedAddressIndex(shieldedMaxIndexUsed, { legacy: false }); } } @@ -621,7 +728,8 @@ async function updateWalletMetadataFromProcessedTxData( * @param {number} [options.rewardLock] The reward lock of the network * @param {number} [options.nowTs] The current timestamp * @param {number} [options.currentHeight] The current height of the best chain - * @returns {Promise<{ maxAddressIndex: number, tokens: Set }>} + * @param {string} [options.pinCode] PIN code for shielded output decryption + * @returns {Promise<{ legacyMaxAddressIndex: number, shieldedMaxAddressIndex: number, tokens: Set }>} */ export async function processNewTx( storage: IStorage, @@ -630,9 +738,11 @@ export async function processNewTx( rewardLock, nowTs, currentHeight, - }: { rewardLock?: number; nowTs?: number; currentHeight?: number } = {} + pinCode, + }: { rewardLock?: number; nowTs?: number; currentHeight?: number; pinCode?: string } = {} ): Promise<{ - maxAddressIndex: number; + legacyMaxAddressIndex: number; + shieldedMaxAddressIndex: number; tokens: Set; }> { function getEmptyBalance(): IBalance { @@ -673,14 +783,77 @@ export async function processNewTx( // We ignore voided transactions if (tx.is_voided) return { - maxAddressIndex: -1, + legacyMaxAddressIndex: -1, + shieldedMaxAddressIndex: -1, tokens: new Set(), }; const isHeightLocked = transactionUtils.isHeightLocked(tx.height, currentHeight, rewardLock); const txAddresses = new Set(); const txTokens = new Set(); - let maxIndexUsed = -1; + let legacyMaxIndexUsed = -1; + let shieldedMaxIndexUsed = -1; + + // Decrypt shielded outputs and append decoded entries to tx.outputs BEFORE the main loop. + // This unifies the processing: the same loop handles transparent + decoded shielded outputs. + // Skip if already decoded (e.g., processHistory re-processing a previously processed tx). + const alreadyDecoded = tx.outputs.some(o => transactionUtils.isShieldedOutputEntry(o)); + if ( + !alreadyDecoded && + storage.shieldedCryptoProvider && + tx.shielded_outputs?.length && + pinCode !== undefined + ) { + try { + // Capture transparent output count before appending decoded shielded outputs. + // result.index from processShieldedOutputs uses this same count as base, + // so (result.index - transparentCount) gives the shielded_outputs array index. + const transparentCount = tx.outputs.length; + + const shieldedResults = await processShieldedOutputs( + storage, + tx, + storage.shieldedCryptoProvider, + pinCode + ); + for (const result of shieldedResults) { + const walletTokenUid = + result.tokenUid === NATIVE_TOKEN_UID_HEX ? NATIVE_TOKEN_UID : result.tokenUid; + const so = tx.shielded_outputs[result.index - transparentCount]; + + // Append decoded shielded output to tx.outputs so the main loop processes it + // alongside transparent outputs (UTXO creation, balance, metadata). + tx.outputs.push({ + type: 'shielded', + value: result.decrypted.value, + token_data: so?.token_data ?? 0, + script: so?.script ?? '', + decoded: { ...so?.decoded, address: result.address }, + token: walletTokenUid, + spent_by: null, + commitment: so?.commitment ?? '', + range_proof: so?.range_proof ?? '', + ephemeral_pubkey: so?.ephemeral_pubkey ?? '', + asset_commitment: so?.asset_commitment, + surjection_proof: so?.surjection_proof, + blindingFactor: result.decrypted.blindingFactor.toString('hex'), + assetBlindingFactor: result.decrypted.assetBlindingFactor?.toString('hex'), + }); + } + if (shieldedResults.length > 0) { + await store.saveTx(tx); + } + } catch (e) { + // processShieldedOutputs handles per-output rewind failures internally. + // If we get here, something unexpected went wrong at the infrastructure level. + storage.logger.error( + 'Unexpected error processing shielded outputs for tx', + tx.tx_id, + '- wallet may be missing shielded funds.', + e + ); + } + } for (const [index, output] of tx.outputs.entries()) { // Skip data outputs since they do not have an address and do not "belong" in a wallet @@ -696,10 +869,19 @@ export async function processNewTx( let addressMeta = await store.getAddressMeta(output.decoded.address); let tokenMeta = await store.getTokenMeta(output.token); - // check if the current address is the highest index used - // Update the max index used if it is - if (addressInfo.bip32AddressIndex > maxIndexUsed) { - maxIndexUsed = addressInfo.bip32AddressIndex; + // Track the max address index per chain + if (addressInfo.addressType === 'shielded-spend') { + if (addressInfo.bip32AddressIndex > shieldedMaxIndexUsed) { + shieldedMaxIndexUsed = addressInfo.bip32AddressIndex; + } + } else if ( + !addressInfo.addressType || + addressInfo.addressType === 'p2pkh' || + addressInfo.addressType === 'p2sh' + ) { + if (addressInfo.bip32AddressIndex > legacyMaxIndexUsed) { + legacyMaxIndexUsed = addressInfo.bip32AddressIndex; + } } // create metadata for address and token if it does not exist @@ -754,6 +936,7 @@ export async function processNewTx( // Add utxo to the storage if unspent // This is idempotent so it's safe to call it multiple times if (output.spent_by === null) { + const isShielded = transactionUtils.isShieldedOutputEntry(output); await store.saveUtxo({ txId: tx.tx_id, index, @@ -764,6 +947,13 @@ export async function processNewTx( value: output.value, timelock: output.decoded.timelock || null, height: tx.height || null, + ...(isShielded + ? { + shielded: true, + blindingFactor: (output as IShieldedOutputEntry).blindingFactor, + assetBlindingFactor: (output as IShieldedOutputEntry).assetBlindingFactor, + } + : {}), }); if (isLocked) { // We will save this utxo on the index of locked utxos @@ -780,20 +970,34 @@ export async function processNewTx( } for (const input of tx.inputs) { - // We ignore data inputs since they do not have an address - if (!input.decoded.address) continue; + // We ignore data inputs and shielded inputs since they do not have an address + // Shielded inputs also lack value/token/token_data fields. + if (!input.decoded?.address || input.token === undefined) continue; const addressInfo = await store.getAddress(input.decoded.address); // This is not our address, ignore if (!addressInfo) continue; - const isAuthority: boolean = transactionUtils.isAuthorityOutput(input); + // At this point input.token is defined (checked above), so all transparent fields exist. + const isAuthority: boolean = transactionUtils.isAuthorityOutput({ + token_data: input.token_data!, + }); let addressMeta = await store.getAddressMeta(input.decoded.address); let tokenMeta = await store.getTokenMeta(input.token); - // We also check the index of the input addresses, but they should have been processed as outputs of another transaction. - if (addressInfo.bip32AddressIndex > maxIndexUsed) { - maxIndexUsed = addressInfo.bip32AddressIndex; + // Track input address indices per chain + if (addressInfo.addressType === 'shielded-spend') { + if (addressInfo.bip32AddressIndex > shieldedMaxIndexUsed) { + shieldedMaxIndexUsed = addressInfo.bip32AddressIndex; + } + } else if ( + !addressInfo.addressType || + addressInfo.addressType === 'p2pkh' || + addressInfo.addressType === 'p2sh' + ) { + if (addressInfo.bip32AddressIndex > legacyMaxIndexUsed) { + legacyMaxIndexUsed = addressInfo.bip32AddressIndex; + } } // create metadata for address and token if it does not exist @@ -813,22 +1017,22 @@ export async function processNewTx( txAddresses.add(input.decoded.address); if (isAuthority) { - if (transactionUtils.isMint(input)) { + if (transactionUtils.isMint({ value: input.value!, token_data: input.token_data! })) { tokenMeta.balance.authorities.mint.unlocked -= 1n; addressMeta.balance.get(input.token)!.authorities.mint.unlocked -= 1n; } - if (transactionUtils.isMelt(input)) { + if (transactionUtils.isMelt({ value: input.value!, token_data: input.token_data! })) { tokenMeta.balance.authorities.melt.unlocked -= 1n; addressMeta.balance.get(input.token)!.authorities.melt.unlocked -= 1n; } } else { - tokenMeta.balance.tokens.unlocked -= input.value; - addressMeta.balance.get(input.token)!.tokens.unlocked -= input.value; + tokenMeta.balance.tokens.unlocked -= input.value!; + addressMeta.balance.get(input.token)!.tokens.unlocked -= input.value!; } // save address and token metadata await store.editTokenMeta(input.token, tokenMeta); - await store.editAddressMeta(input.decoded.address, addressMeta); + await store.editAddressMeta(input.decoded!.address!, addressMeta); } // Nano contract and ocb transactions have the address used to sign the tx @@ -888,7 +1092,8 @@ export async function processNewTx( } return { - maxAddressIndex: maxIndexUsed, + legacyMaxAddressIndex: legacyMaxIndexUsed, + shieldedMaxAddressIndex: shieldedMaxIndexUsed, tokens: txTokens, }; } diff --git a/src/utils/transaction.ts b/src/utils/transaction.ts index 043dcc986..8ad659b7c 100644 --- a/src/utils/transaction.ts +++ b/src/utils/transaction.ts @@ -28,6 +28,8 @@ import Transaction from '../models/transaction'; import CreateTokenTransaction from '../models/create_token_transaction'; import Input from '../models/input'; import Output from '../models/output'; +import ShieldedOutput from '../models/shielded_output'; +import ShieldedOutputsHeader from '../headers/shielded_outputs'; import Network from '../models/network'; import { IBalance, @@ -37,13 +39,16 @@ import { IDataTx, isDataOutputCreateToken, IHistoryOutput, + IShieldedOutputEntry, IUtxoId, IInputSignature, ITxSignatureData, OutputValueType, IHistoryInput, + IHistoryShieldedOutput, AuthorityType, } from '../types'; +import { ShieldedOutputMode } from '../shielded/types'; import Address from '../models/address'; import P2PKH from '../models/p2pkh'; import P2SH from '../models/p2sh'; @@ -70,6 +75,57 @@ const transaction = { ); }, + /** + * Check if an output entry from IHistoryTx.outputs is a shielded output appended + * by the fullnode's to_json_extended(). These entries have type='shielded' and lack + * the 'value' and 'token' fields that transparent outputs have. + * Shielded outputs are processed separately via processShieldedOutputs(). + */ + isShieldedOutputEntry( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + output: IHistoryOutput | Record + ): output is IShieldedOutputEntry { + return output != null && (output as { type?: string }).type === 'shielded'; + }, + + /** + * Normalize a transaction's outputs by extracting shielded entries from outputs[] + * into a separate shielded_outputs[] array. Converts base64-encoded fields to hex. + * Mutates the tx in place. No-op if shielded_outputs is already populated. + */ + normalizeShieldedOutputs(tx: IHistoryTx): void { + if (tx.shielded_outputs) return; + const shieldedEntries: IHistoryShieldedOutput[] = []; + const transparentOutputs: IHistoryOutput[] = []; + for (const output of tx.outputs) { + if (this.isShieldedOutputEntry(output)) { + shieldedEntries.push({ + mode: output.asset_commitment + ? ShieldedOutputMode.FULLY_SHIELDED + : ShieldedOutputMode.AMOUNT_SHIELDED, + commitment: output.commitment, + range_proof: Buffer.from(output.range_proof, 'base64').toString('hex'), + script: Buffer.from(output.script, 'base64').toString('hex'), + token_data: output.token_data, + ephemeral_pubkey: output.ephemeral_pubkey, + decoded: output.decoded, + asset_commitment: output.asset_commitment, + surjection_proof: output.surjection_proof + ? Buffer.from(output.surjection_proof, 'base64').toString('hex') + : undefined, + }); + } else { + transparentOutputs.push(output); + } + } + if (shieldedEntries.length > 0) { + // eslint-disable-next-line no-param-reassign + tx.shielded_outputs = shieldedEntries; + // eslint-disable-next-line no-param-reassign + tx.outputs = transparentOutputs; + } + }, + /** * Check if the output is an authority output * @@ -174,6 +230,10 @@ const transaction = { ): Promise { const xprivstr = await storage.getMainXPrivKey(pinCode); const xprivkey = HDPrivateKey.fromString(xprivstr); + + // Lazily load the spend key chain for shielded-spend addresses + let spendXprivkey: typeof xprivkey | null = null; + const dataToSignHash = tx.getDataToSignHash(); const signatures: IInputSignature[] = []; let ncCallerSignature: Buffer | null = null; @@ -194,12 +254,25 @@ const transaction = { // Not a wallet address continue; } - const xpriv = xprivkey.deriveNonCompliantChild(addressInfo.bip32AddressIndex); + + let derivedKey; + if (addressInfo.addressType === 'shielded-spend') { + // Use spend key chain (m/44'/280'/2'/0) for shielded UTXO inputs + if (!spendXprivkey) { + const spendXprivStr = await storage.getSpendXPrivKey(pinCode); + spendXprivkey = HDPrivateKey.fromString(spendXprivStr); + } + derivedKey = spendXprivkey.deriveNonCompliantChild(addressInfo.bip32AddressIndex); + } else { + // Use legacy key chain (m/44'/280'/0'/0) for regular addresses + derivedKey = xprivkey.deriveNonCompliantChild(addressInfo.bip32AddressIndex); + } + signatures.push({ inputIndex, addressIndex: addressInfo.bip32AddressIndex, - signature: this.getSignature(dataToSignHash, xpriv.privateKey), - pubkey: xpriv.publicKey.toDER(), + signature: this.getSignature(dataToSignHash, derivedKey.privateKey), + pubkey: derivedKey.publicKey.toDER(), }); } @@ -429,6 +502,8 @@ const transaction = { } for (const input of tx.inputs) { + // Shielded inputs don't have value/token/decoded fields + if (!input.decoded || input.token === undefined) continue; const { address } = input.decoded; if (!(address && (await storage.isAddressMine(address)))) { continue; @@ -437,15 +512,15 @@ const transaction = { balance[input.token] = getEmptyBalance(); } - if (this.isAuthorityOutput(input)) { - if (this.isMint(input)) { + if (this.isAuthorityOutput({ token_data: input.token_data! })) { + if (this.isMint({ value: input.value!, token_data: input.token_data! })) { balance[input.token].authorities.mint.unlocked -= 1n; } - if (this.isMelt(input)) { + if (this.isMelt({ value: input.value!, token_data: input.token_data! })) { balance[input.token].authorities.melt.unlocked -= 1n; } } else { - balance[input.token].tokens.unlocked -= input.value; + balance[input.token].tokens.unlocked -= input.value!; } } @@ -629,7 +704,46 @@ const transaction = { ); } if (options.version === DEFAULT_TX_VERSION) { - return new Transaction(inputs, outputs, options); + const tx = new Transaction(inputs, outputs, options); + + // Populate shielded outputs as a ShieldedOutputsHeader + if (txData.shieldedOutputs && txData.shieldedOutputs.length > 0) { + const shieldedModels = txData.shieldedOutputs.map(so => { + if (!so.commitment || !so.rangeProof || !so.script || !so.ephemeralPubkey) { + throw new Error( + 'Shielded output missing required crypto fields (commitment, rangeProof, script, ephemeralPubkey)' + ); + } + const tokenData = this.getTokenDataFromOutput( + { + type: 'p2pkh', + token: so.token, + value: so.value, + authorities: 0n, + address: so.address, + timelock: null, + }, + txData.tokens + ); + + return new ShieldedOutput( + so.mode, + so.commitment, + so.rangeProof, + tokenData, + Buffer.from(so.script, 'hex'), + so.ephemeralPubkey, + so.assetCommitment, + so.surjectionProof, + so.value + ); + }); + + tx.shieldedOutputs = shieldedModels; + tx.headers.push(new ShieldedOutputsHeader(shieldedModels)); + } + + return tx; } throw new ParseError('Invalid transaction version.'); }, @@ -922,6 +1036,8 @@ const transaction = { return hydratedInput as IHistoryInput; }); const outputs: IHistoryOutput[] = tx.outputs.map(o => { + // Shielded outputs already have token populated after decryption + if (this.isShieldedOutputEntry(o)) return o; const hydratedoutput = this.hydrateIOWithToken(o, tx.tokens); return hydratedoutput as IHistoryOutput; }); diff --git a/src/utils/wallet.ts b/src/utils/wallet.ts index d6321f870..8b5c2e7ef 100644 --- a/src/utils/wallet.ts +++ b/src/utils/wallet.ts @@ -13,6 +13,8 @@ import { HATHOR_BIP44_CODE, P2SH_ACCT_PATH, P2PKH_ACCT_PATH, + SHIELDED_SCAN_ACCT_PATH, + SHIELDED_SPEND_ACCT_PATH, WALLET_SERVICE_AUTH_DERIVATION_PATH, } from '../constants'; import { OP_0 } from '../opcodes'; @@ -619,6 +621,21 @@ const wallet = { accessData.acctPathKey = encryptedAcctPathKey; } + // Derive shielded scan and spend keys if root key is available. + // Scan (account 1') and spend (account 2') use separate derivation paths from legacy (account 0') + // so the scan key only grants view access, not spending authority over legacy funds. + if (argXpriv.depth === 0) { + const scanAcctXpriv = argXpriv.deriveNonCompliantChild(SHIELDED_SCAN_ACCT_PATH); + const scanXpriv = scanAcctXpriv.deriveNonCompliantChild(0); + accessData.scanXpubkey = scanXpriv.xpubkey; + accessData.scanMainKey = encryptData(scanXpriv.xprivkey, pin); + + const spendAcctXpriv = argXpriv.deriveNonCompliantChild(SHIELDED_SPEND_ACCT_PATH); + const spendXpriv2 = spendAcctXpriv.deriveNonCompliantChild(0); + accessData.spendXpubkey = spendXpriv2.xpubkey; + accessData.spendMainKey = encryptData(spendXpriv2.xprivkey, pin); + } + if (authXpriv || derivedAuthKey) { let authKey: IEncryptedData; if (authXpriv) { @@ -690,6 +707,13 @@ const wallet = { }; } + // Derive shielded scan (account 1') and spend (account 2') keys. + // Separate from legacy (account 0') so scan key only grants view access. + const scanAcctXpriv = rootXpriv.deriveNonCompliantChild(SHIELDED_SCAN_ACCT_PATH); + const scanXpriv = scanAcctXpriv.deriveNonCompliantChild(0); + const spendAcctXpriv = rootXpriv.deriveNonCompliantChild(SHIELDED_SPEND_ACCT_PATH); + const spendXpriv = spendAcctXpriv.deriveNonCompliantChild(0); + return { walletType, multisigData, @@ -699,6 +723,10 @@ const wallet = { authKey: encryptedAuthPathKey, words: encryptedWords, walletFlags: 0, + scanXpubkey: scanXpriv.xpubkey, + scanMainKey: encryptData(scanXpriv.xprivkey, pin), + spendXpubkey: spendXpriv.xpubkey, + spendMainKey: encryptData(spendXpriv.xprivkey, pin), }; }, @@ -739,6 +767,16 @@ const wallet = { data.acctPathKey = newEncryptedAcctPathKey; } + if (data.scanMainKey) { + const scanKey = decryptData(data.scanMainKey, oldPin); + data.scanMainKey = encryptData(scanKey, newPin); + } + + if (data.spendMainKey) { + const spendKey = decryptData(data.spendMainKey, oldPin); + data.spendMainKey = encryptData(spendKey, newPin); + } + return data; }, diff --git a/src/wallet/types.ts b/src/wallet/types.ts index 1dbb2a223..7962c7569 100644 --- a/src/wallet/types.ts +++ b/src/wallet/types.ts @@ -13,6 +13,7 @@ import { IHistoryTx, IDataTx, WalletAddressMode, + IAddressChainOptions, } from '../types'; import Transaction from '../models/transaction'; import CreateTokenTransaction from '../models/create_token_transaction'; @@ -359,12 +360,15 @@ export interface IHathorWallet { options: { token?: string; changeAddress?: string } ): Promise; stop(params?: IStopWalletParams): void; - getAddressAtIndex(index: number): Promise; + getAddressAtIndex(index: number, opts?: IAddressChainOptions): Promise; getAddressIndex(address: string): Promise; - getCurrentAddress(options?: { - markAsUsed: boolean; - }): AddressInfoObject | Promise; // FIXME: Should have a single return type - getNextAddress(): AddressInfoObject | Promise; // FIXME: Should have a single return type; + getCurrentAddress( + options?: { + markAsUsed: boolean; + }, + opts?: IAddressChainOptions + ): AddressInfoObject | Promise; // FIXME: Should have a single return type + getNextAddress(opts?: IAddressChainOptions): AddressInfoObject | Promise; // FIXME: Should have a single return type; getAddressPrivKey(pinCode: string, addressIndex: number): Promise; signMessageWithAddress(message: string, index: number, pinCode: string): Promise; prepareCreateNewToken( @@ -723,6 +727,8 @@ export interface FullNodeOutput { decoded: FullNodeDecodedOutput; token?: string | null; spent_by?: string | null; + // Shielded entries appended by fullnode's to_json_extended() have type='shielded' + type?: string; } export interface FullNodeTx { diff --git a/src/wallet/wallet.ts b/src/wallet/wallet.ts index 413318ecd..b7df3919d 100644 --- a/src/wallet/wallet.ts +++ b/src/wallet/wallet.ts @@ -748,7 +748,9 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { } for (const txin of tx.inputs) { - if (transaction.isAuthorityOutput(txin)) { + // Shielded inputs don't have value/token/token_data fields + if (txin.token === undefined) continue; + if (transaction.isAuthorityOutput({ token_data: txin.token_data! })) { if (options.includeAuthorities) { if (!balance[txin.token]) { balance[txin.token] = 0n; @@ -760,7 +762,7 @@ class HathorWalletServiceWallet extends EventEmitter implements IHathorWallet { if (!balance[txin.token]) { balance[txin.token] = 0n; } - balance[txin.token] -= txin.value; + balance[txin.token] -= txin.value!; } } diff --git a/src/wallet/walletServiceStorageProxy.ts b/src/wallet/walletServiceStorageProxy.ts index 0966a5506..6beb90560 100644 --- a/src/wallet/walletServiceStorageProxy.ts +++ b/src/wallet/walletServiceStorageProxy.ts @@ -189,13 +189,16 @@ export class WalletServiceStorageProxy { type: input.decoded.type ?? undefined, }, })) as IHistoryTx['inputs'], - outputs: tx.outputs.map(output => ({ - ...output, - decoded: { - ...output.decoded, - type: output.decoded.type ?? undefined, - }, - })) as IHistoryTx['outputs'], + outputs: tx.outputs.map(output => { + if (transactionUtils.isShieldedOutputEntry(output)) return output; + return { + ...output, + decoded: { + ...output.decoded, + type: output.decoded.type ?? undefined, + }, + }; + }) as IHistoryTx['outputs'], parents: tx.parents, tokens: tx.tokens.map(token => token.uid), height: meta.height,