Skip to content

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

Open
salindne wants to merge 10 commits into
multi-sync-poc-registrationfrom
multi-sync-poc-buy-traffic
Open

PoC (draft): buy registered-synchronizer traffic in CC (rung 2/2)#2
salindne wants to merge 10 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 synchronizer, through the existing AmuletRules_BuyMemberTraffic.

  • AmuletRules_BuyMemberTraffic gains an appended optional argument, optRegisteredSynchronizer:
    • None (all existing callers): behavior is unchanged, byte for byte.
    • Some cid: the disclosed RegisteredSynchronizer (the registration gate from rung 1) authorizes the synchronizer id in place of the requiredSynchronizers check; the id must match the registration, and the minTopupAmount floor and expectedDso anti-tamper check still apply.
    • fees and burn reuse computeSynchronizerFees and splitAndBurn unchanged, so the economics are identical to the decentralized-sync traffic buy. The result type is unchanged, so Scan/wallet transaction-log parsing is untouched.
  • MemberTraffic gains an appended optional operator field, set from the registration and made an observer of the record — the operator's node picks up the purchase on-ledger (event-driven, no polling) and later grants the traffic on its own sequencer. Existing contracts carry None and behave as today; the appended-optional field and observer expression are upgrade-safe under Daml's smart-contract-upgrade rules.
  • AmuletRules_MergeMemberTrafficContracts requires all merged contracts to agree on the operator and carries it through the merge, so compaction reuses the existing machinery.

An earlier draft of this rung explored a sibling path (AmuletRules_BuyDedicatedSyncTraffic + a separate DedicatedSyncTraffic template); it was dropped in favor of the shape above.

How it's verified (TestBuyRegisteredSyncTraffic, Daml Script)

  • test_BuyRegisteredSyncTraffic (happy path): register, fund a buyer, run the buy with the registration; asserts a non-zero Amulet burn, a MemberTraffic with the expected dso/operator/synchronizerId/memberId/totalPurchased, and operator visibility.
  • test_BuyMemberTraffic_withoutRegistrationUnchanged: without a registration the choice behaves exactly as before — the decentralized-sync buy succeeds with no operator observer; an unknown synchronizer id still fails the requiredSynchronizers gate.
  • Negatives: registration/synchronizer-id mismatch rejected; below-minTopupAmount rejected on the registered path; mismatched expectedDso rejected before any burn; empty synchronizerId rejected.
  • test_MergeMemberTraffic_operatorPreserved: merging preserves the operator and sums the totals; merging contracts with disagreeing operators is rejected.

The full amulet, wallet, and dso-governance Daml Script suites pass with the change.

Design notes

Reuse-first: no new economics, one record type for all traffic purchases, and the existing merge/reconcile/Scan machinery applies to registered-synchronizer purchases as-is. The registration gate means traffic can only be bought for a governance-registered synchronizer. All template/choice changes are additive (appended optional fields) and upgrade-safe.

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

Implements E1-3 (ChainSafe/canton-extending-mainnet#29; ChainSafe work breakdown), part of Epic E1 (#13). The on-ledger half of the merge follow-up is done here (existing merge reused, operator-aware); #34 now tracks the residual operator-aware trigger grouping. Registry-held migrationId validation and registry uniqueness are tracked as follow-ups (ChainSafe/canton-extending-mainnet#55 / #54).

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.

Comment thread daml/splice-amulet/daml/Splice/DecentralizedSynchronizer.daml Outdated
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.

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.

Still open under the reworked shape: migrationId remains buyer-supplied, now with exactly the same trust surface as the existing decentralized-sync path. The registration doesn't pin it. Options are registry-held migrationId (tracked in ChainSafe/canton-extending-mainnet#27) or accepting parity with the existing path — input welcome.

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.

Concrete proposal for this one after discussing internally: registry-held and operator-maintained. The registration gains a migrationId field (initial value set by the registering vote); the operator gets a consuming choice on their own entry to bump it (monotonic), no vote needed — a wrong bump only breaks their own buyers, so it's self-policing. The buy then validates the supplied migrationId against the disclosed registration, and the operator's reconciler reads the same entry, closing the silent-burn path (a stale id today burns CC into a record the ingestion filter drops before the reconciler ever sums it). Registration and revocation stay governance-gated; only the epoch is operator self-service. Tracked as ChainSafe/canton-extending-mainnet#55 — flagging for your read before we build it into the rung-1 rework.

Comment thread daml/splice-amulet/daml/Splice/AmuletRules.daml Outdated
…uyMemberTraffic (rung 2 rework) [ci]

Rework the buy rung from a sibling path to the design-doc shape: extend the
existing member-traffic purchase instead of duplicating it.

- MemberTraffic: appended optional operator field (upgrade-safe), observed by
  the registered synchronizer's operator so its node can ingest purchases
  on-ledger
- AmuletRules_BuyMemberTraffic: appended optional RegisteredSynchronizer arg;
  a disclosed registration authorizes the synchronizer id in place of the
  requiredSynchronizers gate (the min-topup floor still applies)
- AmuletRules_MergeMemberTrafficContracts: merged contracts must agree on the
  operator, which carries through the merge
- drop the sibling AmuletRules_BuyDedicatedSyncTraffic choice,
  DedicatedSyncTraffic template, and twin validator
- rework the Daml Script tests to the new shape; fix Scala test fixtures for
  the new codegen arity
- regenerate checked-in DARs, dars.lock, DarResources

Signed-off-by: Timothy Wu <tim.wu@chainsafe.io>
@timwu20 timwu20 changed the title PoC (draft): buy dedicated-synchronizer traffic in CC (rung 2/2) PoC (draft): buy registered-synchronizer traffic in CC (rung 2/2) Jul 27, 2026
@timwu20

timwu20 commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Following up on the registration-model comment now that the rework has landed: still open, and slightly sharper — nothing stops a second DsoRules_RegisterSynchronizer vote from creating a duplicate registration for the same synchronizerId with a different operator, and a buyer can pass either one. The merge choice refuses to combine records with disagreeing operators, so the failure is contained, but the authorization question stands. The consume-once registry pattern proposed there is our fix; flagging so it's on the table during review.

Comment thread daml/splice-amulet/daml/Splice/AmuletRules.daml Outdated
Comment thread daml/splice-amulet/daml/Splice/AmuletRules.daml Outdated
Comment thread daml/splice-amulet/daml/Splice/AmuletRules.daml Outdated
migrationId = first.migrationId
fetchAndArchive expectedTrafficContract trafficCid
let traffics = first::rest
-- all merged contracts must agree on the operator observer, which carries through the merge

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When would the synchronizer ids match but the operators not match? Asked differently is this just defence in depth (which is totally reasonable) or do we actually expect it to trigger?

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.

Defence in depth — with unique registrations and no operator rotation it should never trigger. It exists because free-standing registrations don't structurally prevent two entries for one sync id with different operators; if that ever happens, refusing the merge keeps the mixed-operator set visible instead of silently blending it. Comment on the assert now says so (c61ca53).

provider : Party
memberId : Text
synchronizerId : Text
migrationId : Int

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I would suggest to enforce migrationId = 0 when registered synchronizer is set. Migration ids at this point are only a historical thing. We need to keep them for the global synchronizer but they make no sense for any new synchronizer.

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.

Agreed, and done in c61ca53: the buy now rejects a non-zero migrationId when a registration is supplied (dedicated failure status + negative test), folded into the unified validation per your other comment.

@moritzkiefer-da

Copy link
Copy Markdown

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.

Two parts here:

  1. I'm not sure we want to support operator rotation at all. That would also require changing the operator on all member traffic contracts. That generally seems to be a bit of a mess. I'd go for just requiring spinning up a fully new synchronizer.
  2. I do think we want uniqueness of synchronizer ids. However, it's not clear to me if we really need to enforce this on-ledger. Keep in mind that new registrations require SVs to vote. So if we add an off-ledger check in the SV UI to check if there already is an existing registered synchronizer for a synchronizer id, that is probably good enough to enforce uniqueness here.

timwu20 added 2 commits July 28, 2026 22:36
…onId=0 gate)

Per review on PR #2:
- fold all buy validation into validateBuyMemberTrafficInputs (registration
  passed in): min-topup floor in all cases, requiredSynchronizers gate without
  a registration, id-match + migrationId = 0 with one
- registered synchronizers have no migration history; enforce migrationId 0
  with a dedicated failure status (new negative test)
- forA instead of the case block for the registration fetch
- comment cleanups: drop appended-last notes; state the merge assert's
  defence-in-depth intent

Signed-off-by: Timothy Wu <tim.wu@chainsafe.io>
Signed-off-by: Timothy Wu <tim.wu@chainsafe.io>
@timwu20

timwu20 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator
  1. I'm not sure we want to support operator rotation at all. [...]
  2. I do think we want uniqueness of synchronizer ids. However, it's not clear to me if we really need to enforce this on-ledger. [...]

(full comment)

Works for us on both counts. Dropping operator rotation from scope kills the nastiest path (stale registration after handover), and with registrations being vote-gated, an SV-UI duplicate check is a proportionate guard — we'll re-scope our tracking issue from the consume-once registry to that check.

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.

3 participants