Skip to content

Commit e20c160

Browse files
committed
feat: add tx chaining
1 parent 240c5de commit e20c160

File tree

5 files changed

+253
-56
lines changed

5 files changed

+253
-56
lines changed
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"
2+
import * as Cluster from "@evolution-sdk/devnet/Cluster"
3+
import * as Config from "@evolution-sdk/devnet/Config"
4+
import * as Genesis from "@evolution-sdk/devnet/Genesis"
5+
import { Core } from "@evolution-sdk/evolution"
6+
import * as Address from "@evolution-sdk/evolution/core/Address"
7+
import type { SignBuilder } from "@evolution-sdk/evolution/sdk/builders/SignBuilder"
8+
import { createClient } from "@evolution-sdk/evolution/sdk/client/ClientImpl"
9+
10+
describe("TxBuilder.chainResult", () => {
11+
let devnetCluster: Cluster.Cluster | undefined
12+
let genesisConfig: Config.ShelleyGenesis
13+
let genesisUtxos: ReadonlyArray<Core.UTxO.UTxO> = []
14+
15+
const TEST_MNEMONIC =
16+
"test test test test test test test test test test test test test test test test test test test test test test test sauce"
17+
18+
const createTestClient = (accountIndex: number = 0) => {
19+
if (!devnetCluster) throw new Error("Cluster not initialized")
20+
const slotConfig = Cluster.getSlotConfig(devnetCluster)
21+
return createClient({
22+
network: 0,
23+
slotConfig,
24+
provider: {
25+
type: "kupmios",
26+
kupoUrl: "http://localhost:1449",
27+
ogmiosUrl: "http://localhost:1344"
28+
},
29+
wallet: {
30+
type: "seed",
31+
mnemonic: TEST_MNEMONIC,
32+
accountIndex,
33+
addressType: "Base"
34+
}
35+
})
36+
}
37+
38+
beforeAll(async () => {
39+
const tempClient = createClient({
40+
network: 0,
41+
wallet: { type: "seed", mnemonic: TEST_MNEMONIC, accountIndex: 0, addressType: "Base" }
42+
})
43+
44+
const testAddress = await tempClient.address()
45+
const testAddressHex = Address.toHex(testAddress)
46+
47+
genesisConfig = {
48+
...Config.DEFAULT_SHELLEY_GENESIS,
49+
slotLength: 0.02,
50+
epochLength: 50,
51+
activeSlotsCoeff: 1.0,
52+
initialFunds: { [testAddressHex]: 500_000_000_000 }
53+
}
54+
55+
genesisUtxos = await Genesis.calculateUtxosFromConfig(genesisConfig)
56+
57+
devnetCluster = await Cluster.make({
58+
clusterName: "chain-test",
59+
ports: { node: 6008, submit: 9009 },
60+
shelleyGenesis: genesisConfig,
61+
kupo: { enabled: true, port: 1449, logLevel: "Info" },
62+
ogmios: { enabled: true, port: 1344, logLevel: "info" }
63+
})
64+
65+
await Cluster.start(devnetCluster)
66+
await new Promise((resolve) => setTimeout(resolve, 5_000))
67+
}, 180_000)
68+
69+
afterAll(async () => {
70+
if (devnetCluster) {
71+
await Cluster.stop(devnetCluster)
72+
await Cluster.remove(devnetCluster)
73+
}
74+
}, 60_000)
75+
76+
it("should chain multiple transactions and submit them all", { timeout: 90_000 }, async () => {
77+
const client = createTestClient(0)
78+
const address = await client.address()
79+
const TX_COUNT = 5
80+
81+
// Build chained transactions using build() + chainResult
82+
let available = [...genesisUtxos]
83+
const txs: Array<SignBuilder> = []
84+
85+
for (let i = 0; i < TX_COUNT; i++) {
86+
const tx = await client
87+
.newTx()
88+
.payToAddress({ address, assets: Core.Assets.fromLovelace(10_000_000n) })
89+
.build({ availableUtxos: available })
90+
txs.push(tx)
91+
available = [...tx.chainResult().available]
92+
}
93+
94+
// Verify all txHashes are unique
95+
const txHashes = txs.map((tx) => tx.chainResult().txHash)
96+
expect(new Set(txHashes).size).toBe(TX_COUNT)
97+
98+
// Submit all transactions
99+
const submittedHashes: Array<string> = []
100+
for (const tx of txs) {
101+
const hash = await tx.signAndSubmit()
102+
submittedHashes.push(hash)
103+
}
104+
105+
// Verify computed hashes match submitted hashes
106+
for (let i = 0; i < TX_COUNT; i++) {
107+
expect(submittedHashes[i]).toBe(txs[i].chainResult().txHash)
108+
}
109+
110+
// Wait for all to confirm
111+
for (const hash of submittedHashes) {
112+
expect(await client.awaitTx(hash, 1000)).toBe(true)
113+
}
114+
})
115+
})

packages/evolution-devnet/test/utils/utxo-helpers.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import { Core } from "@evolution-sdk/evolution"
22
import * as CoreAddress from "@evolution-sdk/evolution/core/Address"
33
import * as CoreData from "@evolution-sdk/evolution/core/Data"
44
import * as CoreDatumOption from "@evolution-sdk/evolution/core/DatumOption"
5-
import * as CoreScript from "@evolution-sdk/evolution/core/Script"
6-
import * as CoreScriptRef from "@evolution-sdk/evolution/core/ScriptRef"
5+
import type * as CoreScript from "@evolution-sdk/evolution/core/Script"
76
import * as CoreTransactionHash from "@evolution-sdk/evolution/core/TransactionHash"
87
import * as CoreUTxO from "@evolution-sdk/evolution/core/UTxO"
98
import type * as Datum from "@evolution-sdk/evolution/sdk/Datum"
@@ -103,20 +102,12 @@ export const createCoreTestUtxo = (options: CreateCoreTestUtxoOptions): CoreUTxO
103102
}
104103
}
105104

106-
// Convert Core Script to ScriptRef
107-
let coreScriptRef: CoreScriptRef.ScriptRef | undefined
108-
if (scriptRef) {
109-
// Convert Script to ScriptRef bytes (CBOR-encoded script)
110-
const scriptBytes = CoreScript.toCBOR(scriptRef)
111-
coreScriptRef = new CoreScriptRef.ScriptRef({ bytes: scriptBytes })
112-
}
113-
114105
return new CoreUTxO.UTxO({
115106
transactionId: CoreTransactionHash.fromHex(paddedTxId),
116107
index: BigInt(index),
117108
address: CoreAddress.fromBech32(address),
118109
assets,
119-
scriptRef: coreScriptRef,
110+
scriptRef,
120111
datumOption: coreDatumOption
121112
})
122113
}

packages/evolution/src/sdk/builders/SignBuilder.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type * as Transaction from "../../core/Transaction.js"
44
import type * as TransactionWitnessSet from "../../core/TransactionWitnessSet.js"
55
import type { EffectToPromiseAPI } from "../Type.js"
66
import type { SubmitBuilder } from "./SubmitBuilder.js"
7-
import type { TransactionBuilderError } from "./TransactionBuilder.js"
7+
import type { ChainResult, TransactionBuilderError } from "./TransactionBuilder.js"
88
import type { TransactionResultBase } from "./TransactionResult.js"
99

1010
// ============================================================================
@@ -27,6 +27,7 @@ export interface SignBuilderEffect {
2727

2828
// Signing methods
2929
readonly sign: () => Effect.Effect<SubmitBuilder, TransactionBuilderError>
30+
readonly signAndSubmit: () => Effect.Effect<string, TransactionBuilderError>
3031
readonly signWithWitness: (
3132
witnessSet: TransactionWitnessSet.TransactionWitnessSet
3233
) => Effect.Effect<SubmitBuilder, TransactionBuilderError>
@@ -43,9 +44,27 @@ export interface SignBuilderEffect {
4344
* Only available when the client has a signing wallet (seed, private key, or API wallet).
4445
* Provides access to unsigned transaction (via base interface) and signing operations.
4546
*
47+
* Includes `chainResult` for transaction chaining - use `chainResult.available` as
48+
* `availableUtxos` for the next transaction in a chain.
49+
*
4650
* @since 2.0.0
4751
* @category interfaces
4852
*/
4953
export interface SignBuilder extends TransactionResultBase, EffectToPromiseAPI<SignBuilderEffect> {
5054
readonly Effect: SignBuilderEffect
55+
/**
56+
* Compute chain result for building dependent transactions.
57+
* Contains consumed UTxOs, available UTxOs (remaining + created), and txHash.
58+
*
59+
* Result is memoized - computed once on first call, cached for subsequent calls.
60+
*
61+
* @example
62+
* ```typescript
63+
* const tx1 = await client.newTx().payToAddress(...).build({ availableUtxos: walletUtxos })
64+
* const tx2 = await client.newTx().payToAddress(...).build({ availableUtxos: tx1.chainResult().available })
65+
* await tx1.sign().submit()
66+
* await tx2.sign().submit()
67+
* ```
68+
*/
69+
readonly chainResult: () => ChainResult
5170
}

packages/evolution/src/sdk/builders/SignBuilderImpl.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,18 @@
1616

1717
import { Effect } from "effect"
1818

19+
import * as Script from "../../core/Script.js"
1920
import * as Transaction from "../../core/Transaction.js"
21+
import * as TransactionHash from "../../core/TransactionHash.js"
2022
import * as TransactionWitnessSet from "../../core/TransactionWitnessSet.js"
21-
import type * as CoreUTxO from "../../core/UTxO.js"
23+
import type * as TxOut from "../../core/TxOut.js"
24+
import * as CoreUTxO from "../../core/UTxO.js"
25+
import { hashTransaction } from "../../utils/Hash.js"
2226
import type * as Provider from "../provider/Provider.js"
2327
import type * as WalletNew from "../wallet/WalletNew.js"
2428
import type { SignBuilder, SignBuilderEffect } from "./SignBuilder.js"
2529
import { makeSubmitBuilder } from "./SubmitBuilderImpl.js"
26-
import { TransactionBuilderError } from "./TransactionBuilder.js"
30+
import { type ChainResult, TransactionBuilderError } from "./TransactionBuilder.js"
2731

2832
// ============================================================================
2933
// SignBuilder Factory
@@ -48,8 +52,38 @@ export const makeSignBuilder = (params: {
4852
referenceUtxos: ReadonlyArray<CoreUTxO.UTxO>
4953
provider: Provider.Provider
5054
wallet: Wallet
55+
// Data for lazy chainResult computation
56+
outputs: ReadonlyArray<TxOut.TransactionOutput>
57+
availableUtxos: ReadonlyArray<CoreUTxO.UTxO>
5158
}): SignBuilder => {
52-
const { fee, provider, referenceUtxos, transaction, transactionWithFakeWitnesses, utxos, wallet } = params
59+
const { availableUtxos, fee, outputs, provider, referenceUtxos, transaction, transactionWithFakeWitnesses, utxos, wallet } = params
60+
61+
// Memoized chainResult - computed once on first access
62+
let _chainResult: ChainResult | undefined
63+
const chainResult = (): ChainResult => {
64+
if (_chainResult) return _chainResult
65+
66+
const consumed = utxos
67+
const txHash = hashTransaction(transaction.body)
68+
69+
const created: Array<CoreUTxO.UTxO> = outputs.map((output, index) =>
70+
new CoreUTxO.UTxO({
71+
transactionId: txHash,
72+
index: BigInt(index),
73+
address: output.address,
74+
assets: output.assets,
75+
datumOption: output.datumOption,
76+
scriptRef: output.scriptRef ? Script.fromCBOR(output.scriptRef.bytes) : undefined
77+
})
78+
)
79+
80+
const consumedSet = new Set(consumed.map((u) => CoreUTxO.toOutRefString(u)))
81+
const remaining = availableUtxos.filter((u) => !consumedSet.has(CoreUTxO.toOutRefString(u)))
82+
const available = [...remaining, ...created]
83+
84+
_chainResult = { consumed, available, txHash: TransactionHash.toHex(txHash) }
85+
return _chainResult
86+
}
5387

5488
// ============================================================================
5589
// Effect Namespace Implementation
@@ -108,6 +142,18 @@ export const makeSignBuilder = (params: {
108142
return makeSubmitBuilder(signedTransaction, mergedWitnessSet, provider)
109143
}),
110144

145+
/**
146+
* Sign and submit the transaction in one step.
147+
*
148+
* Convenience method that combines sign() and submit().
149+
* Returns the transaction hash on success.
150+
*/
151+
signAndSubmit: () =>
152+
Effect.gen(function* () {
153+
const submitBuilder = yield* signEffect.sign()
154+
return yield* submitBuilder.Effect.submit()
155+
}),
156+
111157
/**
112158
* Sign the transaction using a pre-created witness set.
113159
*
@@ -236,7 +282,9 @@ export const makeSignBuilder = (params: {
236282

237283
return {
238284
Effect: signEffect,
285+
chainResult,
239286
sign: () => Effect.runPromise(signEffect.sign()),
287+
signAndSubmit: () => Effect.runPromise(signEffect.signAndSubmit()),
240288
signWithWitness: (witnessSet: TransactionWitnessSet.TransactionWitnessSet) =>
241289
Effect.runPromise(signEffect.signWithWitness(witnessSet)),
242290
assemble: (witnesses: ReadonlyArray<TransactionWitnessSet.TransactionWitnessSet>) =>

0 commit comments

Comments
 (0)