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", () => {