Skip to content

PoC (draft): buy dedicated-synchronizer traffic in CC (rung 2/2)#2

Draft
salindne wants to merge 7 commits into
multi-sync-poc-registrationfrom
multi-sync-poc-buy-traffic
Draft

PoC (draft): buy dedicated-synchronizer traffic in CC (rung 2/2)#2
salindne wants to merge 7 commits into
multi-sync-poc-registrationfrom
multi-sync-poc-buy-traffic

Conversation

@salindne

@salindne salindne commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Draft PoC, rung 2 of 2, stacked on multi-sync-poc-registration (#1).

What this does

Adds the on-ledger purchase step: a buyer burns Canton Coin (Amulet) on the decentralized synchronizer to buy extra traffic for a registered dedicated synchronizer, reusing Splice's existing traffic-fee and burn machinery unchanged.

  • AmuletRules_BuyDedicatedSyncTraffic choice:
    • requires a matching RegisteredSynchronizer (supplied via explicit disclosure), the registration gate from rung 1;
    • reuses computeSynchronizerFees (bytes to USD to CC) and splitAndBurn exactly as the existing AmuletRules_BuyMemberTraffic does, so the economics are identical to the decentralized-sync traffic buy;
    • enforces the minTopupAmount floor and an expectedDso anti-tamper check;
    • records a DedicatedSyncTraffic.
  • DedicatedSyncTraffic template: the purchase record. Signatory is the DSO party, and it is observed by the operator, so the operator's node can pick up the purchase on-ledger (event-driven, no polling) and later grant the traffic on its own sequencer. Separate template; no MemberTraffic schema change.

How it's verified (TestBuyDedicatedSyncTraffic, Daml Script)

  • test_BuyDedicatedSyncTraffic (happy path): register, fund a buyer, run the buy; asserts a non-zero Amulet burn (amuletPaid > 0), a DedicatedSyncTraffic with the expected dso/operator/synchronizerId/memberId/totalPurchased, and operator visibility.
  • Negatives: below-minTopupAmount rejected; mismatched expectedDso rejected before any burn; empty synchronizerId rejected.

Design notes

Additive and reuse-first: no new economics, no MemberTraffic schema change, no change to the existing AmuletRules_BuyMemberTraffic path. The registration gate means traffic can only be bought for a governance-registered synchronizer.

Out of scope (next phase)

On-ledger only. This proves authorize, burn, record. The operator actually granting traffic on its sequencer (reconcile trigger + operator node) and a LocalNet register/buy/grant end-to-end are the next workstream.

Tracked in

Internal work breakdown in ChainSafe/canton-extending-mainnet (private): #29 (AmuletRules_BuyDedicatedSyncTraffic + DedicatedSyncTraffic); part of Epic #13 (E1) and #12 (E0). Follow-ups from review: #34 (merge machinery), and registry-held migrationId validation (#27 / #29).

Signed-off-by: Sebastian Lindner <33971232+salindne@users.noreply.github.com>
@salindne
salindne force-pushed the multi-sync-poc-registration branch from 51a8808 to 56e4666 Compare July 21, 2026 10:14
@salindne
salindne force-pushed the multi-sync-poc-buy-traffic branch from 05a8667 to 027008d Compare July 21, 2026 10:14
salindne added 6 commits July 21, 2026 05:59
Signed-off-by: Sebastian Lindner <33971232+salindne@users.noreply.github.com>
Signed-off-by: Sebastian Lindner <33971232+salindne@users.noreply.github.com>
… [ci]

Signed-off-by: Sebastian Lindner <33971232+salindne@users.noreply.github.com>
The buy rung adds AmuletRules_BuyDedicatedSyncTraffic + DedicatedSyncTraffic to
splice-amulet, so its DAR (and dependents that embed it) differ from the
registration rung. Regenerated with 'sbt damlDarsLockFileUpdate' after merging
the registration SCU + DAR-lock fix. [ci]

Signed-off-by: Sebastian Lindner <33971232+salindne@users.noreply.github.com>
…al_daml_references)

Splice's rename.sh no_illegal_daml_references check bans certain words in Daml
(global, coin, domain, ...) and restricts DSO/member phrasing. Our PoC comments
used several. Comment-only rewrite (no behaviour change):
- global -> decentralized (the DSO-run synchronizer; matches the module name)
- CC -> Amulet (the on-ledger token; 'Canton Coin' is also banned via 'coin')
- DSO <x> -> 'DSO party' / 'DSO governance' (the only allowed forms)
- reworded 'Dso'/'dso'/'splice-dso-governance' comment mentions

Verified locally: scripts/rename.sh no_illegal_daml_references exits 0, and the
full daml static-check suite passes. Rebuilt checked-in DARs + dars.lock. [ci]

Signed-off-by: Sebastian Lindner <33971232+salindne@users.noreply.github.com>
…uy artifacts

Reconcile buy onto @timwu20's registration work (c514673):
- adopt his terminology wording for the shared files (DecentralizedSynchronizer
  RegisteredSynchronizer part, DsoRules, TestRegisterSynchronizer)
- keep buy-only content (AmuletRules_BuyDedicatedSyncTraffic, DedicatedSyncTraffic,
  TestBuyDedicatedSyncTraffic) with the same approved terminology
- regenerate checked-in DARs, dars.lock, and DarResources.scala for the buy rung
  (fixes the 'Verify no changes in SBT test files' static check)

Verified locally: scripts/rename.sh no_illegal_daml_references exits 0; DARs +
DarResources regenerated via damlDarsLockFileUpdate + updateDarResources. [ci]

Signed-off-by: Sebastian Lindner <33971232+salindne@users.noreply.github.com>
@timwu20

timwu20 commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

The PoC's registration model is a set of free-standing, DSO-signed RegisteredSynchronizer contracts, and AmuletRules_BuyDedicatedSyncTraffic derives synchronizerId and operator from whichever registration cid the buyer discloses. Every such cid is genuinely governance-created, so there's no forgery issue — but nothing on-ledger says "at most one live registration per synchronizer id," and nothing links successor registrations to their predecessors.

The scenario that makes this bite needs no attacker, just operator rotation: sync S hands over from operator A to B; governance registers (S, B); the old (S, A) contract stays active because nothing archives it. Buyers holding the old cid keep purchasing against it — the burn succeeds, the DedicatedSyncTraffic record is created with A as observer, and B's reconcile automation never sees it. Net: the buyer's Amulet is burned and the traffic grant is never applied, with no error anywhere. Once reward reporting lands, a stale registration is also a lever for misattributing another sync's activity.

The template can't self-defend as written: choices can't query the ACS, and Daml 3.x has no contract keys — so uniqueness needs a serialization point. Proposal: make a singleton registry the only factory for registration children, keeping the operator-observer and disclosed-fetch mechanics of this PR untouched:

template SynchronizerRegistry with
    dso : Party
    entries : Map Text (Party, ContractId RegisteredSynchronizer) -- syncId -> (operator, live child)
  where
    signatory dso
    choice Registry_Register : ... with synchronizerId; operator
      controller dso
      do require "not already registered" (not (Map.member synchronizerId entries))
         child <- create RegisteredSynchronizer with dso; synchronizerId; operator
         create this with entries = Map.insert synchronizerId (operator, child) entries
    choice Registry_Rotate : ...     -- archives the predecessor child + inserts the new one, atomically
    choice Registry_Deregister : ... -- archives the child + removes the entry

Because every register/rotate consumes the singleton, concurrent registrations become a clean ledger conflict (one wins, one rejects) — uniqueness by UTXO semantics rather than by discipline. Rotation archives the predecessor in the same transaction, and since an archived cid can no longer be fetched, buyers holding a stale reference fail fast instead of burning into a void. Serialization through one contract is a non-cost at governance cadence, and a dedicated singleton avoids growing AmuletConfig.

-- 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
Collaborator

Choose a reason for hiding this comment

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

Flagging for when this carries real traffic: we'll need a merge path for these records. Every purchase creates one (numPurchases stays 1) and nothing consumes them — at the auto-top-up cadence that's ~144/day per active member per sync. MemberTraffic handles this with DsoRules_MergeMemberTrafficContracts + the SV merge trigger + numMemberTrafficContractsThreshold; since this is deliberately a separate template we need the twin of that machinery. Without it: every SV participant stores every record forever (dso is sole signatory), the reconciler's exact sum per (memberId, synchronizerId, migrationId) is O(n) over an ever-growing set (the grant is an absolute total, so no windowing), and none of it is ever prunable.

One gotcha: the merged record must keep operator as observer, or our on-ledger ingestion goes blind right when compaction starts. Ties into Itai's note about scoping the merge trigger per sync id — same work item from this template's side.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, this is required before it carries real traffic. Tracked in ChainSafe/canton-extending-mainnet#34, which I've expanded to cover the on-ledger merge choice (twin of DsoRules_MergeMemberTrafficContracts) + a threshold config + per-sync-id scoping, and I called out the operator-must-stay-observer constraint explicitly. Out of scope for this on-ledger PoC (it's in the next workstream), but captured. Thanks for the detailed flag.

context : TransferContext -- ^ standard transfer context (open mining round, etc.)
provider : Party -- ^ the buyer; burns Amulet from its own balance
memberId : Text -- ^ participant whose dedicated-sync traffic is credited (need not be `provider`)
migrationId : Int

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Heads-up: migrationId is buyer-supplied with no validation and no on-ledger discovery path. The reconciler will only sum the current migration id, but for a dedicated sync that id is operator-managed — a buyer passing a stale one burns Amulet into a record we'll never count, silently. Upstream BuyMemberTraffic has the same shape, but there the top-up automation knows the gsync migration id; our buyers have no equivalent.

If we go the registry-singleton route (see the registry comment), the registry entry is the natural home for the sync's current migrationId — closes the validation gap and gives buyers discovery in one move. (memberId is similarly free text with only a non-empty check; lower stakes, same idea.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, and the registry-singleton fix is the right shape. Tracked: ChainSafe/canton-extending-mainnet#27 (hold the sync's current migrationId on the RegisteredSynchronizer entry, for validation + buyer discovery) and ChainSafe/canton-extending-mainnet#29 (validate the buy against it), which closes the silent-loss and discovery gaps in one move. We'll decide whether to fold it into this PoC now or take it next iteration.

senderChangeAmulet = transferResult.senderChangeAmulet
meta = Some meta

-- PoC (dedicated-synchronizer traffic): buy extra traffic for a DEDICATED (non-decentralized)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Tiny one: "non-decentralized" reads backwards — a BFT extension sync is decentralized. Maybe "separately-operated synchronizer"? Stays inside the vocabulary rules.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch, "non-decentralized" does read backwards. Will change to "separately-operated synchronizer" (stays within the vocabulary rules). Comment-only, so it rides the next DAR regen.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants