@ledgerhq/coin-tezos
Version:
233 lines • 10.9 kB
JavaScript
;
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
Object.defineProperty(exports, "__esModule", { value: true });
exports.craftRawOperations = craftRawOperations;
const rpc_1 = require("@taquito/rpc");
const utils_1 = require("@taquito/utils");
const errors_1 = require("../types/errors");
const utils_2 = require("../utils");
const craftTransaction_1 = require("./craftTransaction");
const estimateRevealLimits_1 = require("./estimateRevealLimits");
const tezosToolkit_1 = require("./tezosToolkit");
/**
* 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.
*/
async function craftRawOperations(context, rawTransaction, sender, publicKey, sequence) {
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 = (0, tezosToolkit_1.getTezosToolkit)(config);
const normalizedPk = (0, utils_2.normalizePublicKeyForAddress)(publicKey, sender);
// Mock signer lets Taquito estimate without a connected device.
tezosToolkit.setProvider({ signer: (0, utils_2.createMockSigner)(sender, normalizedPk ?? '') });
const feesConfig = config.fees;
const minFees = feesConfig.minFees;
let counter = Number(sequence);
const contents = [];
// 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 || (0, utils_1.validatePublicKey)(normalizedPk) !== utils_1.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 ((0, utils_1.getPkhfromPk)(normalizedPk) !== sender) {
throw new Error('craftRawOperations: public key does not match the sender address');
}
const reveal = await (0, estimateRevealLimits_1.estimateRevealLimits)(tezosToolkit, sender, feesConfig);
contents.push({
kind: rpc_1.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 (0, craftTransaction_1.rawEncode)(config, contents);
}
function parseOperations(rawTransaction) {
let parsed;
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) {
if (!op || typeof op !== 'object') {
throw new Error('craftRawOperations: invalid operation');
}
const candidate = op;
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;
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;
default:
throw new errors_1.UnsupportedOperationKind('unsupported operation kind', {
kind: String(candidate.kind),
});
}
}
function isValidAddress(value) {
return typeof value === 'string' && (0, utils_1.validateAddress)(value) === utils_1.ValidationResult.VALID;
}
function isImplicitAddress(value) {
return typeof value === 'string' && (0, utils_1.validateKeyHash)(value) === utils_1.ValidationResult.VALID;
}
function assertMutezAmount(amount) {
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) {
for (const field of ['fee', 'gas_limit', 'storage_limit']) {
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, sender, needsReveal, tezosToolkit) {
const needsEstimation = operations.some((op) => op.fee === undefined || op.gas_limit === undefined || op.storage_limit === undefined);
if (!needsEstimation)
return undefined;
const params = 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, sender) {
switch (op.kind) {
case 'transaction':
return {
kind: rpc_1.OpKind.TRANSACTION,
source: sender,
to: op.destination,
amount: Number(op.amount),
mutez: true,
...(op.parameters ? { parameter: op.parameters } : {}),
};
case 'delegation':
return {
kind: rpc_1.OpKind.DELEGATION,
source: sender,
...(op.delegate === undefined ? {} : { delegate: op.delegate }),
};
default:
return assertNever(op);
}
}
function buildOperationContent(op, sender, counter, minFees, estimate) {
const limits = resolveLimits(op, minFees, estimate);
switch (op.kind) {
case 'transaction':
return {
kind: rpc_1.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: rpc_1.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) {
throw new errors_1.UnsupportedOperationKind('unsupported operation kind', {
kind: String(op.kind),
});
}
/**
* Honour any fee/gas/storage the caller already provided; fall back to the batch
* estimate for the missing ones.
*/
function resolveLimits(op, minFees, estimate) {
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(),
};
}
//# sourceMappingURL=craftRawOperations.js.map