diff --git a/.changeset/green-peaches-thank.md b/.changeset/green-peaches-thank.md new file mode 100644 index 000000000..51b869463 --- /dev/null +++ b/.changeset/green-peaches-thank.md @@ -0,0 +1,5 @@ +--- +'@xchainjs/xchain-zcash': patch +--- + +adding client ledger diff --git a/packages/xchain-zcash/__e2e__/client.e2e.ts b/packages/xchain-zcash/__e2e__/client.e2e.ts index 9d7a26cfd..4b4880c1b 100644 --- a/packages/xchain-zcash/__e2e__/client.e2e.ts +++ b/packages/xchain-zcash/__e2e__/client.e2e.ts @@ -27,7 +27,7 @@ describe('Zcash client', () => { const hash = await client.transfer({ walletIndex: 0, amount: assetToBase(assetAmount('0.1', 8)), - recipient: address + recipient: address, }) console.log('hash', hash) }) @@ -37,7 +37,7 @@ describe('Zcash client', () => { const hash = await client.transfer({ amount: assetToBase(assetAmount('0.1', 8)), recipient: address, - memo: 'test' + memo: 'test', }) console.log('hash', hash) }) diff --git a/packages/xchain-zcash/__e2e__/zcash-ledger-client.e2e.ts b/packages/xchain-zcash/__e2e__/zcash-ledger-client.e2e.ts new file mode 100644 index 000000000..79a31652c --- /dev/null +++ b/packages/xchain-zcash/__e2e__/zcash-ledger-client.e2e.ts @@ -0,0 +1,71 @@ +import TransportNodeHid from '@ledgerhq/hw-transport-node-hid' +import { Network } from '@xchainjs/xchain-client' +import { assetAmount, assetToBase } from '@xchainjs/xchain-util' +import { UtxoClientParams } from '@xchainjs/xchain-utxo' + +import { ClientLedger } from '../src/clientLedger' +import { AssetZEC, LOWER_FEE_BOUND, NownodesProviders, UPPER_FEE_BOUND, zcashExplorerProviders } from '../src/const' + +jest.setTimeout(200000) + +const defaultZECParams: UtxoClientParams = { + network: Network.Mainnet, + phrase: '', + explorerProviders: zcashExplorerProviders, + dataProviders: [NownodesProviders], + rootDerivationPaths: { + [Network.Mainnet]: `m/44'/133'/0'/0/`, + [Network.Testnet]: `m/44'/1'/0'/0/`, + [Network.Stagenet]: `m/44'/133'/0'/0/`, + }, + feeBounds: { + lower: LOWER_FEE_BOUND, + upper: UPPER_FEE_BOUND, + }, +} + +describe('Zcash Client Ledger', () => { + let zcashClient: ClientLedger + beforeAll(async () => { + const transport = await TransportNodeHid.create() + + zcashClient = new ClientLedger({ + transport, + ...defaultZECParams, + }) + }) + it('get address async without verification', async () => { + const address = await zcashClient.getAddressAsync() + console.log('address', address) + expect(address).toMatch(/^t/) // Transparent addresses start with t + }) + + it('get address async with verification', async () => { + const address = await zcashClient.getAddressAsync(0, true) + console.log('address', address) + expect(address).toMatch(/^t/) // Transparent addresses start with t + }) + + it('get balance', async () => { + const address = await zcashClient.getAddressAsync() + const balance = await zcashClient.getBalance(address) + console.log('balance', balance[0].amount.amount().toString()) + }) + + it('transfer', async () => { + try { + const to = await zcashClient.getAddressAsync(1) + const amount = assetToBase(assetAmount('0.00002')) + const txid = await zcashClient.transfer({ + asset: AssetZEC, + recipient: to, + amount, + memo: 'test', + }) + console.log(JSON.stringify(txid, null, 2)) + } catch (err) { + console.error('ERR running test', err) + fail() + } + }) +}) diff --git a/packages/xchain-zcash/__tests__/clientLedger.test.ts b/packages/xchain-zcash/__tests__/clientLedger.test.ts new file mode 100644 index 000000000..5e4e4dc2f --- /dev/null +++ b/packages/xchain-zcash/__tests__/clientLedger.test.ts @@ -0,0 +1,99 @@ +import { ClientLedger, defaultZECParams } from '../src' + +// Mock transport for testing +const mockTransport = { + exchange: jest.fn(), + setExchangeTimeout: jest.fn(), + close: jest.fn(), +} + +describe('Zcash Ledger Client', () => { + let client: ClientLedger + + beforeEach(() => { + client = new ClientLedger({ + ...defaultZECParams, + transport: mockTransport, + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('Constructor', () => { + it('should create a ClientLedger instance', () => { + expect(client).toBeInstanceOf(ClientLedger) + }) + + it('should throw error for sync getAddress method', () => { + expect(() => client.getAddress()).toThrow('Sync method not supported for Ledger') + }) + }) + + describe('Address Operations', () => { + it('should have getApp method', async () => { + expect(client.getApp).toBeDefined() + expect(typeof client.getApp).toBe('function') + }) + + it('should have getAddressAsync method', async () => { + expect(client.getAddressAsync).toBeDefined() + expect(typeof client.getAddressAsync).toBe('function') + }) + }) + + describe('Transaction Operations', () => { + it('should have transfer method', () => { + expect(client.transfer).toBeDefined() + expect(typeof client.transfer).toBe('function') + }) + + it('should throw error when transport is not properly configured', async () => { + const mockAmount = { + amount: () => ({ toNumber: () => 1000000 }), + } + + const transferParams = { + recipient: 't1d4ZFodUN3sJz1zL6SfKSV6kmkwYm8N5s9', + amount: mockAmount, + memo: 'test', + } + + // Since we're using a mock transport, it should fail at the transport level + await expect(client.transfer(transferParams as Parameters[0])).rejects.toThrow( + 'this.transport.send is not a function', + ) + }) + }) + + describe('Network and Asset Info', () => { + it('should inherit getAssetInfo from base client', () => { + const assetInfo = client.getAssetInfo() + expect(assetInfo).toBeDefined() + expect(assetInfo.asset).toBeDefined() + expect(assetInfo.decimal).toBeDefined() + }) + + it('should inherit validateAddress from base client', () => { + // Test with a valid Zcash testnet address format + const isValid = client.validateAddress('t1d4ZFodUN3sJz1zL6SfKSV6kmkwYm8N5s9') + expect(typeof isValid).toBe('boolean') + }) + }) + + describe('Fee Operations', () => { + it('should inherit fee methods from base client', async () => { + expect(client.getFees).toBeDefined() + expect(typeof client.getFees).toBe('function') + }) + + it('should throw error for fee rates (Zcash uses flat fees)', async () => { + await expect(client.getFeeRates()).rejects.toThrow('Error Zcash has flat fee. Fee rates not supported') + }) + + it('should throw error for fees with rates', async () => { + await expect(client.getFeesWithRates()).rejects.toThrow('Error Zcash has flat fee. Fee rates not supported') + }) + }) +}) diff --git a/packages/xchain-zcash/jest.config.e2e.mjs b/packages/xchain-zcash/jest.config.e2e.mjs index 869267ab9..cd70bfabb 100644 --- a/packages/xchain-zcash/jest.config.e2e.mjs +++ b/packages/xchain-zcash/jest.config.e2e.mjs @@ -4,4 +4,4 @@ export default { testPathIgnorePatterns: ['/node_modules', '/lib'], testMatch: ['/__e2e__/**/*.[jt]s?(x)'], testTimeout: 60000, -} +} \ No newline at end of file diff --git a/packages/xchain-zcash/package.json b/packages/xchain-zcash/package.json index 19f7c0fe7..b14bf23a8 100644 --- a/packages/xchain-zcash/package.json +++ b/packages/xchain-zcash/package.json @@ -48,6 +48,7 @@ "directory": "release/package" }, "devDependencies": { + "@ledgerhq/hw-transport-node-hid": "^6.28.6", "@types/blake2b-wasm": "^2.4.3", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.13", diff --git a/packages/xchain-zcash/src/clientLedger.ts b/packages/xchain-zcash/src/clientLedger.ts index 6476e03c2..557866673 100644 --- a/packages/xchain-zcash/src/clientLedger.ts +++ b/packages/xchain-zcash/src/clientLedger.ts @@ -1,29 +1,103 @@ -import { TxHash } from '@xchainjs/xchain-client' +import AppBtc from '@ledgerhq/hw-app-btc' +import { buildTx } from '@mayaprotocol/zcash-js' +import { TxHash, checkFeeBounds } from '@xchainjs/xchain-client' import { Address } from '@xchainjs/xchain-util' -import { UtxoClientParams } from '@xchainjs/xchain-utxo' - +import { TxParams, UtxoClientParams } from '@xchainjs/xchain-utxo' import { Client } from './client' +/** + * Custom Ledger Zcash client + */ class ClientLedger extends Client { - constructor(params: UtxoClientParams) { + // Reference to the Ledger transport object + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private transport: any + private app: AppBtc | undefined + + // Constructor + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(params: UtxoClientParams & { transport: any }) { super(params) - throw Error('Ledger client not supported for Zcash.') + this.transport = params.transport } - public async getApp() { - throw Error('Not implemented.') + // Get the Ledger BTC application instance configured for Zcash + public async getApp(): Promise { + if (this.app) { + return this.app + } + this.app = new AppBtc({ transport: this.transport, currency: 'zcash' }) + return this.app } + // Get the current address synchronously getAddress(): string { - throw Error('Not implemented.') + throw Error('Sync method not supported for Ledger') } - async getAddressAsync(): Promise
{ - throw Error('Not implemented.') + // Get the current address asynchronously + async getAddressAsync(index = 0, verify = false): Promise
{ + const app = await this.getApp() + const result = await app.getWalletPublicKey(this.getFullDerivationPath(index), { + format: 'legacy', + verify, + }) + return result.bitcoinAddress } - async transfer(): Promise { - throw Error('Not implemented.') + // Transfer ZEC from Ledger + async transfer(params: TxParams): Promise { + const fromAddressIndex = params?.walletIndex || 0 + + // Get sender address + const sender = await this.getAddressAsync(fromAddressIndex) + + // Prepare transaction using base client method (handles flat fee) + const { rawUnsignedTx } = await this.prepareTx({ + ...params, + sender, + feeRate: 0, // Ignored for Zcash + }) + + // Parse the transaction data + const txData = JSON.parse(rawUnsignedTx) + + // Build the actual transaction for signing + const tx = await buildTx( + txData.height, + txData.from, + txData.to, + txData.amount, + txData.utxos, + txData.isMainnet, + txData.memo, + ) + + // Check fee bounds (already done in prepareTx but double-check) + checkFeeBounds(this.feeBounds, tx.fee) + + // LIMITATION: Zcash Ledger transaction signing requires raw transaction hex data + // for previous transactions (UTXOs), but Zcash data providers only return + // parsed transaction objects without the raw hex. + // + // This is different from Bitcoin where the prepareTx method fetches and includes + // the raw transaction hex (txHex field) for each UTXO. + // + // To fully implement Zcash Ledger signing, we would need: + // 1. A Zcash data provider that returns raw transaction hex + // 2. Or use the dedicated Zcash Ledger app instead of Bitcoin app + // 3. Or implement a custom serialization from the transaction object + + throw new Error( + 'Zcash Ledger transfers require raw transaction data that is not available from current data providers. ' + + 'The transaction has been built successfully with fee: ' + + tx.fee + + ' zatoshis. ' + + 'To complete Ledger signing, either:\n' + + '1. Use the keystore client for transfers, or\n' + + '2. Use the dedicated Zcash Ledger app, or\n' + + '3. Implement a data provider that returns raw transaction hex', + ) } } diff --git a/yarn.lock b/yarn.lock index 43807c1c0..051104aba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4428,6 +4428,7 @@ __metadata: resolution: "@xchainjs/xchain-zcash@workspace:packages/xchain-zcash" dependencies: "@bitcoin-js/tiny-secp256k1-asmjs": "npm:^2.2.3" + "@ledgerhq/hw-transport-node-hid": "npm:^6.28.6" "@mayaprotocol/zcash-js": "npm:1.0.7" "@scure/bip32": "npm:^1.7.0" "@types/blake2b-wasm": "npm:^2.4.3"