Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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

Copy link
Copy Markdown
Author

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 with readAs app.dso for in-script visibility (explicit disclosure on a live ledger). The assertions below check the CC was actually burned and the DedicatedSyncTraffic carries the operator/synchronizer from the registration -- and that the operator can see it as an observer.

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) $

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Negative case: trafficAmount = 1 is below minTopupAmount, so the buy is rejected even though the requiredSynchronizers gate is skipped -- proves dropping that gate did not drop the floor.

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
75 changes: 75 additions & 0 deletions daml/splice-amulet/daml/Splice/AmuletRules.daml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sibling of AmuletRules_BuyMemberTraffic (just above). Same controller provider + nonconsuming shape and the same CC-burn machinery; the only real differences are how the target synchronizer is authorized and where the purchase is recorded -- see the two comments below.

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)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Difference #1 vs BuyMemberTraffic: the target sync is authorized by a disclosed RegisteredSynchronizer (rung 1) rather than by requiredSynchronizers. fetchPublicReferenceData + the public RegisteredSynchronizer_Fetch choice let provider -- not a stakeholder -- read it, exactly as computeSynchronizerFees reads the OpenMiningRound. registered.synchronizerId/registered.operator are authoritative from here on.

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Difference #2: only the minTopupAmount floor is enforced (validateDedicatedSyncTopupAmount), NOT the requiredSynchronizers gate -- the registration is the authorization. Everything after this (computeSynchronizerFees, splitAndBurn) is reused unchanged, so the CC burn is identical to a member-traffic purchase.

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
Expand Down Expand Up @@ -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 ()

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Helper that drops the requiredSynchronizers check from validateBuyMemberTrafficInputs and keeps only the min-topup floor. Split out (rather than parameterizing the existing validator) so the member-traffic path is byte-for-byte untouched.

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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Result record mirroring AmuletRules_BuyMemberTrafficResult; the only field change is dedicatedTraffic : ContractId DedicatedSyncTraffic in place of purchasedTraffic.

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
Expand Down
31 changes: 31 additions & 0 deletions daml/splice-amulet/daml/Splice/DecentralizedSynchronizer.daml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The purchase record. Deliberately a separate template from MemberTraffic rather than adding an operator observer field to it: MemberTraffic has ~41 Scala/TS codegen consumers and no observers today, so an observer field is a breaking schema change. A sibling keeps rung 2 additive and CI green. signatory dso + observer operator (line below) is what lets the operator's node ingest purchases on-ledger.

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
Expand Down
2 changes: 1 addition & 1 deletion daml/splice-dso-governance/daml/Splice/DsoRules.daml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down