@ledgerhq/coin-tezos
Version:
386 lines (347 loc) • 13.8 kB
text/typescript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
import type { TezosCoinConfig, TezosConfig, TezosContext } from '../config'
import type { SendTransactionIntent } from '@ledgerhq/coin-module-framework/api/types'
import { localForger } from '@taquito/local-forging'
import { createApi } from '.'
const defaultConfig: TezosConfig = {
baker: { url: 'https://baker.example.com' },
explorer: { url: 'https://api.shadownet.tzkt.io', maxTxQuery: 100 },
node: { url: 'https://rpc.shadownet.teztnets.com' },
fees: {
minGasLimit: 600,
minRevealGasLimit: 300,
minStorageLimit: 0,
minFees: 500,
minEstimatedFees: 500,
},
}
/**
* Shadownet-specific integration tests (Ghostnet was deprecated in early 2026).
* https://teztnets.com/shadownet-about
* https://api.shadownet.tzkt.io
*/
describe('Tezos Api', () => {
const module = createApi()
const context: TezosContext = {
config: async () => defaultConfig as TezosCoinConfig,
logger: () => undefined,
}
// Persistent Shadownet test account — see LIVE-28763 PR for chain-of-custody.
const address = 'tz1dKrT1h6d7wP8fEzMPptG6er7mLLeQjBBY'
// Registered baker on Shadownet, distinct from `address`'s current delegate
// (TF Test Baker) — picked so `delegate` intents don't simulate as `delegate.unchanged`.
const baker = 'tz1a28g3f7Eswy7XfX81sJSdhBHZqMjkfaf9'
describe('estimateFees', () => {
it('returns a default value', async () => {
// Given
const amount = BigInt(100)
// When
const result = await module.estimateFees(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: address,
recipient: address,
amount,
})
// Then
expect(result.value).toBeGreaterThanOrEqual(BigInt(0))
expect(result.parameters?.gasLimit).toBeGreaterThanOrEqual(BigInt(0))
expect(result.parameters?.storageLimit).toBeGreaterThanOrEqual(BigInt(0))
})
})
// Multi-curve sender handling — each row exercises estimateFees with a sender
// address + matching senderPublicKey for a different curve, covering the
// pubkey-normalization + sender-derivation path in api/index.ts.
it.each([
[
'ed25519 / tz1',
'tz1dKrT1h6d7wP8fEzMPptG6er7mLLeQjBBY',
'edpkv2EJVgdvaK8TB5mMttfN93jRhHwoyKcy9sehANdeMDNY27zkjz',
],
[
'secp256k1 / tz2',
'tz29GPjgeRQTRX6mcPQXkiuHnq7jbya1Abnq',
'sppk7cWw2W2H9Uf7zWAju13smtbETcPbLpDGFZtpB7TAoDsVFiAizhf',
],
[
'P256 / tz3',
'tz3Q67aMz7gSMiQRcW729sXSfuMtkyAHYfqc',
'p2pk66gP3wq6k9QgNwAgaGLeXc3YFfrxgYdC7cdYuJWAHYBw4hExVHW',
],
])('does not fail when providing a %s sender with pub key', async (_, sender, pubKey) => {
const result = await module.estimateFees(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender,
senderPublicKey: pubKey,
recipient: address,
amount: BigInt(100),
} as SendTransactionIntent)
expect(result.value).toBeGreaterThanOrEqual(BigInt(0))
expect(result.parameters?.gasLimit).toBeGreaterThanOrEqual(BigInt(0))
expect(result.parameters?.storageLimit).toBeGreaterThanOrEqual(BigInt(0))
})
it('fails when using an unsupported address type', async () => {
await expect(
module.estimateFees(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: address,
recipient: 'tz5heMGVHQnx7ALDcDKqez8fan64Eyicw4DJ',
amount: BigInt(100),
})
).rejects.toThrow('Invalid address "tz5heMGVHQnx7ALDcDKqez8fan64Eyicw4DJ"')
})
describe('listOperations', () => {
it('returns a list regarding address parameter', async () => {
// When
const { items: tx } = await module.listOperations(context, address, {
minHeight: 0,
order: 'asc',
})
// Then
expect(tx.length).toBeGreaterThanOrEqual(1)
tx.forEach((operation) => {
const isSenderOrReceipt =
operation.senders.includes(address) || operation.recipients.includes(address)
expect(isSenderOrReceipt).toBe(true)
expect(operation.value).toBeGreaterThanOrEqual(0)
expect(operation.tx.hash).toMatch(/^o[1-9A-HJ-NP-Za-km-z]+/) // "o" + base58
expect(operation.tx.block.hash).toMatch(/^B[1-9A-HJ-NP-Za-km-z]+/) // "B" + base58
expect(operation.tx.block.height).toBeGreaterThanOrEqual(0)
expect(operation.tx.fees).toBeGreaterThan(0)
expect(operation.tx.feesPayer).toMatch(/^tz[1-3]/)
expect(operation.tx.date).toBeInstanceOf(Date)
})
})
it('returns all operations', async () => {
// When
const { items: tx } = await module.listOperations(context, address, {
minHeight: 0,
order: 'asc',
})
// Then
// Find a way to create a unique id. In Tezos, the same hash may represent different operations in case of delegation.
const checkSet = new Set(tx.map((elt) => `${elt.tx.hash}${elt.type}${elt.senders[0]}`))
expect(checkSet.size).toEqual(tx.length)
})
it('returns operations from latest, but in asc order', async () => {
// When
const { items: txDesc } = await module.listOperations(context, address, {
minHeight: 0,
order: 'desc',
})
// Then
// Check if the result is sorted in ascending order
expect(txDesc[0].tx.block.height).toBeGreaterThanOrEqual(
txDesc[txDesc.length - 1].tx.block.height
)
})
})
describe('lastBlock', () => {
it('returns last block info', async () => {
// When
const result = await module.lastBlock(context)
// Then
expect(result.hash).toMatch(/^B[1-9A-HJ-NP-Za-km-z]+/) // "B" + base58
expect(result.height).toBeGreaterThan(0)
expect(result.time).toBeInstanceOf(Date)
})
})
describe('getBalance', () => {
it('returns a list regarding address parameter', async () => {
// When
const result = await module.getBalance(context, address)
// Then
expect(result[0].asset).toEqual({ type: 'native' })
expect(result[0].value).toBeGreaterThan(0)
})
it('returns 0 when address is not found', async () => {
const result = await module.getBalance(context, 'tz1euQVEofitwkUzMRKCuBK9D1ZPiy4udXz1')
expect(result).toEqual([{ value: BigInt(0), asset: { type: 'native' } }])
})
})
describe('craftTransaction', () => {
async function decode(sbytes: string) {
// note: strip the conventional prefix (aka watermark) added by rawEncode
// output of craftTransaction is "payload to sign = prefix + actual raw transaction"
return await localForger.parse(sbytes.slice(2))
}
it.each(['send', 'delegate', 'undelegate'])(
'returns a raw transaction with %s',
async (type) => {
// `delegate` requires a registered baker; the others accept any tz address.
const recipient = type === 'delegate' ? baker : 'tz1aWXP237BLwNHJcCD4b3DutCevhqq2T1Z9'
const amount = BigInt(10)
// When
const { transaction: encodedTransaction } = await module.craftTransaction(context, {
intentType: 'transaction',
asset: { type: 'native' },
type,
sender: address,
recipient: recipient,
amount: amount,
})
const decodedTransaction = await decode(encodedTransaction)
expect(decodedTransaction.contents).toHaveLength(1)
const operationContent = decodedTransaction.contents[0]
expect(operationContent.source).toEqual(address)
expect(BigInt(operationContent.fee)).toBeGreaterThan(0)
if (type === 'send') {
expect(BigInt(operationContent.amount)).toEqual(amount)
expect(operationContent.destination).toEqual(recipient)
} else if (type === 'delegate') {
expect(operationContent.delegate).toEqual(recipient)
}
}
)
it('should use estimated fees when user does not provide them for crafting a transaction', async () => {
const { transaction: encodedTransaction } = await module.craftTransaction(context, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: address,
recipient: 'tz1aWXP237BLwNHJcCD4b3DutCevhqq2T1Z9',
amount: BigInt(10),
})
const decodedTransaction = await decode(encodedTransaction)
const transactionFee = BigInt(decodedTransaction.contents[0].fee)
expect(transactionFee).toBeGreaterThan(0)
})
it.each([1n, 50n, 99n])(
'should use custom user fees when user provides it for crafting a transaction',
async (customFees: bigint) => {
const { transaction: encodedTransaction } = await module.craftTransaction(
context,
{
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: address,
recipient: 'tz1aWXP237BLwNHJcCD4b3DutCevhqq2T1Z9',
amount: BigInt(10),
},
{ customFees: { value: customFees } }
)
const decodedTransaction = await decode(encodedTransaction)
expect(decodedTransaction.contents[0].fee).toEqual(customFees.toString())
}
)
})
describe('with custom fees settings', () => {
const minFees = 600
const minEstimatedFees = 550
const contextCustomFees: TezosContext = {
config: async () =>
({
...defaultConfig,
fees: {
minGasLimit: 600,
minRevealGasLimit: 300,
minStorageLimit: 0,
minFees,
minEstimatedFees,
},
}) as TezosCoinConfig,
logger: () => undefined,
}
describe('estimateFees', () => {
it('estimate fees when default estimation is greater than minFees', async () => {
const result = await module.estimateFees(contextCustomFees, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: address,
recipient: 'tz1heMGVHQnx7ALDcDKqez8fan64Eyicw4DJ',
amount: BigInt(100),
})
expect(result.value).toBeGreaterThanOrEqual(BigInt(minFees))
expect(result.parameters?.gasLimit).toBeGreaterThanOrEqual(BigInt(0))
expect(result.parameters?.storageLimit).toBeGreaterThanOrEqual(BigInt(0))
})
it('estimate fees when default estimation is lesser than minFees', async () => {
const result = await module.estimateFees(contextCustomFees, {
intentType: 'transaction',
asset: { type: 'native' },
type: 'delegate',
sender: address,
recipient: baker,
amount: BigInt(0),
})
expect(result.value).toBeGreaterThanOrEqual(BigInt(minFees))
})
})
describe('craftTransaction', () => {
it('craft transaction when default estimation is greater than minFees', async () => {
const { transaction: encodedTransaction } = await module.craftTransaction(
contextCustomFees,
{
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: address,
recipient: 'tz1aWXP237BLwNHJcCD4b3DutCevhqq2T1Z9',
amount: BigInt(10),
}
)
const decoded = await localForger.parse(encodedTransaction.slice(2))
const fee = BigInt(decoded.contents[0].fee ?? '0')
expect(fee).toBeGreaterThanOrEqual(BigInt(minFees))
})
it('craft transaction when default estimation is lesser than minFees', async () => {
const { transaction: encodedTransaction } = await module.craftTransaction(
contextCustomFees,
{
intentType: 'transaction',
asset: { type: 'native' },
type: 'delegate',
sender: address,
recipient: baker,
amount: BigInt(0),
}
)
const decoded = await localForger.parse(encodedTransaction.slice(2))
const fee = BigInt(decoded.contents[0].fee ?? '0')
expect(fee).toBeGreaterThanOrEqual(BigInt(minFees))
})
it('craft transaction with customFee when default estimation is greater than customFee', async () => {
const customFee = BigInt(minFees - 50)
const { transaction: encodedTransaction } = await module.craftTransaction(
contextCustomFees,
{
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: address,
recipient: 'tz1aWXP237BLwNHJcCD4b3DutCevhqq2T1Z9',
amount: BigInt(10),
},
{ customFees: { value: customFee } }
)
const decoded = await localForger.parse(encodedTransaction.slice(2))
expect(decoded.contents[0].fee).toBe(customFee.toString())
})
it('craft transaction with customFee when default estimation is lesser than customFee', async () => {
const customFee = BigInt(minFees * 2)
const { transaction: encodedTransaction } = await module.craftTransaction(
contextCustomFees,
{
intentType: 'transaction',
asset: { type: 'native' },
type: 'send',
sender: address,
recipient: 'tz1aWXP237BLwNHJcCD4b3DutCevhqq2T1Z9',
amount: BigInt(10),
},
{ customFees: { value: customFee } }
)
const decoded = await localForger.parse(encodedTransaction.slice(2))
expect(decoded.contents[0].fee).toBe(customFee.toString())
})
})
})
})