From 632ab87af958969cae976a124e7ee19525a5d372 Mon Sep 17 00:00:00 2001 From: Yuriy Lazaryev Date: Tue, 14 Jul 2026 18:06:29 +0200 Subject: [PATCH 1/4] Add multiIndexArray builtin to Plutus Core (CIP-0156) `multiIndexArray :: forall a. list integer -> array a -> list a` returns the array elements at the given indices in order (duplicates preserved), failing the whole call on any out-of-bounds index. Bounds are checked in the Integer domain, so an index exceeding maxBound::Int is out-of-bounds rather than wrapping. Placeholder costing (unimplementedCostingFun) until benchmarked; not exposed to Plinth. Adds the CEK denotation, flat tag, and RewriteRules/plugin exhaustiveness arms, plus regenerated type-synthesis and parser goldens. Issue: IntersectMBO/plutus-private#1675 --- ...y.lazaryev_issue_1675_multi_index_array.md | 3 +++ .../src/PlutusCore/Default/Builtins.hs | 21 +++++++++++++++++++ .../DefaultFun/MultiIndexArray.golden.plc | 1 + .../DefaultFun/MultiIndexArray.golden.sig | 1 + .../RewriteRules/CommuteFnWithConst.hs | 1 + .../Golden/missing-builtin-operand.golden | 1 + .../src/PlutusTx/Compiler/Builtins.hs | 2 ++ 7 files changed, 30 insertions(+) create mode 100644 plutus-core/changelog.d/20260714_151025_yuriy.lazaryev_issue_1675_multi_index_array.md create mode 100644 plutus-core/plutus-core/test/TypeSynthesis/Golden/DefaultFun/MultiIndexArray.golden.plc create mode 100644 plutus-core/plutus-core/test/TypeSynthesis/Golden/Signatures/DefaultFun/MultiIndexArray.golden.sig diff --git a/plutus-core/changelog.d/20260714_151025_yuriy.lazaryev_issue_1675_multi_index_array.md b/plutus-core/changelog.d/20260714_151025_yuriy.lazaryev_issue_1675_multi_index_array.md new file mode 100644 index 00000000000..01c1208353b --- /dev/null +++ b/plutus-core/changelog.d/20260714_151025_yuriy.lazaryev_issue_1675_multi_index_array.md @@ -0,0 +1,3 @@ +### Added + +- New builtin `multiIndexArray` ([CIP-0156](https://cips.cardano.org/cip/CIP-0156)) of type `forall a. list integer -> array a -> list a`, returning the array elements at the given indices in order (duplicates preserved) and failing the whole call on any out-of-bounds index. Placeholder costing; gated under `futurePV`, so it is not available in any released protocol version. diff --git a/plutus-core/plutus-core/src/PlutusCore/Default/Builtins.hs b/plutus-core/plutus-core/src/PlutusCore/Default/Builtins.hs index af7d7b465cd..5f99fc68c9c 100644 --- a/plutus-core/plutus-core/src/PlutusCore/Default/Builtins.hs +++ b/plutus-core/plutus-core/src/PlutusCore/Default/Builtins.hs @@ -218,6 +218,8 @@ data DefaultFun | ValueData | UnValueData | ScaleValue + | -- Batch 7 + MultiIndexArray deriving stock (Show, Eq, Ord, Enum, Bounded, Generic, Ix) deriving anyclass (NFData, Hashable, PrettyBy PrettyConfigPlc) @@ -2471,6 +2473,23 @@ instance uni ~ DefaultUni => ToBuiltinMeaning uni DefaultFun where in makeBuiltinMeaning scaleValueDenotation (runCostingFunTwoArguments . paramScaleValue) + toBuiltinMeaning _semvar MultiIndexArray = + let multiIndexArrayDenotation + :: [Integer] -> SomeConstant uni (Vector a) -> BuiltinResult (Opaque val [a]) + multiIndexArrayDenotation indices (SomeConstant (Some (ValueOf uni vec))) = + case uni of + DefaultUniArray uniA -> + let len = toInteger (Vector.length vec) + lookupIndex i + | 0 <= i && i < len = pure $ Vector.unsafeIndex vec $ fromInteger i + | otherwise = fail "Array index out of bounds" + in fromValueOf (DefaultUniList uniA) <$> traverse lookupIndex indices + _ -> + throwError $ structuralUnliftingError "Expected an array but got something else" + {-# INLINE multiIndexArrayDenotation #-} + in makeBuiltinMeaning + multiIndexArrayDenotation + (runCostingFunTwoArguments . unimplementedCostingFun) -- See Note [Inlining meanings of builtins]. {-# INLINE toBuiltinMeaning #-} @@ -2614,6 +2633,7 @@ instance Flat DefaultFun where ValueData -> 98 UnValueData -> 99 ScaleValue -> 100 + MultiIndexArray -> 101 decode = go =<< decodeBuiltin where @@ -2718,6 +2738,7 @@ instance Flat DefaultFun where go 98 = pure ValueData go 99 = pure UnValueData go 100 = pure ScaleValue + go 101 = pure MultiIndexArray go t = fail $ "Failed to decode builtin tag, got: " ++ show t size _ n = n + builtinTagWidth diff --git a/plutus-core/plutus-core/test/TypeSynthesis/Golden/DefaultFun/MultiIndexArray.golden.plc b/plutus-core/plutus-core/test/TypeSynthesis/Golden/DefaultFun/MultiIndexArray.golden.plc new file mode 100644 index 00000000000..7acc2750ebc --- /dev/null +++ b/plutus-core/plutus-core/test/TypeSynthesis/Golden/DefaultFun/MultiIndexArray.golden.plc @@ -0,0 +1 @@ +all a. list integer -> array a -> list a \ No newline at end of file diff --git a/plutus-core/plutus-core/test/TypeSynthesis/Golden/Signatures/DefaultFun/MultiIndexArray.golden.sig b/plutus-core/plutus-core/test/TypeSynthesis/Golden/Signatures/DefaultFun/MultiIndexArray.golden.sig new file mode 100644 index 00000000000..2e316af031a --- /dev/null +++ b/plutus-core/plutus-core/test/TypeSynthesis/Golden/Signatures/DefaultFun/MultiIndexArray.golden.sig @@ -0,0 +1 @@ +forall a. [Integer] -> SomeConstant DefaultUni (Vector (TyVarRep * ('TyNameRep * "a" 0))) -> BuiltinResult (Opaque Val [TyVarRep * ('TyNameRep * "a" 0)]) \ No newline at end of file diff --git a/plutus-core/plutus-ir/src/PlutusIR/Transform/RewriteRules/CommuteFnWithConst.hs b/plutus-core/plutus-ir/src/PlutusIR/Transform/RewriteRules/CommuteFnWithConst.hs index 27c5bda78a1..22482212f11 100644 --- a/plutus-core/plutus-ir/src/PlutusIR/Transform/RewriteRules/CommuteFnWithConst.hs +++ b/plutus-core/plutus-ir/src/PlutusIR/Transform/RewriteRules/CommuteFnWithConst.hs @@ -155,3 +155,4 @@ isCommutative = \case ValueData -> False UnValueData -> False ScaleValue -> False + MultiIndexArray -> False diff --git a/plutus-core/untyped-plutus-core/test/Parser/Golden/missing-builtin-operand.golden b/plutus-core/untyped-plutus-core/test/Parser/Golden/missing-builtin-operand.golden index 588fbb61b18..f1528b9b564 100644 --- a/plutus-core/untyped-plutus-core/test/Parser/Golden/missing-builtin-operand.golden +++ b/plutus-core/untyped-plutus-core/test/Parser/Golden/missing-builtin-operand.golden @@ -71,6 +71,7 @@ Parsable functions are [ addInteger , mkNilPairData , mkPairData , modInteger + , multiIndexArray , multiplyInteger , nullList , orByteString diff --git a/plutus-tx-plugin/src/PlutusTx/Compiler/Builtins.hs b/plutus-tx-plugin/src/PlutusTx/Compiler/Builtins.hs index 876562b8f79..7ce5223eb01 100644 --- a/plutus-tx-plugin/src/PlutusTx/Compiler/Builtins.hs +++ b/plutus-tx-plugin/src/PlutusTx/Compiler/Builtins.hs @@ -753,6 +753,8 @@ defineBuiltinTerms = do PLC.LengthOfArray -> defineBuiltinInl 'Builtins.lengthOfArray PLC.ListToArray -> defineBuiltinInl 'Builtins.listToArray PLC.IndexArray -> defineBuiltinInl 'Builtins.indexArray + -- Not exposed to Plinth yet: core-language only for now (CIP-0156). + PLC.MultiIndexArray -> pure () -- Data PLC.ChooseData -> defineBuiltinInl 'Builtins.chooseData PLC.EqualsData -> defineBuiltinInl 'Builtins.equalsData From cc8e54a326fea3e816113704adb655c1c878a3a7 Mon Sep 17 00:00:00 2001 From: Yuriy Lazaryev Date: Tue, 14 Jul 2026 18:06:57 +0200 Subject: [PATCH 2/4] Gate multiIndexArray under futurePV (CIP-0156) New batch7 holds builtins that are implemented but not yet approved for release, mapped to futurePV in builtinsIntroducedIn for all three ledger languages. Per Note [Adding new builtins: protocol versions], so multiIndexArray is not available in any released protocol version. The Versions specs (and Data twin) expect it rejected at newestPV. Issue: IntersectMBO/plutus-private#1675 --- .../src/PlutusLedgerApi/Common/Versions.hs | 10 ++++++++++ plutus-ledger-api/test/Spec/Data/Versions.hs | 10 +++++++--- plutus-ledger-api/test/Spec/Versions.hs | 10 +++++++--- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/plutus-ledger-api/src/PlutusLedgerApi/Common/Versions.hs b/plutus-ledger-api/src/PlutusLedgerApi/Common/Versions.hs index 281be0622fa..c730c29de2d 100644 --- a/plutus-ledger-api/src/PlutusLedgerApi/Common/Versions.hs +++ b/plutus-ledger-api/src/PlutusLedgerApi/Common/Versions.hs @@ -31,6 +31,7 @@ module PlutusLedgerApi.Common.Versions , batch4b , batch5 , batch6 + , batch7 , MaxBounds (..) , maxBoundsByPV ) where @@ -302,6 +303,12 @@ batch6 = , ScaleValue ] +-- Builtins that are implemented but not yet approved for release in any protocol +-- version. See Note [Adding new builtins: protocol versions]. +batch7 :: [DefaultFun] +batch7 = + [MultiIndexArray] + {-| Given a ledger language, return a map indicating which builtin functions were introduced in which 'MajorProtocolVersion'. This __must__ be updated when new builtins are added. It is not necessary to add entries for protocol versions @@ -314,6 +321,7 @@ builtinsIntroducedIn = Map.fromList [ (alonzoPV, Set.fromList batch1) , (vanRossemPV, Set.fromList (batch2 ++ batch3 ++ batch4 ++ batch5 ++ batch6)) + , (futurePV, Set.fromList batch7) ] PlutusV2 -> Map.fromList @@ -321,12 +329,14 @@ builtinsIntroducedIn = , (valentinePV, Set.fromList batch3) , (plominPV, Set.fromList batch4b) , (vanRossemPV, Set.fromList (batch4a ++ batch5 ++ batch6)) + , (futurePV, Set.fromList batch7) ] PlutusV3 -> Map.fromList [ (changPV, Set.fromList (batch1 ++ batch2 ++ batch3 ++ batch4)) , (plominPV, Set.fromList batch5) , (vanRossemPV, Set.fromList batch6) + , (futurePV, Set.fromList batch7) ] {-| Return a set containing the builtins which are available in a given LL in a diff --git a/plutus-ledger-api/test/Spec/Data/Versions.hs b/plutus-ledger-api/test/Spec/Data/Versions.hs index fe4f906fb78..deb2aa55726 100644 --- a/plutus-ledger-api/test/Spec/Data/Versions.hs +++ b/plutus-ledger-api/test/Spec/Data/Versions.hs @@ -200,6 +200,9 @@ builtins5 = mkScriptsForBuiltins batch5 builtins6 :: [(String, SerialisedScript)] builtins6 = mkScriptsForBuiltins batch6 +builtins7 :: [(String, SerialisedScript)] +builtins7 = mkScriptsForBuiltins batch7 + allBuiltins :: [(String, SerialisedScript)] allBuiltins = builtins1 @@ -209,6 +212,7 @@ allBuiltins = ++ builtins4b ++ builtins5 ++ builtins6 + ++ builtins7 {-| Test that the builtins that we expect to be allowed in each LL/PV combination can be successfully deserialised and that the rest cannot. This @@ -251,7 +255,7 @@ testPermittedBuiltins = , mkTest valentinePV builtins1 , mkTest changPV builtins1 , mkTest plominPV builtins1 - , mkTest newestPV allBuiltins + , mkTest newestPV (allBuiltins \\ builtins7) ] , let mkTest = testBuiltins PlutusV2 V2.deserialiseScript in testGroup @@ -264,7 +268,7 @@ testPermittedBuiltins = , mkTest valentinePV $ builtins1 ++ builtins2 ++ builtins3 , mkTest changPV $ builtins1 ++ builtins2 ++ builtins3 , mkTest plominPV $ builtins1 ++ builtins2 ++ builtins3 ++ builtins4b - , mkTest newestPV allBuiltins + , mkTest newestPV (allBuiltins \\ builtins7) ] , let mkTest = testBuiltins PlutusV3 V3.deserialiseScript in testGroup @@ -277,7 +281,7 @@ testPermittedBuiltins = , mkTest valentinePV [] , mkTest changPV $ builtins1 ++ builtins2 ++ builtins3 ++ builtins4a ++ builtins4b , mkTest plominPV $ builtins1 ++ builtins2 ++ builtins3 ++ builtins4a ++ builtins4b ++ builtins5 - , mkTest newestPV allBuiltins + , mkTest newestPV (allBuiltins \\ builtins7) ] ] diff --git a/plutus-ledger-api/test/Spec/Versions.hs b/plutus-ledger-api/test/Spec/Versions.hs index ceb229acfcb..60661501309 100644 --- a/plutus-ledger-api/test/Spec/Versions.hs +++ b/plutus-ledger-api/test/Spec/Versions.hs @@ -210,6 +210,9 @@ builtins5 = mkScriptsForBuiltins batch5 builtins6 :: [(String, SerialisedScript)] builtins6 = mkScriptsForBuiltins batch6 +builtins7 :: [(String, SerialisedScript)] +builtins7 = mkScriptsForBuiltins batch7 + allBuiltins :: [(String, SerialisedScript)] allBuiltins = builtins1 @@ -219,6 +222,7 @@ allBuiltins = ++ builtins4b ++ builtins5 ++ builtins6 + ++ builtins7 {-| Test that the builtins that we expect to be allowed in each LL/PV combination can be successfully deserialised and that the rest cannot. This @@ -261,7 +265,7 @@ testPermittedBuiltins = , mkTest valentinePV builtins1 , mkTest changPV builtins1 , mkTest plominPV builtins1 - , mkTest newestPV allBuiltins + , mkTest newestPV (allBuiltins \\ builtins7) ] , let mkTest = testBuiltins PlutusV2 V2.deserialiseScript in testGroup @@ -274,7 +278,7 @@ testPermittedBuiltins = , mkTest valentinePV $ builtins1 ++ builtins2 ++ builtins3 , mkTest changPV $ builtins1 ++ builtins2 ++ builtins3 , mkTest plominPV $ builtins1 ++ builtins2 ++ builtins3 ++ builtins4b - , mkTest newestPV allBuiltins + , mkTest newestPV (allBuiltins \\ builtins7) ] , let mkTest = testBuiltins PlutusV3 V3.deserialiseScript in testGroup @@ -287,7 +291,7 @@ testPermittedBuiltins = , mkTest valentinePV [] , mkTest changPV $ builtins1 ++ builtins2 ++ builtins3 ++ builtins4a ++ builtins4b , mkTest plominPV $ builtins1 ++ builtins2 ++ builtins3 ++ builtins4a ++ builtins4b ++ builtins5 - , mkTest newestPV allBuiltins + , mkTest newestPV (allBuiltins \\ builtins7) ] ] From b088b82676438d838f9c119ee3d0166a53b4c36f Mon Sep 17 00:00:00 2001 From: Yuriy Lazaryev Date: Tue, 14 Jul 2026 18:07:20 +0200 Subject: [PATCH 3/4] Add multiIndexArray evaluation tests (CIP-0156) CEK tests covering index order, duplicate indices, polymorphic element type, empty index list, and the out-of-bounds failure modes (index == length, negative, and an index exceeding maxBound::Int). Issue: IntersectMBO/plutus-private#1675 --- .../testlib/Evaluation/Builtins/Definition.hs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/plutus-core/untyped-plutus-core/testlib/Evaluation/Builtins/Definition.hs b/plutus-core/untyped-plutus-core/testlib/Evaluation/Builtins/Definition.hs index a35ae79ed88..396363e880d 100644 --- a/plutus-core/untyped-plutus-core/testlib/Evaluation/Builtins/Definition.hs +++ b/plutus-core/untyped-plutus-core/testlib/Evaluation/Builtins/Definition.hs @@ -427,6 +427,57 @@ test_BuiltinArray = term = mkIterAppNoAnn (tyInst () (builtin () IndexArray) integer) [arrayOfInts, index] typecheckEvaluateCekNoEmit def defaultBuiltinCostModelForTesting term @?= Right (EvaluationSuccess expectedValue) + , testCase "multiIndexArray" do + -- Order preserved and duplicate indices return the same element. + let indices = mkConstant @[Integer] @DefaultUni () [2, 0, 0, 1] + arrayOfInts = mkConstant @(Vector Integer) @DefaultUni () (Vector.fromList [10, 20, 30]) + expected = mkConstant @[Integer] @DefaultUni () [30, 10, 10, 20] + term = mkIterAppNoAnn (tyInst () (builtin () MultiIndexArray) integer) [indices, arrayOfInts] + typecheckEvaluateCekNoEmit def defaultBuiltinCostModelForTesting term + @?= Right (EvaluationSuccess expected) + , testCase "multiIndexArray-bool-elements" do + -- Polymorphic in the element type. + let indices = mkConstant @[Integer] @DefaultUni () [1, 0] + arrayOfBools = mkConstant @(Vector Bool) @DefaultUni () (Vector.fromList [False, True]) + expected = mkConstant @[Bool] @DefaultUni () [True, False] + term = mkIterAppNoAnn (tyInst () (builtin () MultiIndexArray) bool) [indices, arrayOfBools] + typecheckEvaluateCekNoEmit def defaultBuiltinCostModelForTesting term + @?= Right (EvaluationSuccess expected) + , testCase "multiIndexArray-empty-indices" do + let indices = mkConstant @[Integer] @DefaultUni () [] + arrayOfInts = mkConstant @(Vector Integer) @DefaultUni () (Vector.fromList [10, 20, 30]) + expected = mkConstant @[Integer] @DefaultUni () [] + term = mkIterAppNoAnn (tyInst () (builtin () MultiIndexArray) integer) [indices, arrayOfInts] + typecheckEvaluateCekNoEmit def defaultBuiltinCostModelForTesting term + @?= Right (EvaluationSuccess expected) + , testCase "multiIndexArray-index-equals-length-fails" do + -- An index equal to the length is out of bounds; the whole call fails. + let indices = mkConstant @[Integer] @DefaultUni () [0, 3] + arrayOfInts = mkConstant @(Vector Integer) @DefaultUni () (Vector.fromList [10, 20, 30]) + term = mkIterAppNoAnn (tyInst () (builtin () MultiIndexArray) integer) [indices, arrayOfInts] + typecheckEvaluateCekNoEmit def defaultBuiltinCostModelForTesting term + @?= Right EvaluationFailure + , testCase "multiIndexArray-negative-index-fails" do + -- Negative indices are out of bounds, not wrap-around. + let indices = mkConstant @[Integer] @DefaultUni () [-1] + arrayOfInts = mkConstant @(Vector Integer) @DefaultUni () (Vector.fromList [10, 20, 30]) + term = mkIterAppNoAnn (tyInst () (builtin () MultiIndexArray) integer) [indices, arrayOfInts] + typecheckEvaluateCekNoEmit def defaultBuiltinCostModelForTesting term + @?= Right EvaluationFailure + , testCase "multiIndexArray-empty-array-fails" do + let indices = mkConstant @[Integer] @DefaultUni () [0] + emptyArray = mkConstant @(Vector Integer) @DefaultUni () (Vector.fromList []) + term = mkIterAppNoAnn (tyInst () (builtin () MultiIndexArray) integer) [indices, emptyArray] + typecheckEvaluateCekNoEmit def defaultBuiltinCostModelForTesting term + @?= Right EvaluationFailure + , testCase "multiIndexArray-huge-index-fails" do + -- The bounds check is in the 'Integer' domain, so an index exceeding + -- 'maxBound :: Int' is out of bounds rather than wrapping on conversion. + let indices = mkConstant @[Integer] @DefaultUni () [2 ^ (64 :: Integer)] + arrayOfInts = mkConstant @(Vector Integer) @DefaultUni () (Vector.fromList [10, 20, 30]) + term = mkIterAppNoAnn (tyInst () (builtin () MultiIndexArray) integer) [indices, arrayOfInts] + typecheckEvaluateCekNoEmit def defaultBuiltinCostModelForTesting term + @?= Right EvaluationFailure ] test_BuiltinPair :: TestTree From 282bd5a9498c5a8d94372b737385c98509f3bac8 Mon Sep 17 00:00:00 2001 From: Yuriy Lazaryev Date: Mon, 20 Jul 2026 15:48:52 +0200 Subject: [PATCH 4/4] Explain Integer vs Int for multiIndexArray indices (CIP-0156) --- plutus-core/plutus-core/src/PlutusCore/Default/Builtins.hs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plutus-core/plutus-core/src/PlutusCore/Default/Builtins.hs b/plutus-core/plutus-core/src/PlutusCore/Default/Builtins.hs index 5f99fc68c9c..d173afa33d6 100644 --- a/plutus-core/plutus-core/src/PlutusCore/Default/Builtins.hs +++ b/plutus-core/plutus-core/src/PlutusCore/Default/Builtins.hs @@ -2474,6 +2474,9 @@ instance uni ~ DefaultUni => ToBuiltinMeaning uni DefaultFun where scaleValueDenotation (runCostingFunTwoArguments . paramScaleValue) toBuiltinMeaning _semvar MultiIndexArray = + -- Indices are 'Integer', not 'Int' (unlike 'indexArray'), because '[Int]' has no + -- unlifting: only 'DefaultUni' element types unlift, and 'Int' is not one. The bounds + -- check is therefore done in the 'Integer' domain. let multiIndexArrayDenotation :: [Integer] -> SomeConstant uni (Vector a) -> BuiltinResult (Opaque val [a]) multiIndexArrayDenotation indices (SomeConstant (Some (ValueOf uni vec))) =