diff --git a/.changeset/maestro-nullable-fields.md b/.changeset/maestro-nullable-fields.md
new file mode 100644
index 00000000..b700282c
--- /dev/null
+++ b/.changeset/maestro-nullable-fields.md
@@ -0,0 +1,5 @@
+---
+"@evolution-sdk/evolution": patch
+---
+
+Maestro responses now decode when the fields the Maestro spec marks optional/nullable arrive `null` or missing: `plutus_cost_models.plutus_v3` (maps to an empty cost model), a UTxO's `datum` and `reference_script`, a datum's `bytes` and `json`, a paginated response's `next_cursor`, and an account's `delegated_pool`. Previously any of these failed the whole call with a schema decode error. The three protocol parameters the public `ProtocolParameters` type requires (`delegate_representative_deposit`, `governance_action_deposit`, `min_fee_reference_scripts`) are also accepted as `null`/missing by the schema, and the transform then fails with a clear `ProviderError` naming the field instead of an opaque decode error — no default is invented for them.
diff --git a/packages/evolution/src/sdk/provider/internal/Maestro.ts b/packages/evolution/src/sdk/provider/internal/Maestro.ts
index 3700ad78..5a534475 100644
--- a/packages/evolution/src/sdk/provider/internal/Maestro.ts
+++ b/packages/evolution/src/sdk/provider/internal/Maestro.ts
@@ -54,16 +54,25 @@ export const MaestroProtocolParameters = Schema.Struct({
lovelace: Schema.BigInt
})
}),
- delegate_representative_deposit: Schema.Struct({
- ada: Schema.Struct({
- lovelace: Schema.BigInt
- })
- }),
- governance_action_deposit: Schema.Struct({
- ada: Schema.Struct({
- lovelace: Schema.BigInt
- })
- }),
+ // The Maestro spec marks these two deposits optional/nullable (#570); the public
+ // ProtocolParameters type requires them, so transformProtocolParameters fails with
+ // a clear error naming the field when one arrives null or missing (no default exists)
+ delegate_representative_deposit: Schema.optionalWith(
+ Schema.Struct({
+ ada: Schema.Struct({
+ lovelace: Schema.BigInt
+ })
+ }),
+ { nullable: true }
+ ),
+ governance_action_deposit: Schema.optionalWith(
+ Schema.Struct({
+ ada: Schema.Struct({
+ lovelace: Schema.BigInt
+ })
+ }),
+ { nullable: true }
+ ),
script_execution_prices: Schema.Struct({
memory: Schema.String, // rational format "numerator/denominator"
cpu: Schema.String // rational format "numerator/denominator"
@@ -75,13 +84,18 @@ export const MaestroProtocolParameters = Schema.Struct({
min_utxo_deposit_coefficient: Schema.BigInt,
collateral_percentage: Schema.BigInt,
max_collateral_inputs: Schema.BigInt,
- min_fee_reference_scripts: Schema.Struct({
- base: Schema.NumberFromString
- }),
+ min_fee_reference_scripts: Schema.optionalWith(
+ Schema.Struct({
+ base: Schema.NumberFromString
+ }),
+ { nullable: true }
+ ),
plutus_cost_models: Schema.Struct({
plutus_v1: Schema.Array(Schema.BigInt),
plutus_v2: Schema.Array(Schema.BigInt),
- plutus_v3: Schema.Array(Schema.BigInt)
+ // The spec requires only plutus_v1 and plutus_v2 (#570); a missing/null v3
+ // maps to an empty cost model in transformProtocolParameters
+ plutus_v3: Schema.optionalWith(Schema.Array(Schema.BigInt), { nullable: true })
})
})
@@ -99,8 +113,9 @@ export const MaestroAsset = Schema.Struct({
export const MaestroDatumOption = Schema.Struct({
type: Schema.Literal("hash", "inline"),
hash: Schema.String,
- bytes: Schema.NullOr(Schema.String),
- json: Schema.NullOr(Schema.Unknown)
+ // The spec marks datum bytes (and its json rendering) optional/nullable (#570)
+ bytes: Schema.optionalWith(Schema.String, { nullable: true }),
+ json: Schema.optionalWith(Schema.Unknown, { nullable: true })
})
/**
@@ -131,15 +146,17 @@ export const MaestroUTxO = Schema.Struct({
index: Schema.BigInt,
assets: Schema.Array(MaestroAsset),
address: Schema.String,
- datum: Schema.NullOr(MaestroDatumOption),
- reference_script: Schema.NullOr(MaestroScript)
+ // Both are optional/nullable in the spec (#570); the transforms already accept null
+ datum: Schema.optionalWith(MaestroDatumOption, { nullable: true }),
+ reference_script: Schema.optionalWith(MaestroScript, { nullable: true })
})
/**
* Maestro delegation/account response schema
*/
export const MaestroDelegation = Schema.Struct({
- delegated_pool: Schema.NullOr(Schema.String),
+ // Optional/nullable in the spec (#570); transformDelegation already maps null to poolId: null
+ delegated_pool: Schema.optionalWith(Schema.String, { nullable: true }),
rewards_available: Schema.BigInt
})
@@ -169,7 +186,8 @@ export const MaestroTimestampedResponse = (dataSchema: Schema.Schema
export const MaestroPaginatedResponse = (dataSchema: Schema.Schema) =>
Schema.Struct({
data: Schema.Array(dataSchema),
- next_cursor: Schema.NullOr(Schema.String),
+ // Optional/nullable in the spec (#570); the pagination loop already stops on a falsy cursor
+ next_cursor: Schema.optionalWith(Schema.String, { nullable: true }),
last_updated: Schema.Struct({
timestamp: Schema.String,
block_slot: Schema.BigInt,
@@ -207,6 +225,21 @@ export const parseDecimalFromRational = (rationalStr: string): number => {
return numerator / denominator
}
+/**
+ * The Maestro spec marks these protocol parameters optional/nullable, but the public
+ * ProtocolParameters type requires them and no honest default exists (a deposit of 0
+ * would be a silent wrong value). Fail with a clear error naming the field, per #570;
+ * MaestroEffect.getProtocolParameters converts this throw into a ProviderError.
+ */
+const requireProtocolParameter = (value: T | null | undefined, field: string): T => {
+ if (value === null || value === undefined) {
+ throw new Error(
+ `Maestro protocol parameters: required field "${field}" arrived null or missing, and the public ProtocolParameters type has no default for it`
+ )
+ }
+ return value
+}
+
/**
* Transform Maestro protocol parameters to Evolution SDK format
*/
@@ -220,8 +253,12 @@ export const transformProtocolParameters = (
maxValSize: Number(maestroParams.max_value_size.bytes),
keyDeposit: maestroParams.stake_credential_deposit.ada.lovelace,
poolDeposit: maestroParams.stake_pool_deposit.ada.lovelace,
- drepDeposit: maestroParams.delegate_representative_deposit.ada.lovelace,
- govActionDeposit: maestroParams.governance_action_deposit.ada.lovelace,
+ drepDeposit: requireProtocolParameter(
+ maestroParams.delegate_representative_deposit,
+ "delegate_representative_deposit"
+ ).ada.lovelace,
+ govActionDeposit: requireProtocolParameter(maestroParams.governance_action_deposit, "governance_action_deposit").ada
+ .lovelace,
priceMem: parseDecimalFromRational(maestroParams.script_execution_prices.memory),
priceStep: parseDecimalFromRational(maestroParams.script_execution_prices.cpu),
maxTxExMem: maestroParams.max_execution_units_per_transaction.memory,
@@ -229,7 +266,10 @@ export const transformProtocolParameters = (
coinsPerUtxoByte: maestroParams.min_utxo_deposit_coefficient,
collateralPercentage: Number(maestroParams.collateral_percentage),
maxCollateralInputs: Number(maestroParams.max_collateral_inputs),
- minFeeRefScriptCostPerByte: maestroParams.min_fee_reference_scripts.base,
+ minFeeRefScriptCostPerByte: requireProtocolParameter(
+ maestroParams.min_fee_reference_scripts,
+ "min_fee_reference_scripts"
+ ).base,
costModels: {
PlutusV1: Object.fromEntries(
maestroParams.plutus_cost_models.plutus_v1.map((value, index) => [index.toString(), Number(value)])
@@ -238,7 +278,7 @@ export const transformProtocolParameters = (
maestroParams.plutus_cost_models.plutus_v2.map((value, index) => [index.toString(), Number(value)])
),
PlutusV3: Object.fromEntries(
- maestroParams.plutus_cost_models.plutus_v3.map((value, index) => [index.toString(), Number(value)])
+ (maestroParams.plutus_cost_models.plutus_v3 ?? []).map((value, index) => [index.toString(), Number(value)])
)
}
}
diff --git a/packages/evolution/src/sdk/provider/internal/MaestroEffect.ts b/packages/evolution/src/sdk/provider/internal/MaestroEffect.ts
index c29d6a65..94d4347f 100644
--- a/packages/evolution/src/sdk/provider/internal/MaestroEffect.ts
+++ b/packages/evolution/src/sdk/provider/internal/MaestroEffect.ts
@@ -73,7 +73,19 @@ export const getProtocolParameters = (baseUrl: string, apiKey: string) =>
Maestro.MaestroTimestampedResponse(Maestro.MaestroProtocolParameters),
createHeaders(apiKey)
).pipe(
- Effect.map((response) => Maestro.transformProtocolParameters(response.data)),
+ // flatMap + try, not map: transformProtocolParameters throws a clear error for a
+ // required field that arrived null/missing (#570), and a throw inside Effect.map
+ // would surface as a defect instead of the ProviderError this function promises
+ Effect.flatMap((response) =>
+ Effect.try({
+ try: () => Maestro.transformProtocolParameters(response.data),
+ catch: (cause) =>
+ new ProviderError({
+ message: cause instanceof Error ? cause.message : "Maestro get protocol parameters failed",
+ cause
+ })
+ })
+ ),
Effect.timeout(TIMEOUT),
Effect.catchAll(wrapError("get protocol parameters"))
)
diff --git a/packages/evolution/test/provider/Maestro.test.ts b/packages/evolution/test/provider/Maestro.test.ts
index dfe65479..06132fbd 100644
--- a/packages/evolution/test/provider/Maestro.test.ts
+++ b/packages/evolution/test/provider/Maestro.test.ts
@@ -3,7 +3,7 @@
* shape of the Maestro v1 API, so every integer is checked to decode exactly as a bigint,
* including bare JSON numbers past 2^53.
*/
-import { Effect } from "effect"
+import { Effect, Exit } from "effect"
import { afterEach, describe, expect, it, vi } from "vitest"
import * as Address from "../../src/Address.js"
@@ -125,6 +125,72 @@ describe("Maestro getProtocolParameters", () => {
expect(params.minFeeRefScriptCostPerByte).toBe(15.5)
})
+
+ it("accepts a missing plutus_v3 cost model and maps it to an empty model", async () => {
+ // The Maestro spec requires only plutus_v1 and plutus_v2 (#570)
+ stubFetch({ [`${BASE_URL}/protocol-parameters`]: body.replace(/,\s*"plutus_v3":\[[^\]]*\]/, "") })
+
+ const params = await Effect.runPromise(MaestroEffect.getProtocolParameters(BASE_URL, API_KEY))
+
+ expect(params.costModels.PlutusV3).toEqual({})
+ expect(params.costModels.PlutusV1[0]).toBe(100788)
+ })
+
+ it("accepts a null plutus_v3 cost model and maps it to an empty model", async () => {
+ stubFetch({
+ [`${BASE_URL}/protocol-parameters`]: body.replace(/"plutus_v3":\[[^\]]*\]/, '"plutus_v3":null')
+ })
+
+ const params = await Effect.runPromise(MaestroEffect.getProtocolParameters(BASE_URL, API_KEY))
+
+ expect(params.costModels.PlutusV3).toEqual({})
+ })
+
+ it("fails with a clear error naming the field when a required deposit is null or missing", async () => {
+ /** Render an error and its whole `cause` chain; the top message alone is generic */
+ const renderChain = (error: unknown): string => {
+ const parts: Array = []
+ let current: unknown = error
+ while (current instanceof Error) {
+ parts.push(`${current.name}: ${current.message}`)
+ current = (current as { cause?: unknown }).cause
+ }
+ return parts.join("\n")
+ }
+ const cases: Array<[string, string]> = [
+ [
+ "delegate_representative_deposit",
+ body.replace(
+ '"delegate_representative_deposit":{"ada":{"lovelace":500000000}}',
+ '"delegate_representative_deposit":null'
+ )
+ ],
+ [
+ "governance_action_deposit",
+ body.replace(/"governance_action_deposit":\{"ada":\{"lovelace":100000000000\}\},/, "")
+ ],
+ [
+ "min_fee_reference_scripts",
+ body.replace(/"min_fee_reference_scripts":\{[^}]*\},/, '"min_fee_reference_scripts":null,')
+ ]
+ ]
+ for (const [field, payload] of cases) {
+ stubFetch({ [`${BASE_URL}/protocol-parameters`]: payload })
+
+ const exit = await Effect.runPromiseExit(MaestroEffect.getProtocolParameters(BASE_URL, API_KEY))
+
+ expect(Exit.isFailure(exit)).toBe(true)
+ if (Exit.isFailure(exit)) {
+ // A defect here would be Cause.Die: an unhandled throw, not a provider failure
+ expect(exit.cause._tag).toBe("Fail")
+ if (exit.cause._tag === "Fail") {
+ // The schema ParseError also names the field, so assert the deliberate
+ // wording — a bare decode failure is exactly what #570 asks to replace
+ expect(renderChain(exit.cause.error)).toContain(`required field "${field}"`)
+ }
+ }
+ }
+ })
})
describe("Maestro UTxO queries", () => {
@@ -147,6 +213,34 @@ describe("Maestro UTxO queries", () => {
expect(utxos[1].assets).toEqual(expectedAssets(4810000000n, 1n))
})
+ it("getUtxos accepts UTxOs with the datum and reference_script keys missing, and a page with no next_cursor key", async () => {
+ // The Maestro spec marks datum, reference_script and next_cursor optional/nullable (#570);
+ // the existing fixtures always send them (as null), so a missing key must decode the same way
+ const bare = UTXO_0.replace(',"datum":null,"reference_script":null', "")
+ stubFetch({ [utxosUrl]: `{"data":[${bare}],${lastUpdated}}` })
+
+ const utxos = await Effect.runPromise(
+ MaestroEffect.getUtxos(BASE_URL, API_KEY)(Address.fromBech32(PREPROD_ADDRESS_BECH32))
+ )
+
+ expect(utxos).toHaveLength(1)
+ expect(utxos[0].assets).toEqual(expectedAssets(45000000000000001n, 18446744073709551615n))
+ expect(utxos[0].datumOption).toBeUndefined()
+ expect(utxos[0].scriptRef).toBeUndefined()
+ })
+
+ it("getUtxos accepts a datum whose bytes and json keys are missing", async () => {
+ const withDatum = `{"tx_hash":"${PREPROD_TX_HASH_HEX}","index":3,"assets":[{"unit":"lovelace","amount":"2000000"}],"address":"${PREPROD_ADDRESS_BECH32}","datum":{"type":"hash","hash":"${PREPROD_DATUM_HASH_HEX}"},"reference_script":null,"txout_cbor":null}`
+ stubFetch({ [utxosUrl]: `{"data":[${withDatum}],"next_cursor":null,${lastUpdated}}` })
+
+ const utxos = await Effect.runPromise(
+ MaestroEffect.getUtxos(BASE_URL, API_KEY)(Address.fromBech32(PREPROD_ADDRESS_BECH32))
+ )
+
+ expect(utxos).toHaveLength(1)
+ expect(utxos[0].datumOption).toEqual(DatumHash.fromHex(PREPROD_DATUM_HASH_HEX))
+ })
+
it("getUtxosWithUnit keeps only UTxOs holding the unit", async () => {
stubFetch({
[utxosUrl]: `{"data":[${UTXO_0},${utxoJson(2, "2000000", '"0"').replace(PREPROD_UNIT, POLICY_ID)}],"next_cursor":null,${lastUpdated}}`
@@ -203,6 +297,19 @@ describe("Maestro getDelegation", () => {
expect(delegation).toEqual({ poolId: PoolKeyHash.fromBech32(POOL_ID), rewards: 9007199254740993n })
})
+
+ it("accepts an account with the delegated_pool key missing", async () => {
+ // The Maestro spec marks delegated_pool optional/nullable (#570)
+ stubFetch({
+ [`${BASE_URL}/accounts/${PREPROD_STAKE_ADDRESS_BECH32}`]: `{"data":{"registered":true,"rewards_available":42,"stake_address":"${PREPROD_STAKE_ADDRESS_BECH32}"},${lastUpdated}}`
+ })
+
+ const delegation = await Effect.runPromise(
+ MaestroEffect.getDelegation(BASE_URL, API_KEY)(PREPROD_STAKE_ADDRESS_BECH32)
+ )
+
+ expect(delegation).toEqual({ poolId: null, rewards: 42n })
+ })
})
describe("Maestro evaluateTx", () => {