Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/silver-pets-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@xchainjs/xchain-utxo-providers': minor
'@xchainjs/xchain-zcash': minor
---

add zcash ledger client
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"word-wrap": "1.2.4",
"undici": "5.29.0",
"form-data": "4.0.4",
"elliptic": "^6.6.1"
"elliptic": "^6.6.1",
"tiny-secp256k1": "^2.2.4"
},
"devDependencies": {
"@actions/core": "1.10.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,24 @@ export class NownodesProvider implements UtxoOnlineDataProvider {
}

private async mapUTXOs(utxos: AddressUTXO[]): Promise<UTXO[]> {
return utxos.flatMap((currentUtxo) => {
return [
{
hash: currentUtxo.txid,
index: currentUtxo.vout,
value: Number(currentUtxo.value),
},
]
})
const result: UTXO[] = []

for (const { txid, vout, value } of utxos) {
const rawTx = await nownodes.getTx({
apiKey: this._apiKey,
baseUrl: this.baseUrl,
hash: txid,
})

result.push({
txHex: rawTx.hex,
hash: txid,
index: vout,
value: Number(value),
})
}

return result
}

private async getRawTransactions(params?: TxHistoryParams): Promise<Transaction[]> {
Expand Down
3 changes: 2 additions & 1 deletion packages/xchain-zcash/__e2e__/client.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ describe('Zcash client', () => {
})

it('Should get address', async () => {
console.log(await client.getAddressAsync(1))
const address = await client.getAddressAsync(0)
console.log('address', address)
})

it('Should get balance', async () => {
Expand Down
64 changes: 64 additions & 0 deletions packages/xchain-zcash/__e2e__/clientLedger.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import TransportNodeHid from '@ledgerhq/hw-transport-node-hid'
import { assetAmount, assetToBase } from '@xchainjs/xchain-util'

import { ClientLedger, defaultZECParams } from '../src'

describe('Zcash Ledger Client', () => {
let client: ClientLedger

beforeAll(async () => {
const transport = await TransportNodeHid.create()

client = new ClientLedger({
...defaultZECParams,
transport,
})
})

it('Should get address', async () => {
const address = await client.getAddressAsync(0)
console.log('Address:', address)
expect(address).toBeDefined()
expect(typeof address).toBe('string')
})

it('Should throw error for sync getAddress', () => {
expect(() => client.getAddress()).toThrow('Sync method not supported for Ledger')
})

it('Should get balance', async () => {
const address = await client.getAddressAsync(0)
const balances = await client.getBalance(address)
console.log('Balance', balances[0].amount.amount().toString())
console.log(balances[0].asset)

expect(balances).toBeDefined()
expect(Array.isArray(balances)).toBe(true)
expect(balances.length).toBeGreaterThan(0)
})

it('Should transfer TX without memo', async () => {
const address = await client.getAddressAsync(1)
const txHash = await client.transfer({
walletIndex: 0,
amount: assetToBase(assetAmount('0.1', 8)),
recipient: address,
})
console.log('txHash', txHash)
expect(txHash).toBeDefined()
expect(typeof txHash).toBe('string')
})

it.skip('Should transfer TX with memo', async () => {
const address = await client.getAddressAsync(1)
const txHash = await client.transfer({
walletIndex: 0,
amount: assetToBase(assetAmount('0.1', 8)),
recipient: address,
memo: 'test',
})
console.log('txHash', txHash)
expect(txHash).toBeDefined()
expect(typeof txHash).toBe('string')
})
})
5 changes: 4 additions & 1 deletion packages/xchain-zcash/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,17 @@
"postversion": "git push --follow-tags"
},
"dependencies": {
"@bitcoin-js/tiny-secp256k1-asmjs": "^2.2.3",
"@bitgo/utxo-lib": "^11.11.0",
"@ledgerhq/hw-app-btc": "^10.11.2",
"@ledgerhq/hw-transport": "^6.31.12",
"@mayaprotocol/zcash-js": "1.0.7",
"@scure/bip32": "^1.7.0",
"@xchainjs/xchain-client": "workspace:*",
"@xchainjs/xchain-crypto": "workspace:*",
"@xchainjs/xchain-util": "workspace:*",
"@xchainjs/xchain-utxo": "workspace:*",
"@xchainjs/xchain-utxo-providers": "workspace:*",
"coinselect": "3.1.12",
"ecpair": "2.1.0"
},
"publishConfig": {
Expand Down
192 changes: 181 additions & 11 deletions packages/xchain-zcash/src/clientLedger.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,199 @@
import { TxHash } from '@xchainjs/xchain-client'
import { TxHash, checkFeeBounds, FeeRate, FeeOption } from '@xchainjs/xchain-client'
import { Address } from '@xchainjs/xchain-util'
import { UtxoClientParams } from '@xchainjs/xchain-utxo'
import { UtxoClientParams, TxParams, UTXO } from '@xchainjs/xchain-utxo'
import type Transport from '@ledgerhq/hw-transport'
import UtxoApp from '@ledgerhq/hw-app-btc'
import type { Transaction as LedgerTransaction } from '@ledgerhq/hw-app-btc/lib/types'
import { bitgo, networks, address as zcashAddress } from '@bitgo/utxo-lib'
import type { ZcashPsbt } from '@bitgo/utxo-lib/dist/src/bitgo'

import accumulative from 'coinselect/accumulative.js'

import { Client } from './client'

export type UtxoLedgerClientParams = UtxoClientParams & { transport: Transport }

class ClientLedger extends Client {
constructor(params: UtxoClientParams) {
private transport: Transport
private ledgerApp: UtxoApp | undefined

constructor(params: UtxoLedgerClientParams) {
super(params)
throw Error('Ledger client not supported for Zcash.')
this.transport = params.transport
}

public createLedgerTransport(): UtxoApp {
this.ledgerApp = new UtxoApp({ currency: 'zcash', transport: this.transport })
return this.ledgerApp
}

public async getApp() {
throw Error('Not implemented.')
public getLedgerApp(): UtxoApp {
if (this.ledgerApp) return this.ledgerApp
return this.createLedgerTransport()
}

// Not supported for ledger client
getAddress(): string {
throw Error('Not implemented.')
throw Error('Sync method not supported for Ledger')
}

async getAddressAsync(index = 0): Promise<Address> {
const ledgerApp = this.getLedgerApp()
const derivationPath = this.getFullDerivationPath(index)
const { bitcoinAddress: address } = await ledgerApp.getWalletPublicKey(derivationPath, { format: 'legacy' })
return address
}

async getAddressAsync(): Promise<Address> {
throw Error('Not implemented.')
async buildTx({
amount,
recipient,
memo,
feeRate,
sender,
}: TxParams & { sender: string; feeRate: FeeRate }): Promise<{ psbt: ZcashPsbt; utxos: UTXO[]; inputs: UTXO[] }> {
// Check memo length
if (memo && memo.length > 80) {
throw new Error('memo too long, must not be longer than 80 chars.')
}
// This section of the code is responsible for preparing a transaction by building a Bitcoin PSBT (Partially Signed Bitcoin Transaction).
if (!this.validateAddress(recipient)) throw new Error('Invalid address')
// Determine whether to only use confirmed UTXOs or include pending UTXOs based on the spendPendingUTXO flag.

// Scan UTXOs associated with the sender's address.
const utxos = await this.scanUTXOs(sender, true)
// Throw an error if there are no available UTXOs to cover the transaction.
if (utxos.length === 0) throw new Error('Insufficient Balance for transaction')
// Round up the fee rate to the nearest integer.
const feeRateWhole = Math.ceil(feeRate)
// Compile the memo into a Buffer if provided.
const compiledMemo = memo ? this.compileMemo(memo) : null
// Initialize an array to store the target outputs of the transaction.
const targetOutputs = []

// 1. Add the recipient address and amount to the target outputs.
targetOutputs.push({
address: recipient,
value: amount.amount().toNumber(),
})
// 2. Add the compiled memo to the target outputs if it exists.
if (compiledMemo) {
targetOutputs.push({ script: compiledMemo, value: 0 })
}

// Use the coinselect library to determine the inputs and outputs for the transaction.
const { inputs, outputs } = accumulative(utxos, targetOutputs, feeRateWhole)
// If no suitable inputs or outputs are found, throw an error indicating insufficient balance.
if (!inputs || !outputs) throw new Error('Insufficient Balance for transaction')

const psbt = bitgo.createPsbtForNetwork({ network: networks.zcash }, { version: 455 }) as ZcashPsbt

const NU5 = 0xc2d6d0b4
const branchId = NU5
const CONSENSUS_BRANCH_ID_KEY = Buffer.concat([
Buffer.of(0xfc),
Buffer.of(0x05),
Buffer.from('BITGO'),
Buffer.of(0),
])

// PSBT value must be 4-byte little-endian
const value = Buffer.allocUnsafe(4)
value.writeUInt32LE(branchId, 0)

psbt.addUnknownKeyValToGlobal({ key: CONSENSUS_BRANCH_ID_KEY, value })

// add inputs and outputs
for (const utxo of inputs) {
const witnessInfo = !!utxo.witnessUtxo && { witnessUtxo: { ...utxo.witnessUtxo, value: BigInt(utxo.value) } }

const nonWitnessInfo = !utxo.witnessUtxo && {
nonWitnessUtxo: utxo.txHex ? Buffer.from(utxo.txHex, 'hex') : undefined,
}

const input = { hash: utxo.hash, index: utxo.index, ...witnessInfo, ...nonWitnessInfo }
psbt.addInput(input)
}

for (const output of outputs) {
const address = 'address' in output && output.address ? output.address : sender
const hasOutputScript = output.script

if (hasOutputScript && !compiledMemo) {
continue
}

const mappedOutput = hasOutputScript
? { script: compiledMemo as Buffer<ArrayBufferLike>, value: BigInt(0) }
: { script: zcashAddress.toOutputScript(address, networks.zcash), value: BigInt(output.value) }

psbt.addOutput(mappedOutput)
}

return {
psbt,
utxos,
inputs,
}
}

async transfer(): Promise<TxHash> {
throw Error('Not implemented.')
/**
* Transfer ZEC using Ledger.
* @param {TxParams&FeeRate} params The transfer options including the fee rate.
* @returns {Promise<TxHash|string>} A promise that resolves to the transaction hash or an error message.
* @throws {"memo too long"} Thrown if the memo is longer than 80 characters.
*/
async transfer(params: TxParams & { feeRate?: FeeRate }): Promise<TxHash> {
const ledgerApp = this.getLedgerApp()

// Get fee rate
const feeRate = params.feeRate || (await this.getFees())[FeeOption.Fast].amount().toNumber()
// Check if the fee rate is within the fee bounds
checkFeeBounds(this.feeBounds, feeRate)

// Get the address index from the parameters or use the default value
const fromAddressIndex = params?.walletIndex || 0
const sender = await this.getAddressAsync(fromAddressIndex)

// Prepare psbt
const { psbt, inputs } = await this.buildTx({ ...params, sender, feeRate })
// Prepare Ledger inputs
const ledgerInputs: [LedgerTransaction, number, string | null, number | null][] = (inputs as UTXO[]).map(
({ txHex, index }) => {
const splittedTx = ledgerApp.splitTransaction(
txHex || '',
false, // Zcash doesn't support segwit
true, // set hasExtraData: true
['zcash'],
)
return [splittedTx, index, null, null]
},
)

const expiryHeight = Buffer.alloc(4)
expiryHeight.writeUInt32LE(0)

// Prepare associated keysets
const associatedKeysets = ledgerInputs.map(() => this.getFullDerivationPath(fromAddressIndex))
// Serialize unsigned transaction
const unsignedHex = psbt.data.globalMap.unsignedTx.toBuffer().toString('hex')

const newTx = ledgerApp.splitTransaction(unsignedHex, false, true, ['zcash'])
const outputScriptHex = ledgerApp.serializeTransactionOutputs(newTx).toString('hex')

// Create payment transaction
const txHex = await ledgerApp.createPaymentTransaction({
inputs: ledgerInputs,
associatedKeysets,
outputScriptHex,
segwit: false,
useTrustedInputForSegwit: false,
lockTime: 0,
expiryHeight,
additionals: ['zcash'],
})

const txId = await this.roundRobinBroadcastTx(txHex)
return txId
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/xchain-zcash/src/modules.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// This statement declares a module augmentation for the external module 'coinselect/accumulative.js'.
declare module 'coinselect/accumulative.js'
Loading