Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slimy-nails-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@evolution-sdk/evolution": patch
---

fix preserve original CBOR bytes when signing hex transactions
57 changes: 55 additions & 2 deletions packages/evolution/src/PrivateKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as BIP39 from "@scure/bip39"
import { wordlist } from "@scure/bip39/wordlists/english"
import { Either as E, Equal, FastCheck, Hash, Inspectable, ParseResult, Schema } from "effect"

import * as Bip32PrivateKey from "./Bip32PrivateKey.js"
import * as Bytes from "./Bytes.js"
import * as Bytes32 from "./Bytes32.js"
import * as Bytes64 from "./Bytes64.js"
Expand Down Expand Up @@ -235,8 +236,11 @@ export const validateMnemonic = (mnemonic: string): boolean => BIP39.validateMne

/**
* Create a PrivateKey from a mnemonic phrase (sync version that throws PrivateKeyError).
* All errors are normalized to PrivateKeyError with contextual information.
*
* **WARNING**: This uses secp256k1 BIP32 derivation (`@scure/bip32`), NOT Cardano's
* BIP32-Ed25519. For Cardano key derivation, use {@link fromMnemonicCardano} instead.
*
* @deprecated Use {@link fromMnemonicCardano} for Cardano, or `Bip32PrivateKey` for full control.
* @since 2.0.0
* @category bip39
*/
Expand All @@ -248,8 +252,11 @@ export const fromMnemonic = (mnemonic: string, password?: string): PrivateKey =>

/**
* Derive a child private key using BIP32 path (sync version that throws PrivateKeyError).
* All errors are normalized to PrivateKeyError with contextual information.
*
* **WARNING**: This uses secp256k1 BIP32 derivation (`@scure/bip32`), NOT Cardano's
* BIP32-Ed25519. For Cardano key derivation, use {@link fromMnemonicCardano} instead.
*
* @deprecated Use {@link fromMnemonicCardano} for Cardano, or `Bip32PrivateKey` for full control.
* @since 2.0.0
* @category bip32
*/
Expand All @@ -259,6 +266,46 @@ export const derive = (privateKey: PrivateKey, path: string): PrivateKey => {
})
}

/**
* Derive a Cardano payment or stake key from a mnemonic using BIP32-Ed25519.
*
* This is the correct way to derive Cardano keys from a mnemonic. It uses the
* Icarus/V2 BIP32-Ed25519 derivation scheme, matching CML and cardano-cli behavior.
*
* @example
* ```ts
* // Payment key (default: account 0, index 0)
* const paymentKey = PrivateKey.fromMnemonicCardano(mnemonic)
*
* // Stake key
* const stakeKey = PrivateKey.fromMnemonicCardano(mnemonic, { role: 2 })
*
* // Custom account/index
* const key = PrivateKey.fromMnemonicCardano(mnemonic, { account: 1, index: 3 })
* ```
*
* @since 2.0.0
* @category cardano
*/
export const fromMnemonicCardano = (
mnemonic: string,
options?: { account?: number; role?: 0 | 2; index?: number; password?: string },
): PrivateKey => {
if (!validateMnemonic(mnemonic)) {
throw new PrivateKeyError("Invalid mnemonic phrase")
}
const entropy = BIP39.mnemonicToEntropy(mnemonic, wordlist)
// mnemonicToEntropy returns hex string; fromBip39Entropy accepts it via pbkdf2
const rootXPrv = Bip32PrivateKey.fromBip39Entropy(entropy as unknown as Uint8Array, options?.password ?? "")
const indices = Bip32PrivateKey.CardanoPath.indices(
options?.account ?? 0,
options?.role ?? 0,
options?.index ?? 0,
)
const childNode = Bip32PrivateKey.derive(rootXPrv, indices)
return Bip32PrivateKey.toPrivateKey(childNode)
}

// ============================================================================
// Cryptographic Operations
// ============================================================================
Expand All @@ -281,6 +328,12 @@ export const sign = (privateKey: PrivateKey, message: Uint8Array): Ed25519Signat
/**
* Cardano BIP44 derivation path utilities.
*
* **WARNING**: These paths are only useful with BIP32-Ed25519 derivation
* (`Bip32PrivateKey`). Using them with {@link derive} (which uses secp256k1 BIP32)
* will produce incorrect keys. Use {@link fromMnemonicCardano} or
* `Bip32PrivateKey.CardanoPath` instead.
*
* @deprecated Use {@link fromMnemonicCardano} or `Bip32PrivateKey.CardanoPath`.
* @since 2.0.0
* @category cardano
*/
Expand Down
18 changes: 18 additions & 0 deletions packages/evolution/src/Transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,24 @@ export const addVKeyWitnessesHex = (
return Schema.encodeSync(Schema.Uint8ArrayFromHex)(result)
}

// ============================================================================
// Raw body bytes extraction
// ============================================================================

/**
* Extract the original body bytes from a raw transaction CBOR byte array.
* A Cardano transaction is a 4-element CBOR array: `[body, witnessSet, isValid, auxiliaryData]`.
* This returns the raw body bytes without decoding/re-encoding, preserving the exact CBOR encoding.
*
* @since 2.0.0
* @category encoding
*/
export const extractBodyBytes = (txBytes: Uint8Array): Uint8Array => {
const arrHdr = cborHeaderSize(txBytes, 0)
const { newOffset: bodyEnd } = CBOR.decodeItemWithOffset(txBytes, arrHdr)
return txBytes.subarray(arrHdr, bodyEnd)
}

// ============================================================================
// Domain-level witness addition
// ============================================================================
Expand Down
30 changes: 19 additions & 11 deletions packages/evolution/src/sdk/client/ClientImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import * as Transaction from "../../Transaction.js"
import * as TransactionHash from "../../TransactionHash.js"
import * as TransactionWitnessSet from "../../TransactionWitnessSet.js"
import { runEffectPromise } from "../../utils/effect-runtime.js"
import { hashTransaction } from "../../utils/Hash.js"
import { hashTransaction, hashTransactionRaw } from "../../utils/Hash.js"
import * as CoreUTxO from "../../UTxO.js"
import * as VKey from "../../VKey.js"
import {
Expand Down Expand Up @@ -378,7 +378,11 @@ const createSigningWallet = (network: WalletNew.Network, config: SeedWalletConfi
})

// Build witnesses for keys we have
const txHash = hashTransaction(tx.body)
// When input is a hex string, hash the original CBOR bytes to preserve encoding.
// Re-encoding via hashTransaction(tx.body) can produce different bytes and a wrong hash.
const txHash = typeof txOrHex === "string"
? hashTransactionRaw(Transaction.extractBodyBytes(Bytes.fromHex(txOrHex)))
: hashTransaction(tx.body)
const msg = txHash.hash

const witnesses: Array<TransactionWitnessSet.VKeyWitness> = []
Expand Down Expand Up @@ -465,7 +469,9 @@ const createPrivateKeyWallet = (
referenceUtxos
})

const txHash = hashTransaction(tx.body)
const txHash = typeof txOrHex === "string"
? hashTransactionRaw(Transaction.extractBodyBytes(Bytes.fromHex(txOrHex)))
: hashTransaction(tx.body)
const msg = txHash.hash

const witnesses: Array<TransactionWitnessSet.VKeyWitness> = []
Expand Down Expand Up @@ -676,12 +682,19 @@ const createSigningClient = (
? createPrivateKeyWallet(walletNetwork, walletConfig)
: createApiWallet(walletNetwork, walletConfig)

// Enhanced signTx that automatically fetches reference UTxOs from the network
// Enhanced signTx that automatically fetches reference UTxOs from the network.
// Passes the original txOrHex through to wallet.Effect.signTx to preserve CBOR bytes for hashing.
const signTxWithAutoFetch = (
txOrHex: Transaction.Transaction | string,
context?: { utxos?: ReadonlyArray<CoreUTxO.UTxO>; referenceUtxos?: ReadonlyArray<CoreUTxO.UTxO> }
): Effect.Effect<TransactionWitnessSet.TransactionWitnessSet, WalletNew.WalletError> =>
Effect.gen(function* () {
// If referenceUtxos already provided, pass original txOrHex through
if (context?.referenceUtxos && context.referenceUtxos.length > 0) {
return yield* wallet.Effect.signTx(txOrHex, context)
}

// Decode to Transaction only if we need to check for reference inputs
const tx =
typeof txOrHex === "string"
? yield* ParseResult.decodeUnknownEither(Transaction.FromCBORHex())(txOrHex).pipe(
Expand All @@ -691,23 +704,18 @@ const createSigningClient = (
)
: txOrHex

// If referenceUtxos already provided, use them directly
if (context?.referenceUtxos && context.referenceUtxos.length > 0) {
return yield* wallet.Effect.signTx(tx, context)
}

// Auto-fetch reference UTxOs from the network if the transaction has reference inputs
let referenceUtxos: ReadonlyArray<CoreUTxO.UTxO> = []
if (tx.body.referenceInputs && tx.body.referenceInputs.length > 0) {
// Fetch reference UTxOs from the provider
referenceUtxos = yield* provider.Effect.getUtxosByOutRef(tx.body.referenceInputs).pipe(
Effect.mapError(
(e) => new WalletNew.WalletError({ message: `Failed to fetch reference UTxOs: ${e.message}`, cause: e })
)
)
}

return yield* wallet.Effect.signTx(tx, { ...context, referenceUtxos })
// Pass original txOrHex through to preserve CBOR bytes for hashing
return yield* wallet.Effect.signTx(txOrHex, { ...context, referenceUtxos })
})

const effectInterface = {
Expand Down
9 changes: 9 additions & 0 deletions packages/evolution/src/utils/Hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ export const hashTransaction = (body: TransactionBody.TransactionBody): Transact
return new TransactionHash.TransactionHash({ hash: digest })
}

/**
* Compute the transaction body hash from raw CBOR bytes, preserving original encoding.
* Uses `Transaction.extractBodyBytes` to avoid the decode→re-encode round-trip.
*/
export const hashTransactionRaw = (bodyBytes: Uint8Array): TransactionHash.TransactionHash => {
const digest = blake2b(bodyBytes, { dkLen: 32 })
return new TransactionHash.TransactionHash({ hash: digest })
}

/**
* script_data per CDDL (Conway)
*
Expand Down
46 changes: 46 additions & 0 deletions packages/evolution/test/PrivateKey.CML.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as CML from "@dcspark/cardano-multiplatform-lib-nodejs"
import { describe, expect, it } from "vitest"

import * as Bip32PrivateKey from "../src/Bip32PrivateKey"
import * as PrivateKey from "../src/PrivateKey"
import * as VKey from "../src/VKey"

Expand Down Expand Up @@ -421,4 +422,49 @@ describe("PrivateKey CML Compatibility", () => {
expect(crossVerifyCml).toBe(true)
})
})

describe("fromMnemonicCardano", () => {
const testMnemonic =
"fault emerge ignore athlete extend awful elevator version anchor print balance asset exit main lawn embrace fresh stock marine exhibit plug bulb brown own"

it("should produce the same key as Bip32PrivateKey derivation", () => {
// Derive via fromMnemonicCardano
const paymentKey = PrivateKey.fromMnemonicCardano(testMnemonic)

// Derive via Bip32PrivateKey (the known-correct path used by createClient)
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { mnemonicToEntropy } = require("@scure/bip39") as typeof import("@scure/bip39")
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { wordlist } = require("@scure/bip39/wordlists/english") as typeof import("@scure/bip39/wordlists/english")
const entropy = mnemonicToEntropy(testMnemonic, wordlist)
const rootXPrv = Bip32PrivateKey.fromBip39Entropy(entropy as unknown as Uint8Array, "")
const paymentNode = Bip32PrivateKey.derive(rootXPrv, Bip32PrivateKey.CardanoPath.paymentIndices(0, 0))
const expected = Bip32PrivateKey.toPrivateKey(paymentNode)

expect(PrivateKey.toHex(paymentKey)).toBe(PrivateKey.toHex(expected))
})

it("should derive different keys for payment vs stake roles", () => {
const paymentKey = PrivateKey.fromMnemonicCardano(testMnemonic, { role: 0 })
const stakeKey = PrivateKey.fromMnemonicCardano(testMnemonic, { role: 2 })

expect(PrivateKey.toHex(paymentKey)).not.toBe(PrivateKey.toHex(stakeKey))
})

it("should produce a key different from the deprecated fromMnemonic + derive", () => {
// The old (broken) path
const oldRoot = PrivateKey.fromMnemonic(testMnemonic)
const oldKey = PrivateKey.derive(oldRoot, PrivateKey.CardanoPath.payment())

// The new (correct) path
const newKey = PrivateKey.fromMnemonicCardano(testMnemonic)

// These MUST differ — the old path uses secp256k1, the new uses Ed25519
expect(PrivateKey.toHex(oldKey)).not.toBe(PrivateKey.toHex(newKey))
})

it("should throw on invalid mnemonic", () => {
expect(() => PrivateKey.fromMnemonicCardano("invalid mnemonic")).toThrow("Invalid mnemonic phrase")
})
})
})
Loading