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/maestro-nullable-fields.md
Original file line number Diff line number Diff line change
@@ -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.
88 changes: 64 additions & 24 deletions packages/evolution/src/sdk/provider/internal/Maestro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 })
})
})

Expand All @@ -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 })
})

/**
Expand Down Expand Up @@ -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
})

Expand Down Expand Up @@ -169,7 +186,8 @@ export const MaestroTimestampedResponse = <A, I>(dataSchema: Schema.Schema<A, I>
export const MaestroPaginatedResponse = <A, I>(dataSchema: Schema.Schema<A, I>) =>
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,
Expand Down Expand Up @@ -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 = <T>(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
*/
Expand All @@ -220,16 +253,23 @@ 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,
maxTxExSteps: maestroParams.max_execution_units_per_transaction.cpu,
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)])
Expand All @@ -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)])
)
}
}
Expand Down
14 changes: 13 additions & 1 deletion packages/evolution/src/sdk/provider/internal/MaestroEffect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
)
Expand Down
109 changes: 108 additions & 1 deletion packages/evolution/test/provider/Maestro.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string> = []
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", () => {
Expand All @@ -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}}`
Expand Down Expand Up @@ -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", () => {
Expand Down