@ledgerhq/coin-tezos
Version:
247 lines (221 loc) • 8.66 kB
text/typescript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
import type { TezosCoinConfig } from './config'
import type { APIAccount } from './network/types'
import type { TezosOperationMode } from './types/model'
import { DerivationType } from '@taquito/ledger-signer'
import { compressPublicKey } from '@taquito/ledger-signer/dist/lib/utils'
import { validatePublicKey, ValidationResult, b58Encode, PrefixV2 } from '@taquito/utils'
import { hasManagerKey } from './network/types'
/**
* Dust margin in mutez to prevent transaction failures on send max operations
* This ensures a small safety buffer remains in the account after send max
* Based on production behavior where ~281-287 mutez typically remain
* Increased to 500 to handle edge cases and fee estimation variations
*/
export const DUST_MARGIN_MUTEZ = 500
// Tezos rejects `empty_implicit_delegated_contract`; stake-max leaves this buffer.
export const STAKE_USE_ALL_RESERVE_MUTEZ = 10_000n
/** Stake "Use Max": staked and unstaked-frozen funds count toward `balance` but aren't (re-)stakeable until finalized, so subtract both. */
export function computeMaxStakeAmount(
balance: bigint,
stakedBalance: bigint,
unstakedBalance: bigint,
fees: bigint
): bigint {
const { spendable: liquid } = partitionNativeBalance(balance, stakedBalance, unstakedBalance)
const reserved = fees + STAKE_USE_ALL_RESERVE_MUTEZ
return liquid > reserved ? liquid - reserved : 0n
}
/**
* Splits a Tezos native balance (which includes frozen funds) into spendable and locked
* (staked + unstaked). `locked` is clamped to `total` so spendable stays >= 0 across TzKT reorgs.
*/
export function partitionNativeBalance(
total: bigint,
staked: bigint,
unstaked: bigint
): { spendable: bigint; locked: bigint } {
const frozen = staked + unstaked
const locked = frozen > total ? total : frozen
return { spendable: total - locked, locked }
}
/**
* Suggested fee returned by Taquito for a minimal amount pre-estimation (mutez)
* Used as a stable fallback for send-max when RPC estimation is unavailable.
*/
export const MIN_SUGGESTED_FEE_SMALL_TRANSFER = 489
/**
* Typical operation size (bytes) for a simple XTZ transfer used to approximate
* the increased fee component when computing send-max in fallback mode.
* Chosen to match Taquito’s behavior closely in integration tests.
*/
export const OP_SIZE_XTZ_TRANSFER = 154
/** Minimal asset shape from `TransactionIntent` for FA2 detection */
export type TezosAssetLike = {
type: string
assetReference?: string
}
/**
* Parse FA1.2 / FA2 token contract + token id from coin-framework `assetReference`
* (`KT1…` or `KT1…:tokenId` as produced by getBlock / listOperations).
*/
export function parseTezosTokenAsset(
asset: TezosAssetLike | undefined
): { contractAddress: string; tokenId: number } | null {
if (!asset || asset.type === 'native') return null
const ref = asset.assetReference?.trim()
if (!ref?.startsWith('KT1')) return null
const colonIdx = ref.lastIndexOf(':')
if (colonIdx > 0) {
const contractAddress = ref.slice(0, colonIdx)
const tokenId = Number(ref.slice(colonIdx + 1))
if (!Number.isFinite(tokenId) || tokenId < 0) return null
return { contractAddress, tokenId }
}
return { contractAddress: ref, tokenId: 0 }
}
/**
* Resolves Tezos operation mode from intent type and asset (native XTZ vs token).
*/
export function resolveTezosOperationMode(
intentType: string,
asset: TezosAssetLike | undefined
): TezosOperationMode {
const base = intentType as TezosOperationMode
if (base === 'send' && parseTezosTokenAsset(asset) !== null) {
return 'send_token'
}
return base
}
/**
* Creates a mock signer for Taquito operations (estimation and crafting)
*/
export function createMockSigner(publicKeyHash: string, publicKey: string) {
return {
publicKeyHash: async () => publicKeyHash,
publicKey: async () => publicKey,
sign: () => Promise.reject(new Error('unsupported')),
secretKey: () => Promise.reject(new Error('unsupported')),
}
}
/**
* Normalize a Tezos public key to base58 format (edpk/sppk/p2pk) based on the
* sender address prefix (tz1/tz2/tz3). Accepts either an already base58-encoded
* key or a hex key returned by the Ledger app. Returns undefined if input is
* empty or cannot be parsed.
*/
export function normalizePublicKeyForAddress(
maybeKey: string | undefined,
address: string
): string | undefined {
if (!maybeKey) return undefined
// If it's already a valid base58 public key, keep it
if (validatePublicKey(maybeKey) === ValidationResult.VALID) return maybeKey
// Attempt to convert hex → base58 according to address curve
try {
const pkHex = maybeKey.trim().toLowerCase()
if (pkHex === '') return undefined
const keyBuf = Buffer.from(pkHex, 'hex')
// Choose curve/prefix from address tz1/tz2/tz3
// default are values for tz1 and fallbacks to it
let derivationType: DerivationType = DerivationType.ED25519
let prefix: PrefixV2 = PrefixV2.Ed25519PublicKey
if (address.startsWith('tz2')) {
derivationType = DerivationType.SECP256K1
prefix = PrefixV2.Secp256k1PublicKey
} else if (address.startsWith('tz3')) {
derivationType = DerivationType.P256
prefix = PrefixV2.P256PublicKey
}
const RAW_ED25519_KEY_LENGTH = 32
const COMPRESSED_SEC1_LENGTH = 33
const UNCOMPRESSED_SEC1_LENGTH = 65
const isEd25519 = derivationType === DerivationType.ED25519
if (isEd25519) {
if (keyBuf.length === COMPRESSED_SEC1_LENGTH) {
return b58Encode(keyBuf.slice(1), prefix)
}
if (keyBuf.length !== RAW_ED25519_KEY_LENGTH) {
return undefined
}
return b58Encode(keyBuf, prefix)
}
if (keyBuf.length === UNCOMPRESSED_SEC1_LENGTH) {
return b58Encode(compressPublicKey(keyBuf, derivationType), prefix)
}
return b58Encode(keyBuf, prefix)
} catch {
return undefined
}
}
/**
* Converts a DER-encoded secp256k1/P-256 ECDSA signature (returned by the Tezos Ledger
* app for tz2/tz3 accounts) to the raw 64-byte r||s format the Tezos protocol expects.
* Format: 0x30 <totalLen> 0x02 <rLen> <r_bytes> 0x02 <sLen> <s_bytes>
*/
export function convertSecp256k1DERToRaw(derHex: string): string {
if (!/^[0-9a-fA-F]+$/.test(derHex) || derHex.length % 2 !== 0) {
throw new Error('Tezos: device returned an invalid signature')
}
const buf = Buffer.from(derHex, 'hex')
if (buf.length === 64) return derHex // already raw r||s
const rStart = 4
const rLen = buf[3]
const sLenIdx = rStart + rLen + 1
const sLen = buf[sLenIdx]
const sStart = sLenIdx + 1
const malformed =
buf.length < 8 ||
buf[0] !== 0x30 ||
buf[2] !== 0x02 ||
buf[sLenIdx - 1] !== 0x02 ||
rLen === 0 ||
sLen === 0 ||
rLen > 33 ||
sLen > 33 ||
sStart + sLen !== buf.length
if (malformed) {
throw new Error('Tezos: device returned an invalid signature')
}
const raw = Buffer.concat([
normalizeTo32Bytes(buf.slice(rStart, rStart + rLen)),
normalizeTo32Bytes(buf.slice(sStart, sStart + sLen)),
]).toString('hex')
// A 33-byte r/s is only valid DER when left-padded with 0x00; anything else normalises
// to a non-64-byte result and is not a usable signature.
if (raw.length !== 128) {
throw new Error('Tezos: device returned an invalid signature')
}
return raw
}
export function normalizeTo32Bytes(bytes: Buffer): Buffer {
if (bytes.length === 33 && bytes[0] === 0x00) return bytes.slice(1)
if (bytes.length < 32) {
const padded = Buffer.alloc(32, 0)
bytes.copy(padded, 32 - bytes.length)
return padded
}
return bytes
}
/**
* Creates default fallback estimation values
*/
export function createFallbackEstimation(config: TezosCoinConfig) {
return {
fees: BigInt(config.fees.minEstimatedFees),
gasLimit: BigInt(config.fees.minGasLimit),
storageLimit: BigInt(config.fees.minStorageLimit),
estimatedFees: BigInt(config.fees.minEstimatedFees),
}
}
/**
* Whether an account has nothing on-chain that would spare a recipient the allocation
* (storage) burn. Manager-key accounts (`user`/`delegate`) with a zero balance and truly
* non-existent (`empty`) accounts count as empty. A registered baker (`delegate`) is always
* allocated on-chain, so a zero-balance one is unusual — but keying on `hasManagerKey` keeps
* the reveal/storage logic consistent for both wallet and baker recipients.
*/
export function hasEmptyBalance(account: APIAccount) {
return (hasManagerKey(account) && account.balance === 0) || account.type === 'empty'
}