Skip to content

Commit 3d0085f

Browse files
Merge pull request #200 from IntersectMBO/fix/provider-conformance
fix(provider): correct datum endpoint, delegation pool id, and UTxO mapping in Koios, Kupmios, Blockfrost
2 parents 7459dca + dc5c977 commit 3d0085f

17 files changed

Lines changed: 571 additions & 105 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@evolution-sdk/evolution": patch
3+
---
4+
5+
Fix several provider mapping bugs that caused incorrect or missing data in `getDelegation`, `getDatum`, and `getUtxos` responses.
6+
7+
**Koios**
8+
9+
- `getDelegation`: was decoding the pool ID with `PoolKeyHash.FromHex` but Koios returns a bech32 `pool1…` string — switched to `PoolKeyHash.FromBech32`
10+
- `getUtxos`: `datumOption` and `scriptRef` fields were never populated — all UTxOs returned `datumOption: null, scriptRef: null` regardless of on-chain state. Now correctly maps inline datums, datum hashes, and native/Plutus script references.
11+
12+
**Kupmios (Ogmios)**
13+
14+
- `getDelegation`: the Ogmios v6 response is an array, but the code was using `Object.values(result)[0]` which silently produced wrong data on some responses. Switched to `result[0]`. Also corrected the field path from `delegate.id` to `stakePool.id` to match the v6 schema, and decoded the bech32 pool ID through `Schema.decode(PoolKeyHash.FromBech32)` so the return type satisfies `Provider.Delegation`.
15+
16+
**Blockfrost**
17+
18+
- `getDatum`: was calling `/scripts/datum/{hash}` which returns only the data hash — should be `/scripts/datum/{hash}/cbor` to get the actual CBOR-encoded datum value. Switched endpoint and response schema to `BlockfrostDatumCbor`.

.env.test.local.example

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Provider integration test configuration
2+
# Copy this file to .env.test.local and fill in your keys.
3+
# .env.test.local is gitignored — never commit it.
4+
5+
# ── Koios ─────────────────────────────────────────────────────────────────────
6+
# Koios uses a public endpoint — no API key needed.
7+
# Set KOIOS_ENABLED=true to opt in (off by default to avoid rate limiting CI).
8+
# KOIOS_ENABLED=true
9+
# Optional: override the default public preprod endpoint
10+
# KOIOS_PREPROD_URL=https://preprod.koios.rest/api/v1
11+
12+
# ── Blockfrost ────────────────────────────────────────────────────────────────
13+
# Required to run Blockfrost tests. Get a key at https://blockfrost.io
14+
BLOCKFROST_PREPROD_KEY=preprodXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
15+
# Optional: override the default preprod endpoint
16+
# BLOCKFROST_PREPROD_URL=https://cardano-preprod.blockfrost.io/api/v0
17+
18+
# ── Maestro ───────────────────────────────────────────────────────────────────
19+
# Required to run Maestro tests. Get a key at https://gomaestro.org
20+
MAESTRO_PREPROD_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
21+
# Optional: override the default preprod endpoint
22+
# MAESTRO_PREPROD_URL=https://preprod.gomaestro-api.org/v1
23+
24+
# ── Kupmios (self-hosted or Demeter.run) ──────────────────────────────────────
25+
# Both URLs are required to run Kupmios tests.
26+
# If you use Demeter, provide one Kupo key and one Ogmios key.
27+
# Leave this section unset if you want Kupmios tests to stay skipped.
28+
KUPMIOS_KUPO_URL=https://your-kupo-endpoint
29+
KUPMIOS_OGMIOS_URL=https://your-ogmios-endpoint
30+
KUPMIOS_KUPO_KEY=your-kupo-key
31+
KUPMIOS_OGMIOS_KEY=your-ogmios-key
32+
33+
# Optional overrides if Kupo / Ogmios need different headers:
34+
# KUPMIOS_KUPO_HEADER_JSON={"dmtr-api-key":"your-kupo-key"}
35+
# KUPMIOS_OGMIOS_HEADER_JSON={"dmtr-api-key":"your-ogmios-key"}
36+
37+
# Self-hosted local example:
38+
# KUPMIOS_KUPO_URL=http://localhost:1442
39+
# KUPMIOS_OGMIOS_URL=http://localhost:1337
40+
# KUPMIOS_KUPO_KEY=
41+
# KUPMIOS_OGMIOS_KEY=

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,7 @@ docs/.twoslash
1818
# Ignore debug
1919
debug/
2020
CLAUDE.md
21+
22+
# Local env overrides — never commit API keys
23+
.env.local
24+
.env.test.local

docs/content/docs/transactions/meta.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"index",
55
"first-transaction",
66
"simple-payment",
7-
"multi-output"
7+
"multi-output",
8+
"retry-safe"
89
]
910
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
---
2+
title: "Retry-Safe Transactions"
3+
description: "Build transactions that automatically retry when the node rejects inputs due to indexer lag"
4+
---
5+
6+
# Retry-Safe Transactions
7+
8+
Structure your build-sign-submit pipeline so that retrying re-reads all chain state from scratch every time.
9+
10+
## The Problem
11+
12+
This is one of the most common challenges Cardano developers face when building applications that submit sequential transactions.
13+
14+
When you submit a transaction it doesn't immediately become part of the chain. It first enters the **mempool** of the node you submitted to. A block producer then picks it up and includes it in a new **block**. That block propagates across the network, gets validated, and is attached to the chain. Only once your provider node has received and processed that block does its UTxO set reflect the spent inputs — a process that typically takes 10–30 seconds, and can be longer under network congestion or in the event of a chain fork.
15+
16+
Until that happens, the UTxOs consumed by your transaction still appear as unspent when you query your provider. If you immediately build the next transaction using those stale UTxOs, the node will reject it with `BadInputsUTxO` — because from the ledger's perspective, those inputs no longer exist.
17+
18+
This is not a bug. It is an inherent property of how Cardano's consensus and block propagation work.
19+
20+
The fix is straightforward: **all chain state reads must happen inside the action**, not before it. UTxOs, script UTxOs, datums, oracle values — anything queried from your provider must be re-read on every attempt so each retry works with the freshest available view of the chain.
21+
22+
## How It Works
23+
24+
An "action" is the complete unit of work — read chain state, build, sign, and submit — wrapped in a single retryable function or Effect. When the node rejects the transaction, the retry re-runs from the top, re-reading everything before building again.
25+
26+
```
27+
retry attempt N
28+
└─ read chain state ← fresh every attempt (UTxOs, datums, script state, ...)
29+
└─ build tx
30+
└─ sign
31+
└─ submit to node
32+
├─ accepted → done
33+
└─ BadInputsUTxO → retry attempt N+1
34+
```
35+
36+
Querying chain state **outside** the action and passing it in as a static value defeats this — the same snapshot is reused on every retry.
37+
38+
## Usage
39+
40+
### Plain async with manual retry
41+
42+
The simplest approach: wrap the full pipeline in an async function and call it from a retry loop.
43+
44+
```ts twoslash
45+
import { Address, Assets, createClient } from "@evolution-sdk/evolution";
46+
47+
const client = createClient({
48+
network: "preprod",
49+
provider: {
50+
type: "blockfrost",
51+
baseUrl: "https://cardano-preprod.blockfrost.io/api/v0",
52+
projectId: process.env.BLOCKFROST_API_KEY!
53+
},
54+
wallet: { type: "seed", mnemonic: process.env.WALLET_MNEMONIC!, accountIndex: 0 }
55+
});
56+
57+
const recipient = Address.fromBech32("addr_test1vrm9x2dgvdau8vckj4duc89m638t8djmluqw5pdrFollw8qd9k63");
58+
59+
// The action fetches UTxOs at call time — safe to retry
60+
async function sendPayment() {
61+
const tx = await client
62+
.newTx()
63+
.payToAddress({ address: recipient, assets: Assets.fromLovelace(2_000_000n) })
64+
.build();
65+
66+
const signed = await tx.sign();
67+
return signed.submit();
68+
}
69+
70+
// Simple retry with delay
71+
async function withRetry<T>(action: () => Promise<T>, retries = 3, delayMs = 3000): Promise<T> {
72+
for (let attempt = 1; attempt <= retries; attempt++) {
73+
try {
74+
return await action();
75+
} catch (err) {
76+
if (attempt === retries) throw err;
77+
await new Promise(resolve => setTimeout(resolve, delayMs));
78+
}
79+
}
80+
throw new Error("unreachable");
81+
}
82+
83+
const txHash = await withRetry(sendPayment);
84+
console.log("Submitted:", txHash);
85+
```
86+
87+
### With script UTxOs
88+
89+
When collecting from a script address, query the script UTxOs inside the action so each retry gets a fresh view of what is available at that address.
90+
91+
```ts twoslash
92+
import { Address, Assets, createClient } from "@evolution-sdk/evolution";
93+
94+
const client = createClient({
95+
network: "preprod",
96+
provider: {
97+
type: "blockfrost",
98+
baseUrl: "https://cardano-preprod.blockfrost.io/api/v0",
99+
projectId: process.env.BLOCKFROST_API_KEY!
100+
},
101+
wallet: { type: "seed", mnemonic: process.env.WALLET_MNEMONIC!, accountIndex: 0 }
102+
});
103+
104+
// Illustrative snippet (not runnable as-is) — redeemer and scriptAddress are placeholders
105+
async function unlockFromScript() {
106+
const scriptAddress = Address.fromBech32("addr_test1...");
107+
const recipient = Address.fromBech32("addr_test1vrm9x2dgvdau8vckj4duc89m638t8djmluqw5pdrFollw8qd9k63");
108+
109+
// Script UTxOs fetched inside the action — re-run on every retry
110+
const scriptUtxos = await client.getUtxos(scriptAddress);
111+
112+
const tx = await client
113+
.newTx()
114+
.collectFrom({ inputs: scriptUtxos })
115+
.payToAddress({ address: recipient, assets: Assets.fromLovelace(5_000_000n) })
116+
.build();
117+
118+
const signed = await tx.sign();
119+
return signed.submit();
120+
}
121+
```
122+
123+
### Using Effect for structured retry
124+
125+
When using Effect, compose the full pipeline as a single `Effect.gen` and apply `Effect.retry` directly. `Schedule` controls the timing and number of attempts.
126+
127+
```ts twoslash
128+
import { Address, createClient } from "@evolution-sdk/evolution";
129+
import { Effect, Schedule } from "effect";
130+
131+
const client = createClient({
132+
network: "preprod",
133+
provider: {
134+
type: "blockfrost",
135+
baseUrl: "https://cardano-preprod.blockfrost.io/api/v0",
136+
projectId: process.env.BLOCKFROST_API_KEY!
137+
},
138+
wallet: { type: "seed", mnemonic: process.env.WALLET_MNEMONIC!, accountIndex: 0 }
139+
});
140+
141+
// Illustrative snippet (not runnable as-is) — scriptAddress and redeemer are placeholders
142+
const unlockAction = Effect.gen(function* () {
143+
// Script UTxOs fetched fresh on every attempt
144+
const scriptUtxos = yield* client.Effect.getUtxos(Address.fromBech32("addr_test1vrm9x2dgvdau8vckj4duc89m638t8djmluqw5pdrFollw8qd9k63"));
145+
146+
const signBuilder = yield* client.newTx()
147+
.collectFrom({ inputs: scriptUtxos })
148+
.buildEffect();
149+
150+
return yield* signBuilder.Effect.signAndSubmit();
151+
});
152+
153+
// Retry up to 3 times with a 3-second delay between attempts
154+
const txHash = await unlockAction.pipe(
155+
Effect.retry(Schedule.recurs(3).pipe(Schedule.addDelay(() => "3 seconds"))),
156+
Effect.runPromise
157+
);
158+
159+
console.log("Submitted:", txHash);
160+
```
161+
162+
`Effect.retry` re-runs the entire `Effect.gen` block on failure — every chain state read inside it is re-executed on each attempt.
163+
164+
## Gotchas
165+
166+
- **Read all chain state inside the action, not outside.** Any indexer call made before the action — UTxOs, datums, script state, oracle values — captures a snapshot that is reused on every retry. Move those reads inside the action so each attempt queries the indexer fresh.
167+
- **Retry does not fix insufficient funds.** If the wallet genuinely does not have enough ADA, the node will reject for a different reason and retrying will always fail. Check balances before entering a retry loop.
168+
- **`Effect.retry` retries on any failure by default.** If you use Kupmios (which submits directly via Ogmios to the node), you can narrow retries to stale-input rejections specifically by matching `"BadInputsUTxO"` in the error message — this is the node's ledger validation error surfaced through the submission response:
169+
170+
```ts
171+
Effect.retry(
172+
Schedule.recurs(3).pipe(Schedule.addDelay(() => "3 seconds")),
173+
{ while: (err) => err.message.includes("BadInputsUTxO") }
174+
)
175+
```
176+
177+
Other indexers relay the same node error in different formats — check the raw cause for the specific message.
178+
- **Indexer lag is not instant.** A 0ms retry delay may still read the same stale data. Add at least a 2–3 second delay between attempts.
179+
180+
## Next Steps
181+
182+
- [Simple Payment](/docs/transactions/simple-payment) — Basic transaction building
183+
- [First Transaction](/docs/transactions/first-transaction) — Complete walkthrough
184+
- [Error Handling](/docs/advanced/error-handling) — Typed errors with Effect

docs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@types/react": "^19.1.11",
3939
"@types/react-dom": "^19.1.7",
4040
"baseline-browser-mapping": "^2.9.8",
41+
"effect": "^3.19.3",
4142
"postcss": "^8.5.6",
4243
"shiki": "^3.15.0",
4344
"tailwindcss": "^4.1.12",

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"tsx": "^4.20.4",
5757
"turbo": "^2.5.6",
5858
"typescript": "^5.9.2",
59+
"vite": "^6.0.5",
5960
"vitest": "^3.2.4"
6061
},
6162
"engines": {

packages/evolution/src/sdk/provider/internal/BlockfrostEffect.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -557,8 +557,8 @@ export const getDatum = (baseUrl: string, projectId?: string) => (datumHash: Dat
557557
const datumHashHex = Bytes.toHex(datumHash.hash)
558558
return withRateLimit(
559559
HttpUtils.get(
560-
`${baseUrl}/scripts/datum/${datumHashHex}`,
561-
Blockfrost.BlockfrostDatum,
560+
`${baseUrl}/scripts/datum/${datumHashHex}/cbor`,
561+
BlockfrostDatumCbor,
562562
createHeaders(projectId)
563563
).pipe(
564564
Effect.flatMap((datum) => {

0 commit comments

Comments
 (0)