diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestBuyDedicatedSyncTraffic.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestBuyDedicatedSyncTraffic.daml new file mode 100644 index 0000000000..4c9a9056f0 --- /dev/null +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestBuyDedicatedSyncTraffic.daml @@ -0,0 +1,153 @@ +-- Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | PoC (dedicated-synchronizer traffic), rung 2: buy extra traffic for a governance-registered +-- dedicated synchronizer by burning CC on the global synchronizer, and assert the resulting +-- `DedicatedSyncTraffic` record (observed by the operator). +-- +-- Rung 1 (`TestRegisterSynchronizer`, splice-dso-governance-test) proves the registration is +-- created by a vote. Here we create the `RegisteredSynchronizer` directly as the DSO to isolate +-- the buy path, then exercise `AmuletRules_BuyDedicatedSyncTraffic` against it. +module Splice.Scripts.TestBuyDedicatedSyncTraffic where + +import DA.Assert + +import Daml.Script + +-- The new buy choice, its result record, and `TransferInput`/`InputAmulet`/`TransferContext`. +import Splice.AmuletRules +-- The registry contract (rung 1) and the purchase record this choice creates. +import Splice.DecentralizedSynchronizer (RegisteredSynchronizer(..), DedicatedSyncTraffic(..)) + +-- App + user + round + funding helpers (setupDefaultAppWithUsers, tap, getTransferContext, ...). +import Splice.Scripts.Util + +-- | Happy path: a registered (non-required) synchronizer can have traffic bought for it; the buy +-- burns CC and records a `DedicatedSyncTraffic` naming the operator + synchronizer, observed by +-- the operator. +test_BuyDedicatedSyncTraffic : Script () +test_BuyDedicatedSyncTraffic = do + d <- setupDefaultAppWithUsers + let app = d.app + let provider = d.provider1 + -- Ensure an open mining round exists to price the traffic against. + runNextIssuance app + + -- The dedicated synchronizer's operator, plus a synchronizer id that is deliberately NOT in + -- `requiredSynchronizers` (which today holds only the global sync) - authorization comes from the + -- registration instead. + operator <- allocateParty "dedicatedSyncOperator" + let dedicatedSyncId = "dedicated-sync::1220dededededededededededededededededededededededededededededede" + + -- Rung 1 creates this via a DSO vote; here we create it directly as the DSO to isolate rung 2. + registeredCid <- submit app.dso $ createCmd RegisteredSynchronizer with + dso = app.dso + synchronizerId = dedicatedSyncId + operator + + -- Fund the buyer with CC to burn, and build a standard transfer context. + amuletCid <- tap app provider 1_000_000.0 + context <- getTransferContext app provider None + (amuletRulesCid, _) <- fetchAmuletRulesByKey app.dso + + let memberId = "PAR::participant::1220abababababababababababababababababababababababababababababab" + -- Buy dedicated-sync traffic for the registered synchronizer. `readAs app.dso` gives the buyer + -- visibility of the DSO-signed AmuletRules / OpenMiningRound / RegisteredSynchronizer in-script; + -- on a live ledger these are supplied via explicit disclosure. + result <- submit (actAs provider.primaryParty <> readAs app.dso) $ + exerciseCmd amuletRulesCid AmuletRules_BuyDedicatedSyncTraffic with + inputs = [InputAmulet amuletCid] + context + provider = provider.primaryParty + memberId + migrationId = 0 + trafficAmount = 1_000_000 + registeredSynchronizerCid = registeredCid + expectedDso = Some app.dso + + -- The purchase burned CC... + assertMsg "expected a non-zero CC burn" (result.amuletPaid > 0.0) + -- ...and recorded a DedicatedSyncTraffic with the fields drawn from the registration. + Some dst <- queryContractId app.dso result.dedicatedTraffic + dst.dso === app.dso + dst.operator === operator + dst.synchronizerId === dedicatedSyncId + dst.memberId === memberId + dst.totalPurchased === 1_000_000 + + -- The operator is an observer, so its node can pick up the purchase on-ledger. + operatorView <- query @DedicatedSyncTraffic operator + length operatorView === 1 + +-- | Negative: the `minTopupAmount` floor still applies even though the `requiredSynchronizers` gate +-- is skipped for dedicated synchronizers - a tiny purchase is rejected. +test_BuyDedicatedSyncTraffic_belowMinTopup : Script () +test_BuyDedicatedSyncTraffic_belowMinTopup = do + d <- setupDefaultAppWithUsers + let app = d.app + let provider = d.provider1 + runNextIssuance app + + operator <- allocateParty "dedicatedSyncOperator" + let dedicatedSyncId = "dedicated-sync::1220cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" + registeredCid <- submit app.dso $ createCmd RegisteredSynchronizer with + dso = app.dso + synchronizerId = dedicatedSyncId + operator + + amuletCid <- tap app provider 1_000_000.0 + context <- getTransferContext app provider None + (amuletRulesCid, _) <- fetchAmuletRulesByKey app.dso + + submitMustFail (actAs provider.primaryParty <> readAs app.dso) $ + exerciseCmd amuletRulesCid AmuletRules_BuyDedicatedSyncTraffic with + inputs = [InputAmulet amuletCid] + context + provider = provider.primaryParty + memberId = "PAR::participant::1220efefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefef" + migrationId = 0 + trafficAmount = 1 -- below any sane minTopupAmount + registeredSynchronizerCid = registeredCid + expectedDso = Some app.dso + +-- | Negative: `expectedDso` guards against a swapped-out AmuletRules. A mismatched `expectedDso` +-- (here, a party that is not the DSO) is rejected by `checkExpectedDso` before any burn. +test_BuyDedicatedSyncTraffic_wrongExpectedDso : Script () +test_BuyDedicatedSyncTraffic_wrongExpectedDso = do + d <- setupDefaultAppWithUsers + let app = d.app + let provider = d.provider1 + runNextIssuance app + + operator <- allocateParty "dedicatedSyncOperator" + let dedicatedSyncId = "dedicated-sync::1220bcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbc" + registeredCid <- submit app.dso $ createCmd RegisteredSynchronizer with + dso = app.dso + synchronizerId = dedicatedSyncId + operator + + amuletCid <- tap app provider 1_000_000.0 + context <- getTransferContext app provider None + (amuletRulesCid, _) <- fetchAmuletRulesByKey app.dso + + submitMustFail (actAs provider.primaryParty <> readAs app.dso) $ + exerciseCmd amuletRulesCid AmuletRules_BuyDedicatedSyncTraffic with + inputs = [InputAmulet amuletCid] + context + provider = provider.primaryParty + memberId = "PAR::participant::1220ababababababababababababababababababababababababababababababab" + migrationId = 0 + trafficAmount = 1_000_000 + registeredSynchronizerCid = registeredCid + expectedDso = Some provider.primaryParty -- not the DSO + +-- | Negative: `RegisteredSynchronizer` requires a non-empty synchronizer id (template `ensure`). +test_RegisteredSynchronizer_ensureNonEmpty : Script () +test_RegisteredSynchronizer_ensureNonEmpty = do + d <- setupDefaultAppWithUsers + let app = d.app + operator <- allocateParty "dedicatedSyncOperator" + submitMustFail app.dso $ createCmd RegisteredSynchronizer with + dso = app.dso + synchronizerId = "" + operator diff --git a/daml/splice-amulet/daml/Splice/AmuletRules.daml b/daml/splice-amulet/daml/Splice/AmuletRules.daml index f1292fa96f..90b0a85890 100644 --- a/daml/splice-amulet/daml/Splice/AmuletRules.daml +++ b/daml/splice-amulet/daml/Splice/AmuletRules.daml @@ -305,6 +305,61 @@ template AmuletRules senderChangeAmulet = transferResult.senderChangeAmulet meta = Some meta + -- PoC (dedicated-synchronizer traffic): buy extra traffic for a DEDICATED (non-global) + -- synchronizer that has been registered via governance (see `RegisteredSynchronizer` and + -- `DsoRules_RegisterSynchronizer`, rung 1). This is a sibling of `AmuletRules_BuyMemberTraffic`; + -- the differences are: + -- * the target synchronizer is authorized by a disclosed `RegisteredSynchronizer` contract + -- rather than by membership in `requiredSynchronizers` (which today holds only the global sync); + -- * the created record (`DedicatedSyncTraffic`) is OBSERVED by the synchronizer's operator so + -- the operator's node can ingest the purchase on-ledger. + -- CC burning and fee computation are reused unchanged (`splitAndBurn`, `computeSynchronizerFees`). + nonconsuming choice AmuletRules_BuyDedicatedSyncTraffic : AmuletRules_BuyDedicatedSyncTrafficResult + with + inputs : [TransferInput] -- ^ amulet inputs funding the burn + context : TransferContext -- ^ standard transfer context (open mining round, etc.) + provider : Party -- ^ the buyer; burns CC from its own balance + memberId : Text -- ^ participant whose dedicated-sync traffic is credited (need not be `provider`) + migrationId : Int + trafficAmount : Int + registeredSynchronizerCid : ContractId RegisteredSynchronizer + -- ^ the registration authorizing this synchronizer; supplied via explicit disclosure + expectedDso : Optional Party -- ^ guards against a swapped-out AmuletRules (as in BuyMemberTraffic) + controller provider + do + checkExpectedDso dso expectedDso + -- Read the registration (disclosed). Its `synchronizerId` is authoritative and its `operator` + -- becomes the observer. `RegisteredSynchronizer_Fetch` lets `provider` read a contract it is + -- not a stakeholder of, mirroring how `computeSynchronizerFees` reads the OpenMiningRound. + registered <- fetchPublicReferenceData (ForDso dso) registeredSynchronizerCid (RegisteredSynchronizer_Fetch provider) + let synchronizerId = registered.synchronizerId + -- Only the min-topup floor applies here; the `requiredSynchronizers` gate is intentionally + -- skipped because the `RegisteredSynchronizer` is what authorizes this synchronizer id. + configUsd <- getValueAsOfLedgerTime configSchedule + case validateDedicatedSyncTopupAmount configUsd trafficAmount of + Left failureStatus -> failWithStatus failureStatus + Right _ -> pure () + (trafficCostAmulet, trafficCostUsd) <- computeSynchronizerFees dso provider trafficAmount this context + (transferResult, meta) <- splitAndBurn (toInterfaceContractId self) provider trafficCostAmulet inputs context dso "dedicated sync traffic purchase" + -- Record the purchase, observed by the operator so its node can pick it up on-ledger. + dedicatedTraffic <- create DedicatedSyncTraffic with + dso + operator = registered.operator + memberId + synchronizerId + migrationId + totalPurchased = trafficAmount + numPurchases = 1 + amuletSpent = trafficCostAmulet + usdSpent = trafficCostUsd + return AmuletRules_BuyDedicatedSyncTrafficResult with + round = transferResult.round + summary = transferResult.summary + amuletPaid = trafficCostAmulet + dedicatedTraffic + senderChangeAmulet = transferResult.senderChangeAmulet + meta = Some meta + nonconsuming choice AmuletRules_MergeMemberTrafficContracts : AmuletRules_MergeMemberTrafficContractsResult with @@ -1723,6 +1778,15 @@ validateBuyMemberTrafficInputs configUsd synchronizerId trafficAmount Left $ mkInsufficientTopupAmountFailure trafficAmount configUsd.decentralizedSynchronizer.fees.minTopupAmount | otherwise = Right () +-- PoC (dedicated-synchronizer traffic): like `validateBuyMemberTrafficInputs` but WITHOUT the +-- `requiredSynchronizers` gate. `AmuletRules_BuyDedicatedSyncTraffic` authorizes the synchronizer via +-- a `RegisteredSynchronizer` contract instead, so only the `minTopupAmount` floor is enforced here. +validateDedicatedSyncTopupAmount : AmuletConfig Unit.USD -> Int -> Either FailureStatus () +validateDedicatedSyncTopupAmount configUsd trafficAmount + | trafficAmount < configUsd.decentralizedSynchronizer.fees.minTopupAmount = + Left $ mkInsufficientTopupAmountFailure trafficAmount configUsd.decentralizedSynchronizer.fees.minTopupAmount + | otherwise = Right () + -- | Computing synchronizer fees computeSynchronizerFees : Party -> Party -> Int -> AmuletRules -> TransferContext -> Update (Decimal, Decimal) computeSynchronizerFees dso validator trafficAmount amuletRules context = do @@ -1893,6 +1957,17 @@ data AmuletRules_BuyMemberTrafficResult = AmuletRules_BuyMemberTrafficResult wit meta : Optional Metadata deriving (Show, Eq) +-- | Result of `AmuletRules_BuyDedicatedSyncTraffic` (PoC). Same shape as +-- `AmuletRules_BuyMemberTrafficResult` but returns the `DedicatedSyncTraffic` cid. +data AmuletRules_BuyDedicatedSyncTrafficResult = AmuletRules_BuyDedicatedSyncTrafficResult with + round : Round + summary : TransferSummary + amuletPaid : Decimal + dedicatedTraffic : ContractId DedicatedSyncTraffic + senderChangeAmulet : Optional (ContractId Amulet) + meta : Optional Metadata + deriving (Show, Eq) + data AmuletRules_CreateExternalPartySetupProposalResult = AmuletRules_CreateExternalPartySetupProposalResult with proposalCid : ContractId ExternalPartySetupProposal diff --git a/daml/splice-amulet/daml/Splice/DecentralizedSynchronizer.daml b/daml/splice-amulet/daml/Splice/DecentralizedSynchronizer.daml index 0240faef17..7b65f1bb9b 100644 --- a/daml/splice-amulet/daml/Splice/DecentralizedSynchronizer.daml +++ b/daml/splice-amulet/daml/Splice/DecentralizedSynchronizer.daml @@ -145,6 +145,37 @@ template RegisteredSynchronizer with instance HasCheckedFetch RegisteredSynchronizer ForDso where contractGroupId RegisteredSynchronizer{..} = ForDso with .. +-- PoC (dedicated-synchronizer traffic): the record of extra traffic purchased for a dedicated +-- synchronizer. It mirrors `MemberTraffic` but is OBSERVED by the synchronizer's operator, so the +-- operator's node can ingest purchases on-ledger (event-driven, no polling). It is a SEPARATE +-- template so the shared `MemberTraffic` (and its many Scala/TS codegen consumers) is untouched by +-- this PoC. Created by `AmuletRules_BuyDedicatedSyncTraffic`. +template DedicatedSyncTraffic with + dso : Party + -- ^ signatory; the DSO of the global synchronizer where the CC was burned + operator : Party + -- ^ observer; the dedicated synchronizer's operator (taken from the `RegisteredSynchronizer`) + memberId : Text + -- ^ the participant (member) whose dedicated-sync traffic was bought + synchronizerId : Text + -- ^ the dedicated synchronizer this traffic is for (taken from the `RegisteredSynchronizer`) + migrationId : Int + totalPurchased : Int -- ^ bytes of extra traffic bought + numPurchases : Int + amuletSpent : Decimal -- ^ CC burned for this purchase + usdSpent : Decimal -- ^ USD-equivalent cost + where + signatory dso + observer operator + + ensure totalPurchased >= 0 + && numPurchases >= 0 + && amuletSpent >= 0.0 + && usdSpent >= 0.0 + && migrationId >= 0 + && not (T.isEmpty memberId) + && not (T.isEmpty synchronizerId) + instance Patchable BaseRateTrafficLimits where patch new base current = BaseRateTrafficLimits with burstAmount = patch new.burstAmount base.burstAmount current.burstAmount diff --git a/daml/splice-dso-governance/daml/Splice/DsoRules.daml b/daml/splice-dso-governance/daml/Splice/DsoRules.daml index 7d939927e3..251ce9808b 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoRules.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoRules.daml @@ -29,7 +29,7 @@ import Splice.ValidatorLicense import Splice.Wallet.Subscriptions -- `RegisteredSynchronizer` (PoC): created by `DsoRules_RegisterSynchronizer` below; the template -- itself lives in splice-amulet because AmuletRules must be able to fetch it. -import Splice.DecentralizedSynchronizer (MemberTraffic, RegisteredSynchronizer) +import Splice.DecentralizedSynchronizer (MemberTraffic, RegisteredSynchronizer(..)) import qualified Splice.CometBft as CometBft import Splice.Ans