-
Notifications
You must be signed in to change notification settings - Fork 0
PoC: buy dedicated-synchronizer traffic in CC (rung 2/2) #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: daml-poc-registration
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) $ | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Negative case: |
||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sibling of |
||
| 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) | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Difference #1 vs BuyMemberTraffic: the target sync is authorized by a disclosed |
||
| 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 | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Difference #2: only the |
||
| 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 () | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Helper that drops the |
||
| 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 | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Result record mirroring |
||
| 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The purchase record. Deliberately a separate template from |
||
| 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Exercises the buy for a synchronizer id that is intentionally NOT in
requiredSynchronizers, submitted withreadAs app.dsofor in-script visibility (explicit disclosure on a live ledger). The assertions below check the CC was actually burned and theDedicatedSyncTrafficcarries the operator/synchronizer from the registration -- and that the operator can see it as an observer.