UNPKG

@ledgerhq/coin-tezos

Version:
403 lines (352 loc) 14.4 kB
// SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import type { TezosCoinConfig, TezosContext } from '../config' import { OpKind } from '@taquito/rpc' import { getPkhfromPk } from '@taquito/utils' import { UnsupportedOperationKind } from '../types/errors' import { craftRawOperations } from './craftRawOperations' import { getTezosToolkit } from './tezosToolkit' jest.mock('./tezosToolkit') // Keep real validatePublicKey/normalize, override only the pkh derivation so we can // pair an arbitrary valid edpk with our test sender in the reveal path. jest.mock('@taquito/utils', () => ({ ...jest.requireActual('@taquito/utils'), getPkhfromPk: jest.fn(), })) // Real, valid Tezos addresses — validateOperation rejects malformed ones. const SENDER = 'tz1KqTpEZ7Yob7QbPE4Hy4Wo8fHG8LhKxZSx' const RECIPIENT = 'tz2TaTpo31sAiX2HBJUTLLdUnqVJR4QjLy1V' const CONTRACT = 'KT1CpeSQKdkhWi4pinYcseCFKmDhs5M74BkU' const BAKER = 'tz3Vq38qYD3GEbWcXHMLt5PaASZrkDtEiA8D' // A valid ed25519 public key (edpk…) so normalizePublicKeyForAddress/validatePublicKey accept it. const PUBLIC_KEY = 'edpkuBknW28nW72KG6RoHtYW7p12T6GKc7nAbwYX5m8Wd9sDVC9yav' describe('craftRawOperations', () => { const mockTezosToolkit = { rpc: { getManagerKey: jest.fn(), getBlock: jest.fn(), forgeOperations: jest.fn(), }, estimate: { batch: jest.fn(), reveal: jest.fn(), }, setProvider: jest.fn(), } // Helper: the OperationContents[] that were forged in this call. const forgedContents = () => { expect(mockTezosToolkit.rpc.forgeOperations).toHaveBeenCalled() return mockTezosToolkit.rpc.forgeOperations.mock.calls[0][0].contents } const context: TezosContext = { config: async () => ({ fees: { minFees: 100, minRevealGasLimit: 0, minStorageLimit: 0 }, }) as unknown as TezosCoinConfig, logger: () => undefined, } beforeEach(() => { jest.clearAllMocks() ;(getTezosToolkit as jest.Mock).mockReturnValue(mockTezosToolkit) mockTezosToolkit.rpc.getBlock.mockResolvedValue({ hash: 'BLockHash' }) mockTezosToolkit.rpc.forgeOperations.mockResolvedValue('deadbeef') // Default: account already revealed (no leading reveal op). mockTezosToolkit.rpc.getManagerKey.mockResolvedValue(PUBLIC_KEY) // One estimate per operation (1:1 with the params passed to estimate.batch). mockTezosToolkit.estimate.batch.mockResolvedValue([ { suggestedFeeMutez: 500, gasLimit: 1400, storageLimit: 0 }, ]) }) it('forges a plain transfer, estimating the missing limits', async () => { const ops = [{ kind: 'transaction', destination: RECIPIENT, amount: '1000' }] const result = await craftRawOperations(context, JSON.stringify(ops), SENDER, PUBLIC_KEY, 5n) expect(result).toBe('03deadbeef') // 0x03 watermark + forged bytes expect(mockTezosToolkit.estimate.batch).toHaveBeenCalledWith([ { kind: OpKind.TRANSACTION, source: SENDER, to: RECIPIENT, amount: 1000, mutez: true }, ]) expect(forgedContents()).toEqual([ { kind: OpKind.TRANSACTION, source: SENDER, destination: RECIPIENT, amount: '1000', counter: '5', fee: '500', gas_limit: '1400', storage_limit: '0', }, ]) }) it('forges a contract call, passing the entrypoint/parameters to the estimate and contents', async () => { const parameters = { entrypoint: 'transfer', value: { prim: 'Unit' } } const ops = [{ kind: 'transaction', destination: CONTRACT, amount: '0', parameters }] await craftRawOperations(context, JSON.stringify(ops), SENDER, PUBLIC_KEY, 7n) expect(mockTezosToolkit.estimate.batch).toHaveBeenCalledWith([ { kind: OpKind.TRANSACTION, source: SENDER, to: CONTRACT, amount: 0, mutez: true, parameter: parameters, }, ]) expect(forgedContents()[0]).toMatchObject({ kind: OpKind.TRANSACTION, destination: CONTRACT, counter: '7', parameters, }) }) it('forges a delegation', async () => { mockTezosToolkit.estimate.batch.mockResolvedValue([ { suggestedFeeMutez: 400, gasLimit: 1000, storageLimit: 0 }, ]) const ops = [{ kind: 'delegation', delegate: BAKER }] await craftRawOperations(context, JSON.stringify(ops), SENDER, PUBLIC_KEY, 3n) expect(mockTezosToolkit.estimate.batch).toHaveBeenCalledWith([ { kind: OpKind.DELEGATION, source: SENDER, delegate: BAKER }, ]) expect(forgedContents()).toEqual([ { kind: OpKind.DELEGATION, source: SENDER, counter: '3', fee: '400', gas_limit: '1000', storage_limit: '0', delegate: BAKER, }, ]) }) it('forges a multi-op batch with sequential counters from a single batch estimate', async () => { mockTezosToolkit.estimate.batch.mockResolvedValue([ { suggestedFeeMutez: 500, gasLimit: 1400, storageLimit: 0 }, { suggestedFeeMutez: 900, gasLimit: 5000, storageLimit: 10 }, ]) const ops = [ { kind: 'transaction', destination: RECIPIENT, amount: '10' }, { kind: 'transaction', destination: CONTRACT, amount: '0', parameters: { entrypoint: 'foo', value: { prim: 'Unit' } }, }, ] await craftRawOperations(context, JSON.stringify(ops), SENDER, PUBLIC_KEY, 9n) expect(mockTezosToolkit.estimate.batch).toHaveBeenCalledTimes(1) const contents = forgedContents() expect(contents).toHaveLength(2) expect(contents[0].counter).toBe('9') expect(contents[1]).toMatchObject({ counter: '10', gas_limit: '5000', storage_limit: '10' }) }) it('uses the trailing op estimate when a reveal estimate is prepended for an unrevealed source', async () => { mockTezosToolkit.rpc.getManagerKey.mockResolvedValue(null) ;(getPkhfromPk as jest.Mock).mockReturnValue(SENDER) mockTezosToolkit.estimate.reveal.mockResolvedValue({ suggestedFeeMutez: 374, gasLimit: 1000, storageLimit: 0, }) // batch returns [reveal, op]; the leading reveal estimate is dropped, op uses the trailing one. mockTezosToolkit.estimate.batch.mockResolvedValue([ { suggestedFeeMutez: 374, gasLimit: 1000, storageLimit: 0 }, { suggestedFeeMutez: 800, gasLimit: 2000, storageLimit: 5 }, ]) const ops = [{ kind: 'transaction', destination: RECIPIENT, amount: '1000' }] await craftRawOperations(context, JSON.stringify(ops), SENDER, PUBLIC_KEY, 5n) const contents = forgedContents() expect(contents[0].kind).toBe(OpKind.REVEAL) expect(contents[1]).toMatchObject({ kind: OpKind.TRANSACTION, fee: '800', gas_limit: '2000', storage_limit: '5', }) }) it("throws when the batch estimate count doesn't match (revealed source, extra estimate)", async () => { // Revealed source ⇒ expect exactly N estimates; N+1 is a contract violation, not a silent slice. mockTezosToolkit.estimate.batch.mockResolvedValue([ { suggestedFeeMutez: 374, gasLimit: 1000, storageLimit: 0 }, { suggestedFeeMutez: 800, gasLimit: 2000, storageLimit: 5 }, ]) const ops = [{ kind: 'transaction', destination: RECIPIENT, amount: '1000' }] await expect( craftRawOperations(context, JSON.stringify(ops), SENDER, PUBLIC_KEY, 5n) ).rejects.toThrow('expected 1 estimate(s)') }) it('honours caller-provided fee/gas/storage without estimating', async () => { const ops = [ { kind: 'transaction', destination: RECIPIENT, amount: '1000', fee: '1234', gas_limit: '2000', storage_limit: '10', }, ] await craftRawOperations(context, JSON.stringify(ops), SENDER, PUBLIC_KEY, 5n) expect(mockTezosToolkit.estimate.batch).not.toHaveBeenCalled() expect(forgedContents()[0]).toMatchObject({ fee: '1234', gas_limit: '2000', storage_limit: '10', }) }) it('prepends a reveal when the sender is not revealed', async () => { mockTezosToolkit.rpc.getManagerKey.mockResolvedValue(null) ;(getPkhfromPk as jest.Mock).mockReturnValue(SENDER) mockTezosToolkit.estimate.reveal.mockResolvedValue({ suggestedFeeMutez: 374, gasLimit: 1000, storageLimit: 0, }) const ops = [{ kind: 'transaction', destination: RECIPIENT, amount: '1000' }] await craftRawOperations(context, JSON.stringify(ops), SENDER, PUBLIC_KEY, 5n) const contents = forgedContents() expect(contents).toHaveLength(2) expect(contents[0]).toMatchObject({ kind: OpKind.REVEAL, source: SENDER, counter: '5', public_key: PUBLIC_KEY, }) expect(contents[1]).toMatchObject({ kind: OpKind.TRANSACTION, counter: '6' }) }) it('falls back to SDK reveal helpers when estimate.reveal throws', async () => { mockTezosToolkit.rpc.getManagerKey.mockResolvedValue(null) ;(getPkhfromPk as jest.Mock).mockReturnValue(SENDER) mockTezosToolkit.estimate.reveal.mockRejectedValue(new Error('inconsistent_hash')) const ops = [{ kind: 'transaction', destination: RECIPIENT, amount: '1000' }] await craftRawOperations(context, JSON.stringify(ops), SENDER, PUBLIC_KEY, 5n) const reveal = forgedContents()[0] expect(reveal.kind).toBe(OpKind.REVEAL) // Reveal limits came from the SDK fallback (getRevealFee/getRevealGasLimit), not a thrown error. expect(reveal.fee).toMatch(/^\d+$/) expect(reveal.gas_limit).toMatch(/^\d+$/) }) it('rejects a reveal whose public key does not match the sender', async () => { mockTezosToolkit.rpc.getManagerKey.mockResolvedValue(null) ;(getPkhfromPk as jest.Mock).mockReturnValue('tz1someoneElse') const ops = [{ kind: 'transaction', destination: RECIPIENT, amount: '1000' }] await expect( craftRawOperations(context, JSON.stringify(ops), SENDER, PUBLIC_KEY, 5n) ).rejects.toThrow('does not match the sender') }) it('throws on an unsupported operation kind', async () => { const ops = [{ kind: 'origination', balance: '0' }] await expect( craftRawOperations(context, JSON.stringify(ops), SENDER, PUBLIC_KEY, 5n) ).rejects.toBeInstanceOf(UnsupportedOperationKind) }) describe('input validation', () => { it('throws on malformed JSON or an empty array', async () => { await expect(craftRawOperations(context, 'not-json', SENDER, PUBLIC_KEY, 5n)).rejects.toThrow( 'must be a JSON array' ) await expect(craftRawOperations(context, '[]', SENDER, PUBLIC_KEY, 5n)).rejects.toThrow( 'non-empty array' ) }) it('throws on a non-integer amount', async () => { const ops = [{ kind: 'transaction', destination: RECIPIENT, amount: '1.5' }] await expect( craftRawOperations(context, JSON.stringify(ops), SENDER, PUBLIC_KEY, 5n) ).rejects.toThrow('`amount` must be a non-negative integer string') }) it('throws on a missing or invalid destination', async () => { await expect( craftRawOperations( context, JSON.stringify([{ kind: 'transaction', amount: '1000' }]), SENDER, PUBLIC_KEY, 5n ) ).rejects.toThrow('valid `destination`') await expect( craftRawOperations( context, JSON.stringify([{ kind: 'transaction', destination: 'not-an-address', amount: '1000' }]), SENDER, PUBLIC_KEY, 5n ) ).rejects.toThrow('valid `destination`') }) it('throws on a non-numeric fee', async () => { const ops = [{ kind: 'transaction', destination: RECIPIENT, amount: '1000', fee: 'abc' }] await expect( craftRawOperations(context, JSON.stringify(ops), SENDER, PUBLIC_KEY, 5n) ).rejects.toThrow('`fee` must be a non-negative integer string') }) it('rejects an empty or invalid delegate (must not be treated as undelegation)', async () => { await expect( craftRawOperations( context, JSON.stringify([{ kind: 'delegation', delegate: '' }]), SENDER, PUBLIC_KEY, 5n ) ).rejects.toThrow('`delegate` must be a valid implicit account address') await expect( craftRawOperations( context, JSON.stringify([{ kind: 'delegation', delegate: 123 }]), SENDER, PUBLIC_KEY, 5n ) ).rejects.toThrow('`delegate` must be a valid implicit account address') }) it('rejects a contract (KT1) delegate (delegates must be implicit accounts)', async () => { await expect( craftRawOperations( context, JSON.stringify([{ kind: 'delegation', delegate: CONTRACT }]), SENDER, PUBLIC_KEY, 5n ) ).rejects.toThrow('`delegate` must be a valid implicit account address') }) it('allows a delegation with no delegate (undelegation)', async () => { mockTezosToolkit.estimate.batch.mockResolvedValue([ { suggestedFeeMutez: 400, gasLimit: 1000, storageLimit: 0 }, ]) await craftRawOperations( context, JSON.stringify([{ kind: 'delegation' }]), SENDER, PUBLIC_KEY, 5n ) const content = forgedContents()[0] expect(content.kind).toBe(OpKind.DELEGATION) expect(content).not.toHaveProperty('delegate') }) it('throws when the sequence is out of safe integer range', async () => { const ops = [{ kind: 'transaction', destination: RECIPIENT, amount: '1000' }] await expect( craftRawOperations( context, JSON.stringify(ops), SENDER, PUBLIC_KEY, BigInt(Number.MAX_SAFE_INTEGER) + 1n ) ).rejects.toThrow('out of the safe integer range') }) it('throws on an invalid or contract (KT1) sender address', async () => { const ops = [{ kind: 'transaction', destination: RECIPIENT, amount: '1000' }] await expect( craftRawOperations(context, JSON.stringify(ops), 'not-an-address', PUBLIC_KEY, 5n) ).rejects.toThrow('`sender` must be a valid implicit account address') await expect( craftRawOperations(context, JSON.stringify(ops), CONTRACT, PUBLIC_KEY, 5n) ).rejects.toThrow('`sender` must be a valid implicit account address') }) }) })