@ledgerhq/coin-tezos
Version:
403 lines (352 loc) • 14.4 kB
text/typescript
// 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')
})
})
})