Skip to content
Open
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/batch-signing-scope-reference-utxos.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@evolution-sdk/evolution": patch
---

Scope reference UTxOs per transaction when signing (#483). Batch signing (`signTxsWithAutoFetch`) fetched the union of every transaction's reference inputs and passed the combined list to each transaction's signing call, so a native script referenced by one transaction could add an unneeded witness to an unrelated transaction in the batch. The batch path now signs each transaction with only its own reference UTxOs (the combined fetch is kept), and required-signer computation itself only counts reference UTxOs the transaction actually references, matching the single-transaction path.
29 changes: 28 additions & 1 deletion packages/evolution/src/sdk/client/internal/Signing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,18 @@ const computeRequiredKeyHashesSync = (params: {
}

if (params.referenceUtxos) {
// Only reference UTxOs this transaction actually references can require a
// signer for it. Callers may hand over a wider set (batch signing fetches
// the union of every transaction's reference inputs); counting scripts
// from UTxOs belonging to other transactions would add witnesses this
// transaction never asked for (#483).
const referenced = new Set(
(params.tx.body.referenceInputs ?? []).map((ref) => `${TransactionHash.toHex(ref.transactionId)}#${ref.index}`)
)
for (const utxo of params.referenceUtxos) {
if (!referenced.has(CoreUTxO.toOutRefString(utxo))) {
continue
}
if (utxo.scriptRef && utxo.scriptRef._tag === "NativeScript") {
const keyHashes = extractKeyHashesFromNativeScript(utxo.scriptRef.script)
for (const keyHash of keyHashes) {
Expand Down Expand Up @@ -219,6 +230,7 @@ export const signTxsWithAutoFetch = (
// Collect all reference inputs across all transactions and fetch them once
const seen = new Set<string>()
const allRefInputs: Array<TransactionBody.TransactionBody["inputs"][number]> = []
const decodedTxs: Array<Transaction.Transaction> = []
for (const txOrHex of txs) {
const tx =
typeof txOrHex === "string"
Expand All @@ -228,6 +240,7 @@ export const signTxsWithAutoFetch = (
)
)
: txOrHex
decodedTxs.push(tx)
if (tx.body.referenceInputs) {
for (const ref of tx.body.referenceInputs) {
const key = `${ref.transactionId}#${ref.index}`
Expand All @@ -252,7 +265,21 @@ export const signTxsWithAutoFetch = (
)
}

return yield* wallet.effect.signTxs(txs, { ...context, referenceUtxos })
// Sign each transaction with only its own reference UTxOs, matching the
// single-transaction path: the combined fetch above is an optimisation,
// not a licence for one transaction's reference scripts to add witnesses
// to another transaction in the batch (#483).
return yield* Effect.all(
txs.map((txOrHex, index) => {
const referenced = new Set(
(decodedTxs[index].body.referenceInputs ?? []).map(
(ref) => `${TransactionHash.toHex(ref.transactionId)}#${ref.index}`
)
)
const scopedReferenceUtxos = referenceUtxos.filter((utxo) => referenced.has(CoreUTxO.toOutRefString(utxo)))
return wallet.effect.signTx(txOrHex, { ...context, referenceUtxos: scopedReferenceUtxos })
})
)
})

export const signWithAutoFetch = (
Expand Down
115 changes: 115 additions & 0 deletions packages/evolution/test/Signing.BatchReferenceUtxos.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Effect } from "effect"
import { describe, expect, it } from "vitest"

import * as CoreAddress from "../src/Address.js"
import * as CoreAssets from "../src/Assets.js"
import * as KeyHash from "../src/KeyHash.js"
import * as NativeScripts from "../src/NativeScripts.js"
import * as PrivateKey from "../src/PrivateKey.js"
import { preprod } from "../src/sdk/client/Chain.js"
import { signTxsWithAutoFetch, signWithAutoFetch } from "../src/sdk/client/internal/Signing.js"
import { seedWallet } from "../src/sdk/client/internal/Wallets.js"
import type * as Provider from "../src/sdk/provider/Provider.js"
import { keysFromSeed } from "../src/sdk/wallet/Derivation.js"
import * as Transaction from "../src/Transaction.js"
import * as TransactionBody from "../src/TransactionBody.js"
import * as TransactionHash from "../src/TransactionHash.js"
import * as TransactionInput from "../src/TransactionInput.js"
import * as TransactionWitnessSet from "../src/TransactionWitnessSet.js"
import * as CoreUTxO from "../src/UTxO.js"

const mnemonic =
"zebra short room flavor rival capital fortune hip profit trust melody office depend adapt visa cycle february link tornado whisper physical kiwi film voyage"

const keys = keysFromSeed(mnemonic)
const paymentKey = PrivateKey.fromBech32(keys.paymentKey)
const paymentKh = KeyHash.fromPrivateKey(paymentKey)

// A reference UTxO carrying a native script that names the wallet's payment
// key hash: any transaction that (wrongly) counts it as a required signer
// source will produce a vkey witness from this wallet.
const scriptTxId = TransactionHash.fromHex("aa".repeat(32))
const scriptUtxo = new CoreUTxO.UTxO({
transactionId: scriptTxId,
index: 0n,
address: new CoreAddress.Address({ networkId: 0, paymentCredential: KeyHash.fromHex("00".repeat(28)) }),
assets: CoreAssets.fromLovelace(2_000_000n),
scriptRef: NativeScripts.makeScriptPubKey(paymentKh.hash)
})
const scriptRefInput = new TransactionInput.TransactionInput({ transactionId: scriptTxId, index: 0n })

const plainTx = (): Transaction.Transaction =>
new Transaction.Transaction({
body: new TransactionBody.TransactionBody({ inputs: [], outputs: [], fee: 200_000n }),
witnessSet: TransactionWitnessSet.empty(),
isValid: true,
auxiliaryData: null
})

const referencingTx = (): Transaction.Transaction =>
new Transaction.Transaction({
body: new TransactionBody.TransactionBody({
inputs: [],
outputs: [],
fee: 200_000n,
referenceInputs: [scriptRefInput]
}),
witnessSet: TransactionWitnessSet.empty(),
isValid: true,
auxiliaryData: null
})

// Provider stub: resolves any requested out ref to the script UTxO above.
const stubProvider = {
effect: {
getUtxosByOutRef: (inputs: ReadonlyArray<TransactionInput.TransactionInput>) =>
Effect.succeed(inputs.map(() => scriptUtxo))
}
} as unknown as Provider.Provider

const witnessKeyHashHexes = (ws: TransactionWitnessSet.TransactionWitnessSet): Array<string> =>
(ws.vkeyWitnesses ?? []).map((w) => KeyHash.toHex(KeyHash.fromVKey(w.vkey)))

describe("batch signing scopes reference UTxOs per transaction (#483)", () => {
it("a transaction that does not reference the script UTxO is not signed for it", async () => {
const wallet = seedWallet({ mnemonic })(preprod)

const results = await Effect.runPromise(signTxsWithAutoFetch(stubProvider, wallet, [plainTx(), referencingTx()]))

expect(witnessKeyHashHexes(results[0])).toEqual([])
expect(witnessKeyHashHexes(results[1])).toEqual([KeyHash.toHex(paymentKh)])
})

it("scoping holds regardless of batch order", async () => {
const wallet = seedWallet({ mnemonic })(preprod)

const results = await Effect.runPromise(signTxsWithAutoFetch(stubProvider, wallet, [referencingTx(), plainTx()]))

expect(witnessKeyHashHexes(results[0])).toEqual([KeyHash.toHex(paymentKh)])
expect(witnessKeyHashHexes(results[1])).toEqual([])
})

it("signTx ignores reference UTxOs the transaction does not reference (explicit context)", async () => {
const wallet = seedWallet({ mnemonic })(preprod)

const witnessSet = await wallet.signTx(plainTx(), { referenceUtxos: [scriptUtxo] })

expect(witnessKeyHashHexes(witnessSet)).toEqual([])
})

it("signTx signs for a reference UTxO the transaction does reference (control)", async () => {
const wallet = seedWallet({ mnemonic })(preprod)

const witnessSet = await wallet.signTx(referencingTx(), { referenceUtxos: [scriptUtxo] })

expect(witnessKeyHashHexes(witnessSet)).toEqual([KeyHash.toHex(paymentKh)])
})

it("single-transaction auto-fetch still signs for its own reference script (control)", async () => {
const wallet = seedWallet({ mnemonic })(preprod)

const witnessSet = await Effect.runPromise(signWithAutoFetch(stubProvider, wallet, referencingTx()))

expect(witnessKeyHashHexes(witnessSet)).toEqual([KeyHash.toHex(paymentKh)])
})
})