@ledgerhq/coin-tezos
Version:
313 lines (290 loc) • 11.9 kB
text/typescript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
import type { TezosContext } from '../config'
import type { MichelsonV1Expression, OperationContents } from '@taquito/rpc'
import type { Estimate, ParamsWithKind } from '@taquito/taquito'
import { OpKind } from '@taquito/rpc'
import {
getPkhfromPk,
validateAddress,
validateKeyHash,
validatePublicKey,
ValidationResult,
} from '@taquito/utils'
import { UnsupportedOperationKind } from '../types/errors'
import { createMockSigner, normalizePublicKeyForAddress } from '../utils'
import { rawEncode } from './craftTransaction'
import { estimateRevealLimits } from './estimateRevealLimits'
import { getTezosToolkit } from './tezosToolkit'
/**
* Partial Tezos operation contents: RPC operation contents without `source`,
* `counter` or `branch`, and with optional `fee`/`gas_limit`/`storage_limit`. This
* module fills the missing fields (`source`, a sequential `counter`, and — when
* absent — `fee`/`gas`/`storage` via estimation). Supports `transaction` (transfers
* and contract calls) and `delegation`; other kinds raise UnsupportedOperationKind.
*/
export type PartialTezosTransactionOperation = {
kind: 'transaction'
destination: string
amount: string
parameters?: { entrypoint: string; value: MichelsonV1Expression }
fee?: string
gas_limit?: string
storage_limit?: string
}
export type PartialTezosDelegationOperation = {
kind: 'delegation'
delegate?: string
fee?: string
gas_limit?: string
storage_limit?: string
}
export type PartialTezosOperation =
| PartialTezosTransactionOperation
| PartialTezosDelegationOperation
type ResolvedLimits = { fee: string; gasLimit: string; storageLimit: string }
/**
* Craft a forged (but unsigned) Tezos operation from a JSON-serialized array of
* partial operation contents ({@link PartialTezosOperation}).
*
* Fills the fields a caller cannot specify up front: `source`, a sequential `counter`
* starting at `sequence`, and any missing `fee`/`gas_limit`/`storage_limit` (estimated
* against the node). Prepends a REVEAL when the sender's manager key is not yet
* revealed on-chain.
*
* Returns the `0x03`-watermarked forged hex produced by {@link rawEncode} — the same
* format the structured `craft()` path returns and broadcasts, so the existing
* `signer.signTransaction` + `combine` + broadcast path applies to it unchanged.
*/
export async function craftRawOperations(
context: TezosContext,
rawTransaction: string,
sender: string,
publicKey: string,
sequence: bigint
): Promise<string> {
const config = await context.config()
if (sequence < 0n || sequence > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new Error('craftRawOperations: sequence is out of the safe integer range')
}
if (!isImplicitAddress(sender)) {
throw new Error('craftRawOperations: `sender` must be a valid implicit account address')
}
const operations = parseOperations(rawTransaction)
const tezosToolkit = getTezosToolkit(config)
const normalizedPk = normalizePublicKeyForAddress(publicKey, sender)
// Mock signer lets Taquito estimate without a connected device.
tezosToolkit.setProvider({ signer: createMockSigner(sender, normalizedPk ?? '') })
const feesConfig = config.fees
const minFees = feesConfig.minFees
let counter = Number(sequence)
const contents: OperationContents[] = []
// A manager key that is not yet revealed on-chain requires a leading REVEAL op.
const managerKey = await tezosToolkit.rpc.getManagerKey(sender)
const needsReveal = !managerKey
if (needsReveal) {
if (!normalizedPk || validatePublicKey(normalizedPk) !== ValidationResult.VALID) {
throw new Error('craftRawOperations: a valid public key is required to reveal the sender')
}
// The key must belong to the sender, otherwise the node rejects it as inconsistent_hash.
if (getPkhfromPk(normalizedPk) !== sender) {
throw new Error('craftRawOperations: public key does not match the sender address')
}
const reveal = await estimateRevealLimits(tezosToolkit, sender, feesConfig)
contents.push({
kind: OpKind.REVEAL,
source: sender,
counter: counter.toString(),
fee: reveal.fee.toString(),
gas_limit: reveal.gasLimit.toString(),
storage_limit: reveal.storageLimit.toString(),
public_key: normalizedPk,
})
counter += 1
}
const estimates = await estimateOperations(operations, sender, needsReveal, tezosToolkit)
operations.forEach((op, index) => {
contents.push(buildOperationContent(op, sender, counter, minFees, estimates?.[index]))
counter += 1
})
return rawEncode(config, contents)
}
function parseOperations(rawTransaction: string): PartialTezosOperation[] {
let parsed: unknown
try {
parsed = JSON.parse(rawTransaction)
} catch {
throw new Error('craftRawOperations: rawTransaction must be a JSON array of operations')
}
if (!Array.isArray(parsed) || parsed.length === 0) {
throw new Error('craftRawOperations: expected a non-empty array of operations')
}
return parsed.map(validateOperation)
}
function validateOperation(op: unknown): PartialTezosOperation {
if (!op || typeof op !== 'object') {
throw new Error('craftRawOperations: invalid operation')
}
const candidate = op as Record<string, unknown>
assertOptionalLimits(candidate)
switch (candidate.kind) {
case 'transaction':
if (!isValidAddress(candidate.destination)) {
throw new Error('craftRawOperations: a transaction requires a valid `destination` address')
}
assertMutezAmount(candidate.amount)
return candidate as PartialTezosTransactionOperation
case 'delegation':
// A delegation with no `delegate` is an undelegation; when present it must be a valid
// implicit account (an empty/invalid string must not be silently treated as absent).
if (candidate.delegate !== undefined && !isImplicitAddress(candidate.delegate)) {
throw new Error(
'craftRawOperations: `delegate` must be a valid implicit account address when present'
)
}
return candidate as unknown as PartialTezosDelegationOperation
default:
throw new UnsupportedOperationKind('unsupported operation kind', {
kind: String(candidate.kind),
})
}
}
function isValidAddress(value: unknown): value is string {
return typeof value === 'string' && validateAddress(value) === ValidationResult.VALID
}
function isImplicitAddress(value: unknown): value is string {
return typeof value === 'string' && validateKeyHash(value) === ValidationResult.VALID
}
function assertMutezAmount(amount: unknown): void {
if (typeof amount !== 'string' || !/^\d+$/.test(amount)) {
throw new Error('craftRawOperations: `amount` must be a non-negative integer string (mutez)')
}
if (BigInt(amount) > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new Error('craftRawOperations: `amount` exceeds the safe integer range')
}
}
function assertOptionalLimits(candidate: Record<string, unknown>): void {
for (const field of ['fee', 'gas_limit', 'storage_limit'] as const) {
const value = candidate[field]
if (value !== undefined && (typeof value !== 'string' || !/^\d+$/.test(value))) {
throw new Error(`craftRawOperations: \`${field}\` must be a non-negative integer string`)
}
}
}
/**
* Estimate the whole batch in a single simulation so that gas/storage for operations
* that depend on earlier ones in the same batch are accounted for (a per-op estimate
* would simulate each against head state and can under-estimate). Returns one estimate
* per operation, or `undefined` when every operation already carries all three limits.
*/
async function estimateOperations(
operations: PartialTezosOperation[],
sender: string,
needsReveal: boolean,
tezosToolkit: ReturnType<typeof getTezosToolkit>
): Promise<Estimate[] | undefined> {
const needsEstimation = operations.some(
(op) => op.fee === undefined || op.gas_limit === undefined || op.storage_limit === undefined
)
if (!needsEstimation) return undefined
const params: ParamsWithKind[] = operations.map((op) => toBatchParam(op, sender))
const estimates = await tezosToolkit.estimate.batch(params)
// Taquito may prepend a single reveal estimate when the source is unrevealed. Accept N
// (no prepend) or N+1 (prepended) only in that case; for a revealed source require
// exactly N so a stray estimate fails loudly instead of silently misaligning per-op.
const maxExpected = operations.length + (needsReveal ? 1 : 0)
if (estimates.length < operations.length || estimates.length > maxExpected) {
const expected = needsReveal ? `${operations.length} or ${maxExpected}` : `${operations.length}`
throw new Error(
`craftRawOperations: expected ${expected} estimate(s) for ${operations.length} operation(s), got ${estimates.length}`
)
}
// Keep the trailing N estimates, dropping a prepended reveal estimate if present. The
// REVEAL op itself uses estimateRevealLimits (config-clamped, consistent with
// craftTransaction) rather than this batch-prepended value, which is discarded.
return estimates.slice(estimates.length - operations.length)
}
function toBatchParam(op: PartialTezosOperation, sender: string): ParamsWithKind {
switch (op.kind) {
case 'transaction':
return {
kind: OpKind.TRANSACTION,
source: sender,
to: op.destination,
amount: Number(op.amount),
mutez: true,
...(op.parameters ? { parameter: op.parameters } : {}),
}
case 'delegation':
return {
kind: OpKind.DELEGATION,
source: sender,
...(op.delegate === undefined ? {} : { delegate: op.delegate }),
}
default:
return assertNever(op)
}
}
function buildOperationContent(
op: PartialTezosOperation,
sender: string,
counter: number,
minFees: number,
estimate?: Estimate
): OperationContents {
const limits = resolveLimits(op, minFees, estimate)
switch (op.kind) {
case 'transaction':
return {
kind: OpKind.TRANSACTION,
source: sender,
destination: op.destination,
amount: op.amount,
counter: counter.toString(),
fee: limits.fee,
gas_limit: limits.gasLimit,
storage_limit: limits.storageLimit,
...(op.parameters ? { parameters: op.parameters } : {}),
}
case 'delegation':
return {
kind: OpKind.DELEGATION,
source: sender,
counter: counter.toString(),
fee: limits.fee,
gas_limit: limits.gasLimit,
storage_limit: limits.storageLimit,
...(op.delegate === undefined ? {} : { delegate: op.delegate }),
}
default:
return assertNever(op)
}
}
// Compile-time exhaustiveness guard: a new PartialTezosOperation kind becomes a type error
// here. validateOperation already rejects unknown kinds at runtime before crafting.
function assertNever(op: never): never {
throw new UnsupportedOperationKind('unsupported operation kind', {
kind: String((op as { kind?: unknown }).kind),
})
}
/**
* Honour any fee/gas/storage the caller already provided; fall back to the batch
* estimate for the missing ones.
*/
function resolveLimits(
op: PartialTezosOperation,
minFees: number,
estimate?: Estimate
): ResolvedLimits {
if (op.fee !== undefined && op.gas_limit !== undefined && op.storage_limit !== undefined) {
return { fee: op.fee, gasLimit: op.gas_limit, storageLimit: op.storage_limit }
}
if (!estimate) {
throw new Error('craftRawOperations: missing fee estimation for operation')
}
return {
fee: op.fee ?? Math.max(minFees, estimate.suggestedFeeMutez).toString(),
gasLimit: op.gas_limit ?? estimate.gasLimit.toString(),
storageLimit: op.storage_limit ?? estimate.storageLimit.toString(),
}
}