UNPKG

@ledgerhq/coin-tezos

Version:
577 lines (509 loc) 17.8 kB
// SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import type { TezosCoinConfig, TezosContext } from '../config' import { OpKind } from '@taquito/rpc' import { getRevealFee } from '@taquito/taquito' import { craftTransaction, rawEncode } from './craftTransaction' import { getTezosToolkit } from './tezosToolkit' type TransactionType = | 'send' | 'delegate' | 'undelegate' | 'send_token' | 'stake' | 'unstake' | 'finalize_unstake' jest.mock('./tezosToolkit') function setupFa2ContractMock(parameter: { entrypoint: string; value: unknown }) { const toTransferParams = jest.fn().mockReturnValue({ parameter, amount: 0, mutez: true, }) const transfer = jest.fn().mockReturnValue({ toTransferParams }) return { transfer, toTransferParams } } describe('craftTransaction', () => { const mockTezosToolkit = { rpc: { getContract: jest.fn(), getBlock: jest.fn(), forgeOperations: jest.fn(), }, estimate: { reveal: jest.fn(), }, contract: { at: jest.fn(), }, setProvider: jest.fn(), } let currentConfig: TezosCoinConfig const context: TezosContext = { config: async () => currentConfig, logger: () => undefined, } beforeEach(() => { jest.clearAllMocks() ;(getTezosToolkit as jest.Mock).mockReturnValue(mockTezosToolkit) currentConfig = {} as unknown as TezosCoinConfig }) it('should craft a send transaction', async () => { mockTezosToolkit.rpc.getContract.mockResolvedValue({ counter: '1' }) const account = { address: 'tz1...' } const transaction = { type: 'send' as TransactionType, recipient: 'tz2...', amount: BigInt(1000), fee: { fees: '100', gasLimit: '200', storageLimit: '300' }, } const result = await craftTransaction(context, account, transaction) expect(result.type).toBe('OUT') expect(result.contents).toEqual([ { kind: OpKind.TRANSACTION, amount: '1000', destination: transaction.recipient, source: account.address, counter: '2', fee: '100', gas_limit: '200', storage_limit: '300', }, ]) }) it('should craft a delegate transaction', async () => { mockTezosToolkit.rpc.getContract.mockResolvedValue({ counter: '1' }) const account = { address: 'tz1...' } const transaction = { type: 'delegate' as TransactionType, recipient: 'tz2...', amount: BigInt(0), fee: { fees: '100', gasLimit: '200', storageLimit: '300' }, } const result = await craftTransaction(context, account, transaction) expect(result.type).toBe('DELEGATE') expect(result.contents).toEqual([ { kind: OpKind.DELEGATION, source: account.address, counter: '2', delegate: transaction.recipient, fee: '100', gas_limit: '200', storage_limit: '300', }, ]) }) it('should craft an undelegate transaction', async () => { mockTezosToolkit.rpc.getContract.mockResolvedValue({ counter: '1' }) const account = { address: 'tz1...' } const transaction = { type: 'undelegate' as TransactionType, recipient: '', amount: BigInt(0), fee: { fees: '100', gasLimit: '200', storageLimit: '300' }, } const result = await craftTransaction(context, account, transaction) expect(result.type).toBe('UNDELEGATE') expect(result.contents).toEqual([ { kind: OpKind.DELEGATION, source: account.address, counter: '2', fee: '100', gas_limit: '200', storage_limit: '300', }, ]) }) it('should craft a transaction with reveal operation', async () => { mockTezosToolkit.rpc.getContract.mockResolvedValue({ counter: '1' }) mockTezosToolkit.estimate.reveal.mockResolvedValue({ gasLimit: 100, storageLimit: 0, suggestedFeeMutez: getRevealFee('tz1...'), }) currentConfig = { fees: { minRevealGasLimit: 100, minFees: 0, minStorageLimit: 0, }, } as unknown as TezosCoinConfig const account = { address: 'tz1...' } const transaction = { type: 'send' as TransactionType, recipient: 'tz2...', amount: BigInt(1000), fee: { fees: '100', gasLimit: '200', storageLimit: '300' }, } const publicKey = { publicKey: 'publicKey', publicKeyHash: 'publicKeyHash' } const result = await craftTransaction(context, account, transaction, publicKey) expect(result.contents).toEqual([ { kind: OpKind.REVEAL, fee: getRevealFee(account.address).toString(), gas_limit: '100', storage_limit: '0', source: publicKey.publicKeyHash, counter: '2', public_key: publicKey.publicKey, }, { kind: OpKind.TRANSACTION, amount: '1000', destination: transaction.recipient, source: account.address, counter: '3', fee: '100', gas_limit: '200', storage_limit: '300', }, ]) }) describe('when suggested fee is above or below minFees floor', () => { it('craft transaction when default estimation is greater than minFees', async () => { mockTezosToolkit.rpc.getContract.mockResolvedValue({ counter: '1' }) const suggestedRevealFee = 800 mockTezosToolkit.estimate.reveal.mockResolvedValue({ gasLimit: 300, storageLimit: 0, suggestedFeeMutez: suggestedRevealFee, }) const minFees = 500 currentConfig = { fees: { minRevealGasLimit: 300, minFees, minStorageLimit: 0, }, } as unknown as TezosCoinConfig const account = { address: 'tz2...' } const mainOpFee = 600 const transaction = { type: 'delegate' as TransactionType, recipient: 'tz3...', amount: BigInt(0), fee: { fees: String(mainOpFee), gasLimit: '10000', storageLimit: '0' }, } const publicKey = { publicKey: 'pk', publicKeyHash: 'pkh' } const result = await craftTransaction(context, account, transaction, publicKey) const revealOp = result.contents[0] expect(revealOp.kind).toBe(OpKind.REVEAL) expect(Number(revealOp.fee ?? 0)).toBe(suggestedRevealFee) const delegationOp = result.contents[1] expect(Number(delegationOp.fee ?? 0)).toBe(mainOpFee) }) it('craft transaction when default estimation is lesser than minFees', async () => { mockTezosToolkit.rpc.getContract.mockResolvedValue({ counter: '1' }) mockTezosToolkit.estimate.reveal.mockResolvedValue({ gasLimit: 50, storageLimit: 0, suggestedFeeMutez: 100, }) const minFees = 500 currentConfig = { fees: { minRevealGasLimit: 300, minFees, minStorageLimit: 25, }, } as unknown as TezosCoinConfig const account = { address: 'tz2...' } const transaction = { type: 'delegate' as TransactionType, recipient: 'tz3...', amount: BigInt(0), fee: { fees: '200', gasLimit: '1000', storageLimit: '0' }, } const publicKey = { publicKey: 'pk', publicKeyHash: 'pkh' } const result = await craftTransaction(context, account, transaction, publicKey) const revealOp = result.contents[0] expect(revealOp.kind).toBe(OpKind.REVEAL) expect(revealOp.fee).toBe(String(minFees)) expect(revealOp.gas_limit).toBe('300') expect(revealOp.storage_limit).toBe('25') }) it('craft transaction with customFee splits total so reveal and main op respect minFees', async () => { mockTezosToolkit.rpc.getContract.mockResolvedValue({ counter: '1' }) mockTezosToolkit.estimate.reveal.mockResolvedValue({ gasLimit: 300, storageLimit: 0, suggestedFeeMutez: 100, }) const minFees = 1000 currentConfig = { fees: { minRevealGasLimit: 300, minFees, minStorageLimit: 0, }, } as unknown as TezosCoinConfig const account = { address: 'tz2TaTpo31sAiX2HBJUTLLdUnqVJR4QjLy1V' } const mainOpFee = 1000 const transaction = { type: 'delegate' as TransactionType, recipient: 'tz3Vq38qYD3GEbWcXHMLt5PaASZrkDtEiA8D', amount: BigInt(0), fee: { fees: String(mainOpFee), gasLimit: '10000', storageLimit: '0' }, } const publicKey = { publicKey: 'pk', publicKeyHash: account.address } const result = await craftTransaction(context, account, transaction, publicKey) expect(result.contents).toHaveLength(2) const revealFee = Number(result.contents[0].fee ?? 0) const delegationFee = Number(result.contents[1].fee ?? 0) const total = revealFee + delegationFee expect(revealFee).toBe(minFees) expect(delegationFee).toBe(mainOpFee) expect(total).toBe(minFees + mainOpFee) }) }) it('should craft an FA2 send_token transaction', async () => { const fa2Param = { entrypoint: 'transfer', value: { prim: 'Pair' as const } } const { transfer } = setupFa2ContractMock(fa2Param) mockTezosToolkit.contract.at.mockResolvedValue({ methods: { transfer } }) mockTezosToolkit.rpc.getContract.mockResolvedValue({ counter: '1' }) const contractAddress = 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU' const account = { address: 'tz1VSUr8wwNhLAzempoch5d6nLRSNtxK8LBr' } const transaction = { type: 'send_token' as TransactionType, recipient: 'tz1VSUr8wwNhLAzempoch5d6nLRSNtxK8LBr', amount: BigInt(5), fee: { fees: '100', gasLimit: '200', storageLimit: '300' }, contractAddress, tokenId: 0, } const result = await craftTransaction(context, account, transaction) expect(result.type).toBe('OUT') expect(mockTezosToolkit.contract.at).toHaveBeenCalledWith(contractAddress) expect(transfer).toHaveBeenCalledWith([ { from_: account.address, txs: [{ to_: transaction.recipient, token_id: 0, amount: 5n }], }, ]) expect(result.contents).toEqual([ { kind: OpKind.TRANSACTION, source: account.address, destination: contractAddress, amount: '0', counter: '2', fee: '100', gas_limit: '200', storage_limit: '300', parameters: fa2Param, }, ]) }) it('should craft FA2 send_token with reveal when publicKey is provided', async () => { const fa2Param = { entrypoint: 'transfer', value: [] } const { transfer } = setupFa2ContractMock(fa2Param) mockTezosToolkit.contract.at.mockResolvedValue({ methods: { transfer } }) mockTezosToolkit.rpc.getContract.mockResolvedValue({ counter: '1' }) mockTezosToolkit.estimate.reveal.mockResolvedValue({ gasLimit: 100, storageLimit: 0, suggestedFeeMutez: getRevealFee('tz1...'), }) currentConfig = { fees: { minRevealGasLimit: 100, minFees: 0, minStorageLimit: 0, }, } as unknown as TezosCoinConfig const contractAddress = 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU' const account = { address: 'tz1...' } const transaction = { type: 'send_token' as TransactionType, recipient: 'tz2...', amount: BigInt(1), fee: { fees: '100', gasLimit: '200', storageLimit: '300' }, contractAddress, tokenId: 7, } const publicKey = { publicKey: 'publicKey', publicKeyHash: 'publicKeyHash' } const result = await craftTransaction(context, account, transaction, publicKey) expect(result.contents).toHaveLength(2) expect(result.contents[0].kind).toBe(OpKind.REVEAL) expect(result.contents[1]).toMatchObject({ kind: OpKind.TRANSACTION, destination: contractAddress, amount: '0', counter: '3', parameters: fa2Param, }) }) it('should throw when send_token is missing contractAddress or tokenId', async () => { mockTezosToolkit.rpc.getContract.mockResolvedValue({ counter: '1' }) await expect( craftTransaction( context, { address: 'tz1...' }, { type: 'send_token' as TransactionType, recipient: 'tz2...', amount: 1n, fee: {}, } ) ).rejects.toThrow('FA2 transfer requires contractAddress and tokenId') await expect( craftTransaction( context, { address: 'tz1...' }, { type: 'send_token' as TransactionType, recipient: 'tz2...', amount: 1n, fee: {}, contractAddress: 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU', } ) ).rejects.toThrow('FA2 transfer requires contractAddress and tokenId') }) it('should craft a stake transaction', async () => { mockTezosToolkit.rpc.getContract.mockResolvedValue({ counter: '1' }) const account = { address: 'tz1...' } const transaction = { type: 'stake' as TransactionType, recipient: '', amount: BigInt(1234), fee: { fees: '100', gasLimit: '200', storageLimit: '300' }, } const result = await craftTransaction(context, account, transaction) expect(result.type).toBe('STAKE') expect(result.contents).toEqual([ { kind: OpKind.TRANSACTION, amount: '1234', destination: account.address, source: account.address, counter: '2', fee: '100', gas_limit: '200', storage_limit: '300', parameters: { entrypoint: 'stake', value: { prim: 'Unit' } }, }, ]) }) it('should craft an unstake transaction', async () => { mockTezosToolkit.rpc.getContract.mockResolvedValue({ counter: '1' }) const account = { address: 'tz1...' } const transaction = { type: 'unstake' as TransactionType, recipient: '', amount: BigInt(4321), fee: { fees: '100', gasLimit: '200', storageLimit: '300' }, } const result = await craftTransaction(context, account, transaction) expect(result.type).toBe('UNSTAKE') expect(result.contents).toEqual([ { kind: OpKind.TRANSACTION, amount: '4321', destination: account.address, source: account.address, counter: '2', fee: '100', gas_limit: '200', storage_limit: '300', parameters: { entrypoint: 'unstake', value: { prim: 'Unit' } }, }, ]) }) it('should craft a finalize_unstake transaction with amount forced to 0', async () => { mockTezosToolkit.rpc.getContract.mockResolvedValue({ counter: '1' }) const account = { address: 'tz1...' } const transaction = { type: 'finalize_unstake' as TransactionType, recipient: '', // even with a non-zero amount the operation must serialize amount: "0" amount: BigInt(999), fee: { fees: '100', gasLimit: '200', storageLimit: '300' }, } const result = await craftTransaction(context, account, transaction) expect(result.type).toBe('FINALIZE_UNSTAKE') expect(result.contents).toEqual([ { kind: OpKind.TRANSACTION, amount: '0', destination: account.address, source: account.address, counter: '2', fee: '100', gas_limit: '200', storage_limit: '300', parameters: { entrypoint: 'finalize_unstake', value: { prim: 'Unit' } }, }, ]) }) it('should prepend a reveal operation when crafting a stake transaction with publicKey', async () => { mockTezosToolkit.rpc.getContract.mockResolvedValue({ counter: '1' }) mockTezosToolkit.estimate.reveal.mockResolvedValue({ gasLimit: 100, storageLimit: 0, suggestedFeeMutez: getRevealFee('tz1...'), }) currentConfig = { fees: { minRevealGasLimit: 100, minFees: 0, minStorageLimit: 0, }, } as unknown as TezosCoinConfig const account = { address: 'tz1...' } const transaction = { type: 'stake' as TransactionType, recipient: '', amount: BigInt(500), fee: { fees: '100', gasLimit: '200', storageLimit: '300' }, } const publicKey = { publicKey: 'publicKey', publicKeyHash: 'publicKeyHash' } const result = await craftTransaction(context, account, transaction, publicKey) expect(result.type).toBe('STAKE') expect(result.contents).toEqual([ { kind: OpKind.REVEAL, fee: getRevealFee(account.address).toString(), gas_limit: '100', storage_limit: '0', source: publicKey.publicKeyHash, counter: '2', public_key: publicKey.publicKey, }, { kind: OpKind.TRANSACTION, amount: '500', destination: account.address, source: account.address, counter: '3', fee: '100', gas_limit: '200', storage_limit: '300', parameters: { entrypoint: 'stake', value: { prim: 'Unit' } }, }, ]) }) it('should throw an error for unsupported transaction type', async () => { const account = { address: 'tz1...' } const transaction = { type: 'invalid' as any, recipient: 'tz2...', amount: BigInt(1000), fee: { fees: '100', gasLimit: '200', storageLimit: '300' }, } await expect(craftTransaction(context, account, transaction)).rejects.toThrow( 'unsupported mode' ) }) it('should include leading watermark byte when using rawEncode', async () => { mockTezosToolkit.rpc.getBlock.mockResolvedValue({ hash: 'aaaa' }) mockTezosToolkit.rpc.forgeOperations.mockResolvedValue('deadcafe') const rawTx = await rawEncode(currentConfig, []) // 0x03 is a conventional prefix (aka a watermark) for tezos transactions expect(rawTx).toEqual('03deadcafe') }) })