Skip to content

Commit dac80e8

Browse files
committed
Make the unionWith property test differential against the builtin
Compare the typed unionWith (+) against the builtin unionValue path on CEK rather than against host-Haskell unionWith: a shared-source oracle cannot catch a bug that lands the same way on both sides. Inputs are restricted to the well-formed domain unsafeDataAsValue accepts, and results are compared up to key order and zero-sum entries. Bindings use plinthc instead of the compile splice. Also rephrase Note [Single-pass unionWith] and the checkBinRel docstring to describe the present structure without contrasting against history. For IntersectMBO/plutus-private#2243.
1 parent b7e3a5a commit dac80e8

2 files changed

Lines changed: 66 additions & 31 deletions

File tree

  • plutus-ledger-api/src/PlutusLedgerApi/V1/Data
  • plutus-tx-plugin/test-ledger-api/Spec/Data

plutus-ledger-api/src/PlutusLedgerApi/V1/Data/Value.hs

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -402,20 +402,12 @@ assetClassValueOf :: Value -> AssetClass -> Integer
402402
assetClassValueOf v (AssetClass (c, t)) = valueOf v c t
403403
{-# INLINEABLE assetClassValueOf #-}
404404

405-
{- Note [Fused unionWith]
406-
The previous implementation built an intermediate of type
407-
@Map CurrencySymbol (Map TokenName (These Integer Integer))@ via a separate
408-
@unionVal@ helper, then re-walked the result in 'unionWith' to flatten each
409-
@These@ into a plain @Integer@ by applying @f@. That was three full outer
410-
passes — @Map.union@, @Map.map unThese@ (yielding inner maps of @These Integer
411-
Integer@), then @Map.map (Map.map collapse)@ — for a single conceptual merge.
412-
413-
This fused version drops the intermediate stage of inner-@These@ wrapping:
414-
'fuseInners' walks the outer @Map.union@ result once and, for each currency
415-
symbol, either applies @f@ in place against a single inner side or merges
416-
both inner sides via @Map.map collapse (Map.union innerL innerR)@. The
417-
@Map TokenName (These Integer Integer)@ shape is gone; the outer 'Map.map'
418-
runs once, not twice. -}
405+
{- Note [Single-pass unionWith]
406+
'Map.union' tags each currency symbol with a 'These' recording which side(s)
407+
hold it. 'fuseInners' consumes that tag in one outer 'Map.map': against an
408+
implicit @0@ on the absent side, or — when both sides hold the symbol — over
409+
the inner @Map.union innerL innerR@. The merge therefore touches each level
410+
once and never materialises a @Map TokenName (These Integer Integer)@. -}
419411

420412
{-| Combine two 'Value' maps with the argument function.
421413
Assumes the well-definedness of the two maps. -}
@@ -462,11 +454,9 @@ isZero (Value xs) = Map.all (Map.all (\i -> 0 == i)) xs
462454
{-| Check whether a binary relation holds for value pairs of two 'Value' maps,
463455
supplying 0 where a key is only present in one of them.
464456
465-
Mirrors 'unionWith' (see Note [Fused unionWith]): a single outer 'Map.union'
466-
plus one outer 'Map.all'. For currency symbols present in both 'Value's,
467-
the inner check runs over the inner 'Map.union'. For currency symbols
468-
present on only one side, the inner check applies the relation against
469-
@0@ on the missing side. -}
457+
Shares the structure of 'unionWith' (see Note [Single-pass unionWith]), with
458+
'Map.all' in place of 'Map.map': the walk short-circuits on the first pair
459+
that fails @f@, applying the relation against @0@ on whichever side is absent. -}
470460
checkBinRel :: (Integer -> Integer -> Bool) -> Value -> Value -> Bool
471461
checkBinRel f (Value mapL) (Value mapR) =
472462
Map.all checkInners (Map.union mapL mapR)

plutus-tx-plugin/test-ledger-api/Spec/Data/Value.hs

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import PlutusTx.Numeric
2727
import PlutusTx.Prelude hiding (integerToByteString)
2828
import PlutusTx.Show (toDigits)
2929
import PlutusTx.TH (compile)
30-
import PlutusTx.Test.Run.Code (evalResult, evaluateCompiledCode, evaluationResultMatchesHaskell)
30+
import PlutusTx.Test.Run.Code (evalResult, evaluateCompiledCode)
3131
import PlutusTx.Traversable qualified as Tx
3232

3333
import PlutusCore.Builtin qualified as PLC
@@ -319,16 +319,61 @@ test_valueOf =
319319
`unsafeApplyCode` liftCodeDef (unTokenName tn)
320320
in nonBuiltin === builtin
321321

322-
{-| Check that running the compiled fused 'unionWith' on CEK produces the
323-
same 'Value' as the host-Haskell implementation, for arbitrary pairs of
324-
'Value's. The combining function must come from 'PlutusTx.Prelude' so
325-
that Plinth can inline it into the compiled UPLC. -}
322+
{-| The 'unionWith' @(+)@ under test. Signature matches 'compiledBuiltinUnion' so the property
323+
can pit them against each other. -}
324+
compiledUnionWith :: CompiledCode (BI.BuiltinData -> BI.BuiltinData -> BI.BuiltinData)
325+
compiledUnionWith = plinthc \bd1 bd2 ->
326+
Tx.toBuiltinData (unionWith (+) (Tx.unsafeFromBuiltinData bd1) (Tx.unsafeFromBuiltinData bd2))
327+
328+
{-| Independent oracle: the builtin union path. Shares no source with 'compiledUnionWith', so a
329+
bug in one cannot hide behind the same bug in the other. -}
330+
compiledBuiltinUnion :: CompiledCode (BI.BuiltinData -> BI.BuiltinData -> BI.BuiltinData)
331+
compiledBuiltinUnion = plinthc \bd1 bd2 ->
332+
B.mkValue (B.unionValue (B.unsafeDataAsValue bd1) (B.unsafeDataAsValue bd2))
333+
334+
-- | Evaluate a compiled union on CEK and decode its result.
335+
runUnionCode
336+
:: CompiledCode (BI.BuiltinData -> BI.BuiltinData -> BI.BuiltinData)
337+
-> Value
338+
-> Value
339+
-> Value
340+
runUnionCode code value1 value2 =
341+
Tx.unsafeFromBuiltinData
342+
. BI.dataToBuiltinData
343+
. either Haskell.throw id
344+
$ errOrRes
345+
>>= PLC.readKnownSelf
346+
where
347+
prog =
348+
code
349+
`unsafeApplyCode` liftCodeDef (Tx.toBuiltinData value1)
350+
`unsafeApplyCode` liftCodeDef (Tx.toBuiltinData value2)
351+
(errOrRes, _cost) =
352+
PLC.runCekNoEmit PLC.defaultCekParametersForTesting PLC.counting
353+
. PLC.runQuote
354+
. PLC.unDeBruijnTermWith (Haskell.error "Free variable")
355+
. PLC._progTerm
356+
$ getPlc prog
357+
358+
-- | 'unionWith' @(+)@ must agree with the builtin union path on CEK.
326359
test_unionWith :: TestTree
327360
test_unionWith =
328-
testProperty "unionWith on CEK matches host Haskell" \value1 value2 ->
329-
let compiled =
330-
$$(compile [||\v1 v2 -> unionWith (+) v1 v2||])
331-
`unsafeApplyCode` liftCodeDef value1
332-
`unsafeApplyCode` liftCodeDef value2
333-
expected = unionWith (+) value1 value2
334-
in evaluationResultMatchesHaskell compiled (===) expected
361+
testProperty "non-builtin unionWith matches builtin unionValue on CEK" \rawValue1 rawValue2 ->
362+
let v1 = normalise rawValue1
363+
v2 = normalise rawValue2
364+
-- Compare semantically: key order and zero-sum entries differ between the paths but
365+
-- carry no meaning, so canonicalise before '==='.
366+
canon code = normaliseLists . valueToLists $ runUnionCode code v1 v2
367+
in canon compiledUnionWith === canon compiledBuiltinUnion
368+
369+
{-| Restrict an arbitrary 'Value' to the well-formed domain 'unsafeDataAsValue' accepts: the
370+
builtin errors on unsorted keys, zero quantities, or empty token maps. -}
371+
normaliseLists
372+
:: [(CurrencySymbol, [(TokenName, Integer)])] -> [(CurrencySymbol, [(TokenName, Integer)])]
373+
normaliseLists =
374+
Haskell.sortOn fst
375+
. Haskell.filter (Haskell.not . Haskell.null . snd)
376+
. Haskell.map (Haskell.fmap (Haskell.sortOn fst . Haskell.filter ((Haskell./= 0) . snd)))
377+
378+
normalise :: Value -> Value
379+
normalise = listsToValue . normaliseLists . valueToLists

0 commit comments

Comments
 (0)