From fa9e5c41af3be876407f4e967049e1fd72d4f650 Mon Sep 17 00:00:00 2001 From: scolear Date: Mon, 14 Sep 2026 15:25:27 +0200 Subject: [PATCH 01/24] docs: add the Noise-sunset coordination design Decman coordinated every multi-party workflow over a Noise P2P transport. Canton already carries partially signed topology proposals to every member and Daml contracts to every observer. This design replaces Noise with those native paths: a node party and registry contract per node, workflow proposals and acceptances in Daml, co-signing by transaction hash in the synchronizer store, signature rounds for prepared transactions, hash-pinned DAR uploads, and an operator-moved ACS snapshot. --- docs/NOISE_SUNSET_DESIGN.md | 591 ++++++++++++++++++++++++++++++++++++ 1 file changed, 591 insertions(+) create mode 100644 docs/NOISE_SUNSET_DESIGN.md diff --git a/docs/NOISE_SUNSET_DESIGN.md b/docs/NOISE_SUNSET_DESIGN.md new file mode 100644 index 000000000..a4df71f9b --- /dev/null +++ b/docs/NOISE_SUNSET_DESIGN.md @@ -0,0 +1,591 @@ +# Noise sunset: Canton-native coordination + +**Status:** implementation spec, 2026-09-14. +**Scope:** remove the Noise P2P transport from decman. Nodes coordinate only through +Canton: the synchronizer topology store, Daml contracts, and the Ledger API. + +This document is the shared contract for the implementation. Every section states a +decision. The research that grounds each decision is summarized in section 12. + +--- + +## 1. Thesis + +Canton is already a peer-to-peer network. Every operation decman coordinates over Noise +has a native Canton equivalent: + +| Noise did this | Canton does this | +|---|---| +| Ship a topology proposal to peers for signing | Partially-signed proposals propagate through the synchronizer store to every member (`proposal = true`) | +| Collect signatures and resubmit | Each member co-signs by `Authorize { transaction_hash }`; Canton merges signatures by fingerprint | +| Announce a node, its version, its keys | A self-signed Daml registry contract (`DecmanNode`) plus the self-signed root `NamespaceDelegation` in the topology store | +| Invite a peer to a workflow | A Daml `WorkflowProposal` with the invitee as observer | +| Accept or decline | A Daml choice on that proposal | +| Collect Daml transaction signatures | A Daml `SubmissionRound` with `SubmissionSignature` records | +| Push DAR bytes | A hash-pinned `DarPin`; each operator uploads locally; vetting is visible in the topology store | +| Ship an ACS snapshot | Offline party replication stays; the operator moves the file; an on-ledger `AcsManifest` pins it | +| Ping for liveness | A Daml heartbeat on `DecmanNode` | + +No decman node ever opens a connection to another decman node. + +## 2. Decisions + +### D1. Node identity: a node party + +Each decman node has one **node party**. The node party is a normal Canton party hosted on +the node's participant with Confirmation permission. It has a Ledger API user with +`CanActAs` and `CanReadAs` on itself. It exists before any decentralized party exists. + +Storage: a `party_credentials` row with `kind = 'node'` and `dec_party_id = `. +`AuthRegistry::get(node_party)` returns its token manager unchanged. Migration `000020` +adds `kind TEXT NOT NULL DEFAULT 'decparty'`. Decparty-oriented views filter `kind = 'decparty'`. + +HTTP: `GET /node-identity` and `PUT /node-identity` (admin; the PUT is bootstrap-exempt while no +node identity exists, using the existing bootstrap mutex). Body = `PartyConfigRequest` shape +without `dec_party_id` (member party id becomes the node party id). The existing +`POST /party-config/discover-member-party` pre-fills it. + +The node party signs: the registry entry, workflow proposals, acceptances, declines, +submission rounds, signatures, ACS manifests, and heartbeats. + +### D2. Discovery: one string out of band + +Operators exchange one string per peer: `participant_id,node_party_id,name`. The UI button +"Share my identity" copies it. "Add peer" pastes it. The `peers` table keeps +`(participant_id PK, name, party)` and drops `address`, `port`, `public_key`. + +Before a node writes any contract that names a peer node party as observer, it verifies +through the tokenless Admin API that the party is hosted on the claimed participant +(`ListPartyToParticipant(filter_party = node_party)` head state contains `participant_id` +with Confirmation or Submission permission). This is the only trust check; the security +boundary stays the operator's own co-signature. + +### D3. Registry: `DecmanNode` + +Every node publishes one `DecmanNode` contract (signatory = node party, observers = the peer +node parties from its `peers` table). Fields: `participantId`, `displayName`, `version`, +`buildVersion`, `coordinationVersion`, `peers`, `lastActiveAt`, `heartbeatIntervalSecs`, +`minHeartbeatIntervalSecs`. Choices: `DecmanNode_Heartbeat` (re-create with `lastActiveAt = now`, +guarded by the minimum interval), `DecmanNode_Update` (peers, names, versions), `DecmanNode_Retire`. + +The registry entry is (re)published at startup and whenever the peers table changes. +Heartbeat cadence is `DECPM_HEARTBEAT_INTERVAL_SECS` (default 3600). A peer is `Stale` when +`now - lastActiveAt > 3 × heartbeatIntervalSecs`. Tests set the interval to 5 s. + +Peer health: `/participants-status` is served from an in-memory snapshot that the observer +loop refreshes. `ConnectionStatus` becomes `CurrentNode | Active | Stale | Unknown`. +`latency_ms` is removed. New fields: `last_seen_at`, `heartbeat_age_secs`, `node_party`. +`version` and `build_version` come from the registry entry. + +Version gate: a proposer refuses to start a workflow (HTTP 409, naming the participants) +unless every invitee has a registry entry with `coordinationVersion >= COORDINATION_VERSION`. +`MIN_PEER_VERSION` and `WIRE_VERSION` are deleted. `COORDINATION_VERSION = 1`. +The decman crate version becomes `2.0.0`. + +### D4. Key model: one dual-usage key per member per decentralized party + +For every NEW decentralized party each member generates ONE vault key named +`{prefix}-key` with usages `[Namespace, Protocol]`. Its self-signed root `NamespaceDelegation` +puts the full `SigningPublicKey` into the synchronizer store. The proposer reads it with +`ListNamespaceDelegation(store = Synchronizer, filter_namespace = fp, filter_target_key_fingerprint = fp)` +and copies `target_key` into `PartyToParticipant.party_signing_keys`. The DND owner +fingerprint equals the party signing-key fingerprint, so kick attribution is on-chain. + +Legacy parties (two keys `{prefix}-namespace` + `{prefix}-daml-transactions`) keep working +unchanged: kick, add-party, change-threshold sign with `signed_by = []` and Canton +auto-selects the legacy namespace key. Own-Daml-key lookup is on-chain first +(`party_signing_keys ∩ ListMyKeys`), then `dec_party_identity`, then the legacy vault name. + +Key generation is idempotent by name (existing `get_or_create_signing_key`). + +### D5. Topology coordination: proposals in the synchronizer store + +Proposer: +1. Read the accepted mapping (`proposals = false`, `Snapshot(MaxValue)`) to get serial `S`. +2. `Authorize { proposal { ADD_REPLACE, serial = S + 1 (or 1), mapping }, must_fully_authorize = false, signed_by = [], store = Synchronizer(physical_id) }`. +3. Never pass `FORCE_FLAG_ALLOW_UNVALIDATED_SIGNING_KEYS`. Never use the Authorized store. +4. Keep the returned `transaction_hash` on the run row. + +Members (observer loop, every `DECPM_OBSERVER_POLL_SECS`, default 3): +1. `ListDecentralizedNamespaceDefinition` and `ListPartyToParticipant` with + `BaseQuery { store = Synchronizer, proposals = true, time_query = Snapshot(MaxValue) }`. +2. Keep proposals that name this node (owner fingerprint in `owners`, or participant in + `participants`) and that this node has not signed (`signed_by_fingerprints`). +3. Match each proposal to an accepted `WorkflowProposal` (D6). Unmatched proposals are + listed in the UI as "unsolicited"; they are never signed automatically. +4. Validate against the accepted proposal and head state (section 5). +5. Guard: `proposal.serial == accepted.serial + 1` (or `1` with no accepted mapping). +6. Co-sign: `Authorize { transaction_hash = hex(BaseResult.transaction_hash), must_fully_authorize = false, signed_by = [], store = Synchronizer }`. + Retry `TOPOLOGY_TRANSACTION_NOT_FOUND` on the next poll. Treat + `TOPOLOGY_NO_APPROPRIATE_SIGNING_KEY_IN_STORE` with the existing retry budget. +7. Completion: poll `proposals = false` until `serial == S + 1` and `valid_from <= now`. + +Order for onboarding: DND first. Wait until the DND is effective in the synchronizer store +on this node. Then P2P. Before proposing a DND, wait until every owner's root +`NamespaceDelegation` is effective in the synchronizer store. + +Cancel: a topology proposal cannot be withdrawn. Cancel archives the `WorkflowProposal`; +members stop co-signing because the match disappears. A stale proposal without the members' +signatures can never become effective. + +The clearing of a joiner's Onboarding flag needs only the joiner's participant signature. +The joiner calls `ClearPartyOnboardingFlag` and polls until `onboarded == true`. No +co-sign round exists for it. + +### D6. Workflow intent: `WorkflowProposal` + +Every run starts with a `WorkflowProposal` (signatory = proposer node party, observers = +invitee node parties). It carries the human-readable intent the invitation card shows today +and the reference set the member validates against. Invitees `Accept` (non-consuming, creates +`WorkflowAcceptance`) or `Decline` (creates `WorkflowDecline`). The proposer `Cancel`s or +`Finish`es it. Every `WorkflowAcceptance` carries the acceptor's `participantId` and, for +onboarding and add-party, its fresh key material: `namespaceFingerprint` and +`signingPublicKeyHex`. Acceptances are visible to the proposer and to all invitees, so every +member can verify the full owner set before co-signing. + +Consent model: the operator accepts once. After that the node co-signs every topology +proposal that matches the accepted `WorkflowProposal` and passes validation. This is the same +model as today, with a stronger reference set. + +### D7. Contracts workflow: `SubmissionRound` + +The proposer runs `PrepareSubmission` once per contract definition with +`max_record_time = preparation_time + 20h` and creates a `SubmissionRound` per prepared +transaction (bytes as lowercase hex, hash, hashing scheme version, deadline). Members that +accepted the `WorkflowProposal` re-hash the bytes with `canton_hash`, check `act_as`, sign +with their per-decparty key (vault export or KMS), and exercise `SubmissionRound_Sign`. When +the proposer sees `>= party_signing_keys.threshold` distinct fingerprints it executes one +`ExecuteSubmissionAndWait` with all signatures and closes the round. Deadline is 24 h minus a +margin. Expired rounds are re-prepared and re-signed. + +### D8. DARs: hash-pinned local upload + +`POST /dars/distribute` creates a `WorkflowProposal(kind = Dars)` with `DarPin { filename, +sha256Hex, mainPackageId, sizeBytes }` per file. The proposer uploads locally. Each invitee's +UI shows the pins; the operator uploads the same files through `POST /dars/upload`, which +refuses a file whose sha256 does not match a pending pin when a `pin` id is supplied. The +proposer observes every participant's `VettedPackages` in the synchronizer store +(`ListVettedPackages(filter_participant)`) and completes the run when all pins are vetted +everywhere. `/packages/compare-peers` reads vetted packages per participant from the +topology store. No DAR bytes travel over any decman channel. + +The coordination DAR (`releases/v1/decman-coordination-v1-0.1.0.dar`) is embedded with +`include_bytes!` and uploaded and vetted at startup (`DECPM_AUTO_UPLOAD_COORDINATION_DAR`, +default true). `POST /dars/upload` remains the manual fallback. + +### D9. ACS synchronization: offline replication, operator-moved file + +Canton online party replication is alpha and unusable on protocol version 35. Offline +replication stays. When a current host observes the P2P that marks the joiner `Onboarding` +become effective, it exports the snapshot to a spool file and publishes an `AcsManifest` +(party, target participant, activation serial, exporter participant, size, sha256, +package ids). If `sizeBytes == 0` the joiner skips the import and clears its flag. Otherwise: + +* `GET /acs-export/{party}/{target}?serial=N` (admin JWT, streamed gzip) on any current host. +* `POST /acs-import/{party}?serial=N` (admin JWT, streamed body) on the joiner. The joiner + verifies: head state marks itself Onboarding at that serial; a manifest from a + non-onboarding host exists; sha256 and size match that manifest; every manifest package id + is vetted locally. Then it runs the existing disconnect / `ImportPartyAcs` / reconnect + bracket and clears the flag. + +The add-party run on the joiner waits in step `SyncAcs` until the import completes or the +empty fast path fires. + +### D10. Cancel, decline, retry + +* Cancel (proposer): exercise `WorkflowProposal_Cancel`, mark the run `Cancelled`. Members + see the proposal vanish and mark their peer rows `Cancelled`. +* Decline (invitee): exercise `WorkflowProposal_Decline`. The proposer marks the run `Failed` + ("Peer X declined the invitation"). Sibling invitees mark their rows `Cancelled`. +* Retry: idempotent "ensure". The proposer re-reads the accepted serial and re-proposes only + if its proposal is gone. Members re-issue `Authorize { transaction_hash }`. No broadcast. +* Dismiss: unchanged. + +### D11. Projection tables + +`workflow_runs` stays as the UI projection with the same status vocabulary. New columns: +`proposal_cid`, `coordinator_participant` (renamed from `coordinator_pubkey`), +`coordinator_party`, `topology_hashes_json`. `pending_invitations` stays as the local cache +of unaccepted `WorkflowProposal`s that name this node: `coordinator_pubkey` becomes +`coordinator_participant`, plus `proposal_cid`, `coordinator_party`. `workflow_artifacts` +stays for local-only artefacts (keys, offsets, spool file paths). + +### D12. Rollout + +Old builds refuse to start a workflow when any invitee's Noise listener is gone. New builds +refuse until every invitee has a registry entry. So a mixed window is safe. Migration 000020 +fail-marks every `inprogress` run with an explanatory error. Runbook: drain, upgrade each +node inside the window, configure the node identity, verify registry entries, then start +workflows. + +## 3. Daml package `decman-coordination-v1` + +Location: `daml/decman-coordination/` (`name: decman-coordination-v1`, `version: 0.1.0`, +`sdk-version: 3.4.11`, `--target=2.2`, dependencies: `daml-prim`, `daml-stdlib` only). +Test package: `daml/decman-coordination-test/` with `daml-script` and `../dars/testlib-0.1.0.dar`. +Both go into `daml/multi-package.yaml`. The built DAR is committed to +`releases/v1/decman-coordination-v1-0.1.0.dar`. CI gets a `dpm test` step for the test package. + +Text encoding: fingerprints and hashes are Canton hex strings (`1220...`). Public keys are the +lowercase hex of the serialized protobuf `SigningPublicKey`. Signatures are lowercase hex of +the raw signature bytes. Every hex field is validated with `DA.Crypto.Text.isHex` +(build option `-Wno-crypto-text-is-alpha`). + +```daml +module Decman.Coordination.Types where +data WorkflowKind = Onboarding | AddParty | Kick | ChangeThreshold | Contracts | Dars + deriving (Eq, Show) +data DarPin = DarPin with filename : Text; sha256Hex : Text; mainPackageId : Text; sizeBytes : Int + deriving (Eq, Show) +``` + +```daml +module Decman.Coordination.Node where +template DecmanNode + with + node : Party + participantId : Text + displayName : Text + version : Text + buildVersion : Text + coordinationVersion : Int + peers : [Party] + lastActiveAt : Time + heartbeatIntervalSecs : Int + minHeartbeatIntervalSecs : Int + where + signatory node + observer peers + ensure heartbeatIntervalSecs > 0 && minHeartbeatIntervalSecs >= 0 && coordinationVersion >= 1 + choice DecmanNode_Heartbeat : ContractId DecmanNode + controller node + do now <- getTime + assertMsg "heartbeat too soon" (now >= addRelTime lastActiveAt (seconds minHeartbeatIntervalSecs)) + create this with lastActiveAt = now + choice DecmanNode_Update : ContractId DecmanNode + with newPeers : [Party]; newDisplayName : Text; newVersion : Text; newBuildVersion : Text + newCoordinationVersion : Int; newHeartbeatIntervalSecs : Int; newMinHeartbeatIntervalSecs : Int + controller node + do now <- getTime + create this with peers = newPeers; displayName = newDisplayName; version = newVersion + buildVersion = newBuildVersion; coordinationVersion = newCoordinationVersion + heartbeatIntervalSecs = newHeartbeatIntervalSecs + minHeartbeatIntervalSecs = newMinHeartbeatIntervalSecs; lastActiveAt = now + choice DecmanNode_Retire : () + controller node + do pure () +``` + +```daml +module Decman.Coordination.Workflow where +template WorkflowProposal + with + proposer : Party + proposerParticipant : Text + instance : Text + kind : WorkflowKind + invitees : [Party] + participants : [Text] + decPartyId : Optional Text + prefix : Optional Text + threshold : Optional Int + previousThreshold : Optional Int + newParticipant : Optional Text + kickedParticipant : Optional Text + darPins : [DarPin] + packageNames : [Text] + description : Text + createdAt : Time + where + signatory proposer + observer invitees + nonconsuming choice WorkflowProposal_Accept : ContractId WorkflowAcceptance + with acceptor : Party; participantId : Text; namespaceFingerprint : Optional Text + signingPublicKeyHex : Optional Text; memberParty : Optional Party + controller acceptor + do assertMsg "not invited" (acceptor `elem` invitees) + now <- getTime + create WorkflowAcceptance with proposal = self; proposer; acceptor; observers = invitees + instance; participantId; namespaceFingerprint; signingPublicKeyHex; memberParty; acceptedAt = now + nonconsuming choice WorkflowProposal_Decline : ContractId WorkflowDecline + with decliner : Party; reason : Text + controller decliner + do assertMsg "not invited" (decliner `elem` invitees) + now <- getTime + create WorkflowDecline with proposal = self; proposer; decliner; observers = invitees; instance; reason; declinedAt = now + choice WorkflowProposal_Cancel : () + controller proposer + do pure () + choice WorkflowProposal_Finish : ContractId WorkflowOutcome + with succeeded : Bool; error : Optional Text + controller proposer + do now <- getTime + create WorkflowOutcome with proposer; instance; kind; observers = invitees; succeeded; error; finishedAt = now + +template WorkflowAcceptance + with proposal : ContractId WorkflowProposal; proposer : Party; acceptor : Party; observers : [Party] + instance : Text; participantId : Text; namespaceFingerprint : Optional Text + signingPublicKeyHex : Optional Text; memberParty : Optional Party; acceptedAt : Time + where + signatory acceptor + observer proposer, observers + choice WorkflowAcceptance_Archive : () controller acceptor do pure () + +template WorkflowDecline + with proposal : ContractId WorkflowProposal; proposer : Party; decliner : Party; observers : [Party] + instance : Text; reason : Text; declinedAt : Time + where + signatory decliner + observer proposer, observers + choice WorkflowDecline_Archive : () controller decliner do pure () + +template WorkflowOutcome + with proposer : Party; instance : Text; kind : WorkflowKind; observers : [Party] + succeeded : Bool; error : Optional Text; finishedAt : Time + where + signatory proposer + observer observers + choice WorkflowOutcome_Archive : () controller proposer do pure () +``` + +```daml +module Decman.Coordination.Submission where +template SubmissionRound + with + proposer : Party + instance : Text + index : Int + signers : [Party] + decPartyId : Text + actAs : Text + description : Text + preparedTransactionHex : Text + preparedHashHex : Text + hashingSchemeVersion : Int + preparationTime : Time + deadline : Time + where + signatory proposer + observer signers + nonconsuming choice SubmissionRound_Sign : ContractId SubmissionSignature + with signer : Party; participantId : Text; signedBy : Text; signatureHex : Text; format : Text; algorithm : Text + controller signer + do assertMsg "not a signer" (signer `elem` signers) + now <- getTime + create SubmissionSignature with round = self; proposer; signer; observers = signers; instance; index + participantId; signedBy; signatureHex; format; algorithm; signedAt = now + choice SubmissionRound_Close : () + with result : Text + controller proposer + do pure () + +template SubmissionSignature + with round : ContractId SubmissionRound; proposer : Party; signer : Party; observers : [Party] + instance : Text; index : Int; participantId : Text; signedBy : Text; signatureHex : Text + format : Text; algorithm : Text; signedAt : Time + where + signatory signer + observer proposer, observers + choice SubmissionSignature_Archive : () controller signer do pure () +``` + +```daml +module Decman.Coordination.Acs where +template AcsManifest + with + exporter : Party + exporterParticipant : Text + observers : [Party] + decPartyId : Text + targetParticipant : Text + activationSerial : Int + sizeBytes : Int + sha256Hex : Text + packageIds : [Text] + exportedAt : Time + where + signatory exporter + observer observers + choice AcsManifest_Archive : () controller exporter do pure () +``` + +Daml tests (given/when/then, `dpm test`): heartbeat honours the minimum interval; a +non-invitee cannot accept or decline; accept is repeatable by different invitees; cancel +archives; sign requires membership in `signers`; manifest fields validate. + +## 4. Rust module `crates/decman/src/onledger/` + +| File | Responsibility | +|---|---| +| `mod.rs` | Module doc. `OnLedger` facade (config, db, auth, package resolver). `spawn_observer`. | +| `identity.rs` | `NodeIdentity { node_party, participant_id, token_manager }` loaded from `party_credentials` kind `node`. `require_node_identity` error when absent. Hosting verification of a peer node party via `ListPartyToParticipant`. | +| `daml/mod.rs`, `daml/templates.rs`, `daml/codec.rs`, `daml/client.rs` | Template ids via `PackageResolver` key `decman_coordination` (`#decman-coordination-v1`). Record encode/decode for every template. Create/exercise through `CommandService.submit_and_wait` as the node party. ACS reads through the existing `for_each_active_created` / `ledger_paging` helpers filtered by template, as the node party. | +| `registry.rs` | Publish/update/heartbeat `DecmanNode`. Read peers' entries. `PeerHealthSnapshot` in `AppState`. Version gate `preflight_unregistered_peers`. Vetting readiness `fetch_vetted_packages_for(participant)`. | +| `proposals.rs` | Create/accept/decline/cancel/finish `WorkflowProposal`. Read pending proposals, acceptances, declines, outcomes. Project into `pending_invitations` and `workflow_runs`. | +| `topology.rs` | `proposals_query` (`Snapshot(MaxValue)`, `proposals = true`). `list_pending_dnd`, `list_pending_p2p`. `propose_mapping` with explicit serial. `cosign_by_hash`. `wait_effective`. `wait_owner_root_delegations`. Mapping builders for bootstrap, add owner/host, remove owner/host, change threshold (byte-stable order: owners sorted, participants sorted by uid). | +| `observer.rs` | The polling loop. Each tick: refresh registry snapshot; project proposals to invitations; for accepted runs drive the member side (generate keys, co-sign, sign rounds, upload check, ACS wait); for proposer runs drive the coordinator side (await acceptances, build and submit, await effective, next step, finish). Heartbeat when due. Metrics. | +| `validation.rs` | `Expectations` built from the accepted `WorkflowProposal` + acceptances + head state. Checks per kind (section 5). | +| `submission.rs` | Contracts: prepare, open rounds, collect, execute, close. Member: verify, sign, publish. | +| `dars.rs` | Pins, upload verification, vetting observation, completion. | +| `acs.rs` | Export spool + manifest publication; import endpoint logic; empty fast path; flag clearing. | +| `engine/{onboarding,add_party,kick,change_threshold,contracts,dars}.rs` | Per-kind step machines (coordinator and member sides) as pure functions over on-ledger state plus local artefacts. Steps in section 6. | + +Removed: `crates/decman/src/noise/` (all), `server/peer_status.rs`, `server/health.rs`, +`server/handlers/keys.rs`, `workflow/{onboarding,add_party,kick,change_threshold,contracts,dars}/{coordinator,peer}.rs`, +`workflow::start_peer`, `WorkflowState` peer-connection gates, `WorkflowStep::to_command`, +all `*InvitePayload` DTOs, `MissingPeerEdge`/`OnboardingMeshErrorResponse`, `KeyStatusResponse`, +Cargo deps `hyper-noise`, `tokio-noise` (and its patch), `secp256k1`, `hyper`, `http` if unused, +the `Timeouts` and `NoiseRetryConfig` config structs, `NodeInfo.{listen_address,port,public_address}`, +CLI flags `--listen-address --noise-port --public-address --timeout-* --noise-retry-*`, +`data/noise.key` handling, `MIN_PEER_VERSION`, `WIRE_VERSION`, `integration-tests/smoke-noise-errors.sh`. + +Kept and reused: `workflow/topology.rs` helpers (`sign_transactions_with_topology_retry`, +`authorize_with_topology_retry`, `synchronizer_store_id`, `head_state_query`, `fetch_*`), +`workflow/signing_keys.rs` (with on-chain-first lookup), `workflow/party_replication/*` +(export session, import bracket, offsets, flag clearing), `canton_hash`, `signing/*`, +`workflow/external_party/*` (unchanged), `workflow/storage.rs`, `workflow/state.rs` reduced +to the run projection, `server/reward_automation.rs` loop shape as the observer template. + +## 5. Validation before a member co-signs + +Built from: the accepted `WorkflowProposal`, all `WorkflowAcceptance`s for it, the head +state, and local identity. + +Common: the proposal names this node (owner fingerprint or participant); this node has not +signed it; `serial == accepted + 1` (or `1`). + +Onboarding DND: `owners` == set of acceptances' `namespaceFingerprint` ∪ proposer's own +fingerprint (the proposer publishes its key in its own acceptance record, created by itself); +`|owners| == |participants|`; `threshold == proposal.threshold`; `namespace == computeNamespace(owners)`. +Onboarding P2P: `party == prefix::namespace`; hosts == `participants` all Confirmation, no +Onboarding marker; `party_signing_keys.keys` fingerprints == owners; both thresholds == +`proposal.threshold`. + +Add-party DND: owners == head owners ∪ {joiner fingerprint from its acceptance}; threshold +== `proposal.threshold`. Add-party P2P: hosts == head hosts ∪ {joiner, Confirmation, +Onboarding}; keys == head keys ∪ {joiner key}; thresholds == `proposal.threshold`. + +Kick DND: owners == head owners − {kicked owner}; threshold == `proposal.threshold`. +Kick P2P: hosts == head hosts − {kicked}; keys == head keys − {kicked key}; thresholds. + +Change threshold: mapping equal to head except both thresholds == `proposal.threshold`. + +Contracts round: recomputed hash == `preparedHashHex`; `act_as == [decPartyId]`; +`deadline > now`; the round belongs to the accepted proposal instance. + +DAR upload: sha256 of the uploaded file == a pending pin for this node. + +Any mismatch: do not sign; mark the peer run `Failed` with the reason; show it in the UI. + +## 6. Steps per kind (UI projection) + +Names are the `current_step` values. `step_total` is the list length. The frontend +`workflowSteps.ts` mirrors these lists. + +| Kind | Coordinator | Member | +|---|---|---| +| Onboarding | `WaitingForAcceptances`, `GenerateKeys`, `ProposeNamespace`, `AwaitNamespace`, `ProposeParty`, `AwaitParty`, `Complete` | `GenerateKeys`, `CoSignNamespace`, `CoSignParty`, `Complete` | +| AddParty | `WaitingForAcceptances`, `ExportState`, `ProposeChanges`, `AwaitChanges`, `AwaitReplication`, `Complete` | joiner: `GenerateKeys`, `CoSignChanges`, `SyncAcs`, `ClearOnboarding`, `Complete`; others: `CoSignChanges`, `PublishManifest`, `Complete` | +| Kick | `WaitingForAcceptances`, `ExportState`, `ProposeChanges`, `AwaitChanges`, `Complete` | `CoSignChanges`, `Complete` | +| ChangeThreshold | `WaitingForAcceptances`, `ExportState`, `ProposeChanges`, `AwaitChanges`, `Complete` | `CoSignChanges`, `Complete` | +| Contracts | `WaitingForAcceptances`, `AwaitDars`, `PrepareSubmissions`, `CollectSignatures`, `ExecuteSubmissions`, `Complete` | `UploadDars`, `SignSubmissions`, `Complete` | +| Dars | `WaitingForAcceptances`, `AwaitVetting`, `Complete` | `UploadDars`, `Complete` | + +Kick and change-threshold need `threshold` acceptances (quorum), not all. Contracts needs +`party_signing_keys.threshold` signatures. Onboarding and add-party need every invitee. + +## 7. HTTP surface + +Unchanged paths and shapes: all `/governance/*`, `/auth/*`, `/party-config*`, `/v0/tenant/*`, +token-standard routes, `/workflows*`, `/onboarding*`, `/kick*`, `/add-party*`, +`/change-threshold*`, `/contracts*`, `/dars/*`, `/invitations*`, `/decentralized-parties`, +`/packages/*`, `/network-config`, `/node-config`, `/node-health`, `/healthz`, `/metrics`. + +Changed DTOs: + +* `Peer { participant_id, name, party: Option }` (drop `address`, `port`, `public_key`). +* `NodeInfo { participant_id }` (drop listener fields). `NodeConfig` drops `timeouts`, `noise_retry`. +* `ConnectionStatus { CurrentNode, Active, Stale, Unknown }`. +* `ParticipantStatus { id, status, node_party?, last_seen_at?, heartbeat_age_secs?, workflow?, version?, build_version? }`. +* `PendingInvitation`: `coordinator_pubkey` → `coordinator_participant`; add `coordinator_party`, `proposal_cid`. +* `WorkflowRun`: `coordinator_pubkey` → `coordinator_participant`; add `coordinator_party`, `proposal_cid`; `connected_peers` keeps its name and means "invitees that accepted". +* `PeerPackageResult { participant_id, name, reachable, error_kind?, packages }` stays; `reachable` means "topology read succeeded"; `PeerErrorKind { TopologyReadFailed, NoVettedPackages, Other }`. +* `KnownMember` unchanged; populated from `WorkflowAcceptance.memberParty` records and `party_credentials`. + +New: + +* `GET /node-identity`, `PUT /node-identity`. +* `GET /registry` → `{ self: DecmanNodeView, peers: [DecmanNodeView], inbound: [DecmanNodeView] }`. +* `GET /acs-export/{party}/{target}?serial=` (admin, streamed), `POST /acs-import/{party}?serial=` (admin, streamed), `GET /acs-manifests/{party}`. +* `POST /dars/upload` accepts optional `pin_instance` to verify against pending pins. +* `GET /proposals/unsolicited` → topology proposals naming this node with no matching accepted `WorkflowProposal`. + +Removed: `GET /keys/status`. + +## 8. Database migration `000020_noise_sunset` + +Up, in this order: +1. `ALTER TABLE party_credentials ADD COLUMN kind TEXT NOT NULL DEFAULT 'decparty';` +2. `UPDATE workflow_runs SET status = 'failed', error = 'Interrupted by the Noise-sunset upgrade. Dismiss this card and start the operation again.', updated_at = strftime('%s','now') WHERE status = 'inprogress';` +3. `UPDATE workflow_runs SET coordinator_pubkey = (SELECT participant_id FROM peers WHERE peers.public_key = workflow_runs.coordinator_pubkey) WHERE coordinator_pubkey IS NOT NULL AND EXISTS (SELECT 1 FROM peers WHERE peers.public_key = workflow_runs.coordinator_pubkey);` +4. `ALTER TABLE workflow_runs RENAME COLUMN coordinator_pubkey TO coordinator_participant;` +5. `ALTER TABLE workflow_runs ADD COLUMN coordinator_party TEXT; ALTER TABLE workflow_runs ADD COLUMN proposal_cid TEXT; ALTER TABLE workflow_runs ADD COLUMN topology_hashes_json TEXT;` +6. `DELETE FROM pending_invitations; ALTER TABLE pending_invitations RENAME COLUMN coordinator_pubkey TO coordinator_participant; ALTER TABLE pending_invitations ADD COLUMN coordinator_party TEXT; ALTER TABLE pending_invitations ADD COLUMN proposal_cid TEXT;` +7. `ALTER TABLE peers DROP COLUMN address; ALTER TABLE peers DROP COLUMN port; ALTER TABLE peers DROP COLUMN public_key;` +8. `CREATE TABLE proposal_decisions (proposal_cid TEXT PRIMARY KEY NOT NULL, decision TEXT NOT NULL, decided_at INTEGER NOT NULL);` + +Down is documented as lossy (re-adds the three peer columns with defaults). + +## 9. Configuration + +Removed env: `DECPM_LISTEN_ADDRESS`, `DECPM_NOISE_PORT`, `DECPM_PUBLIC_ADDRESS`, +`DECPM_TIMEOUT_*`, `DECPM_NOISE_RETRY_*`, `DECPM_PEER_WAIT_POLL_DELAY_MS`, `DECPM_ACS_BLOCK_BYTES`. + +Added env: `DECPM_HEARTBEAT_INTERVAL_SECS` (3600), `DECPM_HEARTBEAT_MIN_INTERVAL_SECS` (60), +`DECPM_PEER_STALE_FACTOR` (3), `DECPM_OBSERVER_POLL_SECS` (3), +`DECPM_AUTO_UPLOAD_COORDINATION_DAR` (true), `DECPM_ACS_SPOOL_DIR` (`{dir}/data/acs`). + +`PackageConfig` gains `decman_coordination: Option` (default `#decman-coordination-v1`). + +## 10. Tests + +Unit (cargo): codec round trips for every template; mapping builders are byte-stable; +serial guard; validation per kind (positive and each negative); registry staleness; hash +co-sign idempotency (mocked client); migration 000020 on a seeded DB; DTO snapshots. + +Daml (`dpm test`): as in section 3. + +Integration harness (`integration-tests/*.sh`): drop Noise ports and key reads; allocate a +node party per node on the JSON Ledger API, grant rights, `PUT /node-identity`; wait for +each node's `DecmanNode` to appear on its peers; set heartbeat interval 5 s. Phases: keep +the ledger-only ones; rewrite the coordination ones to the new steps; delete +`peer_health_flip` (replace with a staleness flip), `peer_3_strikes_abort`, `retry_with_offline_peer`, +`invite_cap`, `check_peer_dars` (replace with a vetted-packages comparison). + +## 11. Documentation + +README, ARCHITECTURE, DEPLOYMENT_GUIDE, USER_GUIDE, CUSTOM_DAML_TEMPLATES, KMS_SIGNING, +CONTRIBUTING: remove every Noise reference; document the node identity, the registry, the +proposal model, the DAR pin flow, the ACS handoff, the cutover runbook, and the new env vars. +Kubernetes manifests lose port 9000 and the Noise LoadBalancer. `Dockerfile` exposes 8080 only. + +## 12. Evidence summary + +* Partial-authorization proposals propagate and merge by fingerprint; co-sign by hash; + no expiry, no cancel; `Snapshot(MaxValue)` for discovery. (Canton `TopologyManager.scala`, + `TopologyStateProcessor.scala`, `GrpcTopologyManagerReadService.scala`; Splice + `SvOnboardingPartyToParticipantProposalTrigger.scala`.) +* DND serial 1 needs every owner's signature and every owner's root `NamespaceDelegation` + in the synchronizer store first; a P2P under a decentralized namespace needs the DND + effective first. (`TopologyMappingChecks.scala`, `TransactionAuthorizationCache.scala`.) +* A root `NamespaceDelegation` is self-authorizing and carries the full public key; + `[Namespace, Protocol]` is a valid usage set, also on KMS. (`Signing.scala`, + `KmsPrivateCrypto.scala`.) +* Clearing an Onboarding flag needs only the joiner's participant namespace. + (`TopologyMapping.scala` requiredAuth; `PartyReplicationTopologyWorkflow.scala`.) +* Interactive submission: 24 h `preparationTimeRecordTimeTolerance` on the Global + Synchronizer; all signatures in one execute call; any node with `CanExecuteAs` may execute. +* Online party replication is alpha with `ProtocolVersion.dev` codecs; unusable on PV 35. +* Daml visibility is stakeholder-only; a registry needs explicit observers; traffic cost of a + 1 KB create with 4 observers is well under the free base rate. +* Canton requires every observer participant to have vetted the package before a create. From 7e3c75ac324dadac5a9b2babffc1d905b1ad9572 Mon Sep 17 00:00:00 2001 From: scolear Date: Mon, 14 Sep 2026 15:51:30 +0200 Subject: [PATCH 02/24] docs: revise the Noise-sunset design after adversarial review The review found nine blocking defects: a Confirmation-only node party cannot submit commands, the bootstrap exemption would open an unauthenticated write on upgraded nodes, members never pinned the topology operation, an accepted proposal had no serial binding or expiry, acceptance provenance was unspecified, the kicked key had no trusted source, kick quorum ignored the stored namespace threshold, the ACS manifest signer was unbound, and registry publication ignored package vetting. This revision fixes each one and the should-fix items that followed from them. --- docs/NOISE_SUNSET_DESIGN.md | 912 ++++++++++++++++++++++++------------ 1 file changed, 620 insertions(+), 292 deletions(-) diff --git a/docs/NOISE_SUNSET_DESIGN.md b/docs/NOISE_SUNSET_DESIGN.md index a4df71f9b..293083ff2 100644 --- a/docs/NOISE_SUNSET_DESIGN.md +++ b/docs/NOISE_SUNSET_DESIGN.md @@ -1,11 +1,11 @@ # Noise sunset: Canton-native coordination -**Status:** implementation spec, 2026-09-14. +**Status:** implementation spec v2, 2026-09-14 (after adversarial review). **Scope:** remove the Noise P2P transport from decman. Nodes coordinate only through Canton: the synchronizer topology store, Daml contracts, and the Ledger API. This document is the shared contract for the implementation. Every section states a -decision. The research that grounds each decision is summarized in section 12. +decision. Section 13 summarizes the research that grounds each decision. --- @@ -33,17 +33,24 @@ No decman node ever opens a connection to another decman node. ### D1. Node identity: a node party Each decman node has one **node party**. The node party is a normal Canton party hosted on -the node's participant with Confirmation permission. It has a Ledger API user with -`CanActAs` and `CanReadAs` on itself. It exists before any decentralized party exists. +the node's participant with **Submission permission and confirmation threshold 1**. A party +with Confirmation permission only cannot submit Daml commands, so it cannot serve as a node +party. The node party has a Ledger API user with `CanActAs` and `CanReadAs` on itself. It +exists before any decentralized party exists. Storage: a `party_credentials` row with `kind = 'node'` and `dec_party_id = `. `AuthRegistry::get(node_party)` returns its token manager unchanged. Migration `000020` -adds `kind TEXT NOT NULL DEFAULT 'decparty'`. Decparty-oriented views filter `kind = 'decparty'`. - -HTTP: `GET /node-identity` and `PUT /node-identity` (admin; the PUT is bootstrap-exempt while no -node identity exists, using the existing bootstrap mutex). Body = `PartyConfigRequest` shape -without `dec_party_id` (member party id becomes the node party id). The existing -`POST /party-config/discover-member-party` pre-fills it. +adds `kind TEXT NOT NULL DEFAULT 'decparty'`. Decparty-oriented views (`GET /auth/status`, +party lists) filter `kind = 'decparty'`. Rows with `kind = 'node'` are **excluded** from the +inbound JWT trusted-issuer set (`find_trusted`), so a node identity never widens who can log in. + +HTTP: `GET /node-identity` and `PUT /node-identity`. The PUT requires the admin role. It is +exempt from authentication only while `party_credentials` is **entirely empty**, under the +same predicate and mutex as today's `PUT /party-config`. An upgraded node that already holds +decparty rows sets its node identity with an admin JWT. Body = `PartyConfigRequest` without +`dec_party_id` (the member party id field carries the node party). The handler reads the head +`PartyToParticipant` of the node party and rejects it unless this participant hosts it with +Submission permission. `POST /party-config/discover-member-party` pre-fills the form. The node party signs: the registry entry, workflow proposals, acceptances, declines, submission rounds, signatures, ACS manifests, and heartbeats. @@ -52,195 +59,318 @@ submission rounds, signatures, ACS manifests, and heartbeats. Operators exchange one string per peer: `participant_id,node_party_id,name`. The UI button "Share my identity" copies it. "Add peer" pastes it. The `peers` table keeps -`(participant_id PK, name, party)` and drops `address`, `port`, `public_key`. +`(participant_id PK, name, party)` and drops `address`, `port`, `public_key`. `party` is +now mandatory for a usable peer. -Before a node writes any contract that names a peer node party as observer, it verifies -through the tokenless Admin API that the party is hosted on the claimed participant -(`ListPartyToParticipant(filter_party = node_party)` head state contains `participant_id` -with Confirmation or Submission permission). This is the only trust check; the security -boundary stays the operator's own co-signature. +Before a node names a peer node party as observer of any contract, it verifies through the +tokenless Admin API that the party is hosted on the claimed participant with **Submission** +permission (`ListPartyToParticipant(filter_party = node_party)`, head state, `mapping.party == +node_party`, participant present with `Submission`). Every registry read is keyed by the +contract **signatory** (`node == peers.party`); the `participantId` field is a claim that is +cross-checked with the same hosting read before use. This is the only trust check; the +security boundary stays the operator's own co-signature. ### D3. Registry: `DecmanNode` -Every node publishes one `DecmanNode` contract (signatory = node party, observers = the peer -node parties from its `peers` table). Fields: `participantId`, `displayName`, `version`, -`buildVersion`, `coordinationVersion`, `peers`, `lastActiveAt`, `heartbeatIntervalSecs`, -`minHeartbeatIntervalSecs`. Choices: `DecmanNode_Heartbeat` (re-create with `lastActiveAt = now`, -guarded by the minimum interval), `DecmanNode_Update` (peers, names, versions), `DecmanNode_Retire`. - -The registry entry is (re)published at startup and whenever the peers table changes. -Heartbeat cadence is `DECPM_HEARTBEAT_INTERVAL_SECS` (default 3600). A peer is `Stale` when -`now - lastActiveAt > 3 × heartbeatIntervalSecs`. Tests set the interval to 5 s. - -Peer health: `/participants-status` is served from an in-memory snapshot that the observer -loop refreshes. `ConnectionStatus` becomes `CurrentNode | Active | Stale | Unknown`. -`latency_ms` is removed. New fields: `last_seen_at`, `heartbeat_age_secs`, `node_party`. -`version` and `build_version` come from the registry entry. - -Version gate: a proposer refuses to start a workflow (HTTP 409, naming the participants) -unless every invitee has a registry entry with `coordinationVersion >= COORDINATION_VERSION`. -`MIN_PEER_VERSION` and `WIRE_VERSION` are deleted. `COORDINATION_VERSION = 1`. -The decman crate version becomes `2.0.0`. +Every node publishes one `DecmanNode` contract (signatory = node party, observers = peer node +parties). Fields: `participantId`, `displayName`, `version`, `buildVersion`, +`coordinationVersion`, `peers`, `lastActiveAt`, `heartbeatIntervalSecs`, +`minHeartbeatIntervalSecs`. Choices: `DecmanNode_Heartbeat` (re-create with +`lastActiveAt = now`, guarded by the minimum interval), `DecmanNode_Update`, `DecmanNode_Retire`. + +Observers are only those peers whose participant has **vetted the coordination package** +(`ListVettedPackages(filter_participant)`, validity window applied). Canton rejects a create +whose informee participant has not vetted the package, so an unvetted peer is left out and +retried on the next observer tick. Publication at startup is a background task and never +blocks `start_server`. `DecmanNode_Update` runs only when the on-ledger fields differ from the +desired state. `/participants-status` and `GET /registry` report "peer has not vetted the +coordination DAR" explicitly. + +Heartbeat cadence is `DECPM_HEARTBEAT_INTERVAL_SECS` (default 3600). The template floor +`minHeartbeatIntervalSecs` is written as `min(DECPM_HEARTBEAT_MIN_INTERVAL_SECS (default 60), +DECPM_HEARTBEAT_INTERVAL_SECS)`. A peer is `Stale` when `now - lastActiveAt > +DECPM_PEER_STALE_FACTOR (default 3) × heartbeatIntervalSecs`. The UI labels the value +"last heartbeat N ago", not liveness. Tests set the interval to 5 s and the floor to 1 s. + +Peer health: `/participants-status` is served from an in-memory snapshot the observer loop +refreshes. `ConnectionStatus` becomes `CurrentNode | Active | Stale | Unknown | Unvetted`. +`latency_ms` and `workflow` are removed. New fields: `last_seen_at`, `heartbeat_age_secs`, +`node_party`. `version` and `build_version` come from the registry entry. Without a node +identity the endpoint returns every peer as `Unknown` and the UI shows a "configure node +identity" banner. `DecmanNode` contracts whose signatory is not in the peers table appear +only in the `inbound` bucket of `GET /registry`. + +Version gate: a proposer refuses to start a workflow (HTTP 409 naming the participants) unless +every invitee (a) has vetted the coordination package, (b) has a visible registry entry, and +(c) reports `coordinationVersion >= COORDINATION_VERSION`. The 409 text distinguishes "no +entry visible (peer has not added you, or has not vetted)" from "entry too old". +`MIN_PEER_VERSION` and `WIRE_VERSION` are deleted. `COORDINATION_VERSION = 1`. The decman +crate version becomes `2.0.0`. ### D4. Key model: one dual-usage key per member per decentralized party -For every NEW decentralized party each member generates ONE vault key named -`{prefix}-key` with usages `[Namespace, Protocol]`. Its self-signed root `NamespaceDelegation` -puts the full `SigningPublicKey` into the synchronizer store. The proposer reads it with -`ListNamespaceDelegation(store = Synchronizer, filter_namespace = fp, filter_target_key_fingerprint = fp)` -and copies `target_key` into `PartyToParticipant.party_signing_keys`. The DND owner -fingerprint equals the party signing-key fingerprint, so kick attribution is on-chain. - -Legacy parties (two keys `{prefix}-namespace` + `{prefix}-daml-transactions`) keep working -unchanged: kick, add-party, change-threshold sign with `signed_by = []` and Canton -auto-selects the legacy namespace key. Own-Daml-key lookup is on-chain first -(`party_signing_keys ∩ ListMyKeys`), then `dec_party_identity`, then the legacy vault name. - -Key generation is idempotent by name (existing `get_or_create_signing_key`). +For every NEW decentralized party each member generates ONE vault key named `{prefix}-key` +with usages `[Namespace, Protocol]`. Its self-signed root `NamespaceDelegation` (published to +the Authorized store with `must_fully_authorize = true`, exactly as `generate_keys` does +today) carries the full `SigningPublicKey` into the synchronizer store. The proposer reads it +with `ListNamespaceDelegation(store = Synchronizer, operation = ADD_REPLACE, filter_namespace = +fp, filter_target_key_fingerprint = fp)`, requires `valid_from <= now`, asserts +`fingerprint(target_key) == fp`, and copies `target_key` verbatim into +`PartyToParticipant.party_signing_keys`. The NSD `target_key` is the single source of key +bytes; the `signingPublicKeyHex` on acceptances is informational. The DND owner fingerprint +equals the party signing-key fingerprint, so kick attribution is on-chain for these parties. + +Legacy parties (two keys `{prefix}-namespace` + `{prefix}-daml-transactions`) keep working: +kick, add-party, change-threshold sign with `signed_by = []` and Canton auto-selects the legacy +namespace key. Own-Daml-key lookup is on-chain first (`party_signing_keys ∩ ListMyKeys`), +then `dec_party_identity`, then the legacy vault name. + +Key generation is idempotent by name (existing `get_or_create_signing_key`). A KMS spike +(generate the dual key on a KMS participant, publish its root NSD, co-sign a P2P, run one +contracts round with an ECDSA/DER signature) is a rollout precondition (section 10). ### D5. Topology coordination: proposals in the synchronizer store Proposer: 1. Read the accepted mapping (`proposals = false`, `Snapshot(MaxValue)`) to get serial `S`. -2. `Authorize { proposal { ADD_REPLACE, serial = S + 1 (or 1), mapping }, must_fully_authorize = false, signed_by = [], store = Synchronizer(physical_id) }`. -3. Never pass `FORCE_FLAG_ALLOW_UNVALIDATED_SIGNING_KEYS`. Never use the Authorized store. -4. Keep the returned `transaction_hash` on the run row. - -Members (observer loop, every `DECPM_OBSERVER_POLL_SECS`, default 3): -1. `ListDecentralizedNamespaceDefinition` and `ListPartyToParticipant` with - `BaseQuery { store = Synchronizer, proposals = true, time_query = Snapshot(MaxValue) }`. -2. Keep proposals that name this node (owner fingerprint in `owners`, or participant in - `participants`) and that this node has not signed (`signed_by_fingerprints`). -3. Match each proposal to an accepted `WorkflowProposal` (D6). Unmatched proposals are - listed in the UI as "unsolicited"; they are never signed automatically. -4. Validate against the accepted proposal and head state (section 5). -5. Guard: `proposal.serial == accepted.serial + 1` (or `1` with no accepted mapping). -6. Co-sign: `Authorize { transaction_hash = hex(BaseResult.transaction_hash), must_fully_authorize = false, signed_by = [], store = Synchronizer }`. - Retry `TOPOLOGY_TRANSACTION_NOT_FOUND` on the next poll. Treat + The `WorkflowProposal` already records `dndBaseSerial`/`p2pBaseSerial`; if `S` differs, the + run fails ("topology moved"). +2. `Authorize { proposal { ADD_REPLACE, serial = S + 1 (or 1), mapping }, must_fully_authorize = + false, signed_by = [], store = Synchronizer(physical_id) }`. DND and P2P proposals never go + to the Authorized store and never carry `FORCE_FLAG_ALLOW_UNVALIDATED_SIGNING_KEYS`. +3. Record the transaction hash on the run row: compute it locally as + `multihash_sha256(HashPurpose 11 || response.transaction.transaction)` (the versioned + envelope bytes), the same computation the external-party path already performs. + +Members (observer loop, every `DECPM_OBSERVER_POLL_SECS`, default 3; mainnet guidance 10): +1. For every accepted, in-progress run: `ListDecentralizedNamespaceDefinition(filter_namespace = + party namespace)` and `ListPartyToParticipant(filter_party = party id)` with + `BaseQuery { store = Synchronizer, proposals = true, operation = ADD_REPLACE, time_query = + Snapshot(MaxValue) }`. The onboarding namespace is `computeNamespace(owners)` from the + accepted proposal; the party id is `prefix::namespace`. +2. Keep proposals with `BaseResult.operation == ADD_REPLACE`, whose `signed_by_fingerprints` + contains the proposer's owner fingerprint and not this node's fingerprint. +3. Validate against the accepted proposal and head state (section 5). +4. Guard: `proposal.serial == accepted.serial + 1` and `accepted.serial == base serial` from + the `WorkflowProposal` (or `1` with no accepted mapping). +5. In the same tick, immediately before signing: re-read the `WorkflowProposal` as active, the + local run row as `inprogress`, and `proposal_decisions` for the pinned hash (or pin it now). + Never sign from a cached match. +6. Co-sign: `Authorize { transaction_hash = hex(BaseResult.transaction_hash), + must_fully_authorize = false, signed_by = [], store = Synchronizer }`. Retry + `TOPOLOGY_TRANSACTION_NOT_FOUND` on the next poll. Treat `TOPOLOGY_NO_APPROPRIATE_SIGNING_KEY_IN_STORE` with the existing retry budget. 7. Completion: poll `proposals = false` until `serial == S + 1` and `valid_from <= now`. +8. Spend rule: once a matching transaction is effective at `base + 1`, or the run row is + terminal, the proposal is spent for that mapping and never matched again. + +The unfiltered scan (`proposals = true`, no filter) runs only for `GET /proposals/unsolicited` +and every 60 s for the UI; it never signs. -Order for onboarding: DND first. Wait until the DND is effective in the synchronizer store -on this node. Then P2P. Before proposing a DND, wait until every owner's root -`NamespaceDelegation` is effective in the synchronizer store. +Order: DND first, wait until effective in the synchronizer store on this node, then P2P. This +holds for onboarding, add-party, kick, and change-threshold. Before proposing a DND, wait until +every owner's root `NamespaceDelegation` is effective in the synchronizer store. Cancel: a topology proposal cannot be withdrawn. Cancel archives the `WorkflowProposal`; -members stop co-signing because the match disappears. A stale proposal without the members' -signatures can never become effective. +members stop co-signing because the match disappears. A cancelled onboarding proposal can never +become effective without the invitees. A cancelled kick or change-threshold proposal that +already reached the owner threshold **still becomes effective**; the UI says so. -The clearing of a joiner's Onboarding flag needs only the joiner's participant signature. -The joiner calls `ClearPartyOnboardingFlag` and polls until `onboarded == true`. No -co-sign round exists for it. +The clearing of a joiner's Onboarding flag needs only the joiner's participant signature. The +joiner calls `ClearPartyOnboardingFlag` and polls until `onboarded == true`. No co-sign round +exists for it. ### D6. Workflow intent: `WorkflowProposal` Every run starts with a `WorkflowProposal` (signatory = proposer node party, observers = -invitee node parties). It carries the human-readable intent the invitation card shows today -and the reference set the member validates against. Invitees `Accept` (non-consuming, creates -`WorkflowAcceptance`) or `Decline` (creates `WorkflowDecline`). The proposer `Cancel`s or -`Finish`es it. Every `WorkflowAcceptance` carries the acceptor's `participantId` and, for -onboarding and add-party, its fresh key material: `namespaceFingerprint` and -`signingPublicKeyHex`. Acceptances are visible to the proposer and to all invitees, so every -member can verify the full owner set before co-signing. - -Consent model: the operator accepts once. After that the node co-signs every topology -proposal that matches the accepted `WorkflowProposal` and passes validation. This is the same -model as today, with a stronger reference set. +invitee node parties). It carries the human-readable intent and the reference set members +validate against, plus the proposer's own key material (`proposerNamespaceFingerprint`, +`proposerSigningPublicKeyHex`, `proposerDamlKeyFingerprint`), the head serials at creation +(`dndBaseSerial`, `p2pBaseSerial`, `previousThreshold`), and `expiresAt` (default 7 days). +The coordinator therefore generates its key **before** creating the proposal. + +Invitees `Accept` (non-consuming, creates `WorkflowAcceptance`) or `Decline` (creates +`WorkflowDecline`). The proposer `Cancel`s or `Finish`es it. Every `WorkflowAcceptance` carries +the acceptor's `participantId`, its `namespaceFingerprint` and `signingPublicKeyHex` (onboarding +and add-party joiner), and its `damlKeyFingerprint` for the party (all kinds; feeds +`dec_party_participant.signing_key`). + +An acceptance is **counted** only if: `acceptance.proposal == proposalCid`, +`acceptance.proposer == proposal.proposer`, `acceptor ∈ proposal.invitees`, +`acceptance.participantId ∈ proposal.participants`, and the acceptor's node party is hosted +on `participantId` (D2 check). Exactly one acceptance per acceptor is allowed; conflicting key +material fails the run closed. A `WorkflowAcceptance` is created only through the `Accept` +choice; a direct create by a non-invitee fails the predicates above. + +Kick: `invitees` = remaining members only. The kicked node sees the topology proposal as +unsolicited. + +Consent model: the operator accepts once. After that the node co-signs every topology proposal +that matches the accepted `WorkflowProposal`, its counted acceptances, its base serials, and +section 5. The proposal is spent per mapping (D5 step 8) and expires at `expiresAt`. + +Accept flow: `POST /invitations/accept` writes `proposal_decisions(proposal_cid, 'accepted')` +and the peer run row at its first step. The next observer tick generates the key (when the +kind needs one), waits for the root NSD in the synchronizer store, exercises +`WorkflowProposal_Accept` with the key material, and advances. `POST /invitations/decline` +writes `'declined'` and exercises `WorkflowProposal_Decline` at once. ### D7. Contracts workflow: `SubmissionRound` -The proposer runs `PrepareSubmission` once per contract definition with -`max_record_time = preparation_time + 20h` and creates a `SubmissionRound` per prepared -transaction (bytes as lowercase hex, hash, hashing scheme version, deadline). Members that -accepted the `WorkflowProposal` re-hash the bytes with `canton_hash`, check `act_as`, sign -with their per-decparty key (vault export or KMS), and exercise `SubmissionRound_Sign`. When -the proposer sees `>= party_signing_keys.threshold` distinct fingerprints it executes one -`ExecuteSubmissionAndWait` with all signatures and closes the round. Deadline is 24 h minus a -margin. Expired rounds are re-prepared and re-signed. +The proposer runs `PrepareSubmission` once per contract definition with `max_record_time = now ++ 20h`, reads the live `preparationTimeRecordTimeTolerance` from the synchronizer parameters, +and creates a `SubmissionRound` per prepared transaction (bytes as lowercase hex, hash, +hashing scheme version, `preparationTime`, `maxRecordTime`, `deadline = min(maxRecordTime, +preparationTime + tolerance) − 30 min`). `signers` = invitee node parties. The proposer signs +locally and adds its own signature at execute time. + +A member that accepted the proposal: requires `round.decPartyId == acceptedProposal.decPartyId`, +`act_as == [that party]`, `maxRecordTime` present, `deadline > now`, its own key fingerprint +∈ `party_signing_keys` of the head P2P; recomputes the hash with `canton_hash`; decodes the +transaction and requires every root node to be a `Create` whose package resolves to a name in +`acceptedProposal.packageNames`; then signs with its per-decparty key (vault export or KMS) +and exercises `SubmissionRound_Sign`. + +The proposer, before counting: `signedBy ∈ fingerprints(party_signing_keys)`, the signature +verifies locally against that key (Ed25519 CONCAT, ECDSA DER), deduped by fingerprint. When +the count reaches `party_signing_keys.threshold` it executes one `ExecuteSubmissionAndWait` +with exactly the verified set and closes the round. Expired rounds are re-prepared and +re-signed. `threshold = None` on any `WorkflowProposal` fails closed. ### D8. DARs: hash-pinned local upload `POST /dars/distribute` creates a `WorkflowProposal(kind = Dars)` with `DarPin { filename, -sha256Hex, mainPackageId, sizeBytes }` per file. The proposer uploads locally. Each invitee's -UI shows the pins; the operator uploads the same files through `POST /dars/upload`, which -refuses a file whose sha256 does not match a pending pin when a `pin` id is supplied. The -proposer observes every participant's `VettedPackages` in the synchronizer store -(`ListVettedPackages(filter_participant)`) and completes the run when all pins are vetted -everywhere. `/packages/compare-peers` reads vetted packages per participant from the -topology store. No DAR bytes travel over any decman channel. +sha256Hex, mainPackageId, sizeBytes }` per file and uploads locally. Each invitee's UI shows the +pins; the operator uploads the same files through `POST /dars/upload` with `pin_instance = +WorkflowProposal.runId`. The handler refuses a file whose sha256 does not match a pending pin +and always passes `expected_main_package_id = pin.mainPackageId`. The proposer observes every +participant's `VettedPackages` (`ListVettedPackages(filter_participant)`, validity window) and +completes the run when every pin is vetted everywhere. `/packages/compare-peers` reads vetted +packages per participant from the topology store. No DAR bytes travel over any decman channel. The coordination DAR (`releases/v1/decman-coordination-v1-0.1.0.dar`) is embedded with -`include_bytes!` and uploaded and vetted at startup (`DECPM_AUTO_UPLOAD_COORDINATION_DAR`, -default true). `POST /dars/upload` remains the manual fallback. +`include_bytes!` and uploaded and vetted by a background task at startup with retry +(`DECPM_AUTO_UPLOAD_COORDINATION_DAR`, default true; `expected_main_package_id` pinned; +`synchronizer_id = utils::get_synchronizer_id(config)`); state appears in `/node-health`. This +is a policy change from operator-accepted vetting and is documented as such. +`POST /dars/upload` remains the manual fallback. ### D9. ACS synchronization: offline replication, operator-moved file Canton online party replication is alpha and unusable on protocol version 35. Offline -replication stays. When a current host observes the P2P that marks the joiner `Onboarding` -become effective, it exports the snapshot to a spool file and publishes an `AcsManifest` -(party, target participant, activation serial, exporter participant, size, sha256, -package ids). If `sizeBytes == 0` the joiner skips the import and clears its flag. Otherwise: +replication stays. Every current host captures `ADD_PARTY_EXPORT_OFFSET` (`capture_offset_once`, +keyed by party, joiner, base serial) as the first action of `CoSignChanges`, before its +`Authorize`; the joiner captures its pre-activation offset the same way. +`persisted_or_derived_offset` stays as the fallback. + +When a current host observes the P2P that marks the joiner `Onboarding` become effective, it +exports the snapshot to `DECPM_ACS_SPOOL_DIR` and publishes an `AcsManifest` (party, target +participant, activation serial, exporter participant, size, sha256, package ids). The joiner +accepts a manifest only if: (1) the signatory `exporter` is hosted on `exporterParticipant` with +Submission (D2 check); (2) `exporterParticipant` is in the decparty's head hosts with +`onboarding == None`; (3) `exporter` equals the node party recorded for that participant in the +peers table; (4) `activationSerial` equals the earliest serial whose mapping marks the joiner +Onboarding, and the head P2P still marks the joiner Onboarding. These checks also gate the +`sizeBytes == 0` fast path, which skips the import and clears the flag. * `GET /acs-export/{party}/{target}?serial=N` (admin JWT, streamed gzip) on any current host. -* `POST /acs-import/{party}?serial=N` (admin JWT, streamed body) on the joiner. The joiner - verifies: head state marks itself Onboarding at that serial; a manifest from a - non-onboarding host exists; sha256 and size match that manifest; every manifest package id - is vetted locally. Then it runs the existing disconnect / `ImportPartyAcs` / reconnect - bracket and clears the flag. +* `POST /acs-import/{party}?serial=N&exporter=` (admin JWT, streamed body) on the + joiner: match that exporter's manifest; verify sha256 and size; verify every manifest package + id is vetted locally; then run the existing disconnect / `ImportPartyAcs` / reconnect bracket + and clear the flag. -The add-party run on the joiner waits in step `SyncAcs` until the import completes or the -empty fast path fires. +The joiner's run waits in `SyncAcs` until the import completes or the fast path fires. Spool +files are deleted when the joiner is observed `onboarded == true` or the run is dismissed. ### D10. Cancel, decline, retry * Cancel (proposer): exercise `WorkflowProposal_Cancel`, mark the run `Cancelled`. Members see the proposal vanish and mark their peer rows `Cancelled`. -* Decline (invitee): exercise `WorkflowProposal_Decline`. The proposer marks the run `Failed` - ("Peer X declined the invitation"). Sibling invitees mark their rows `Cancelled`. +* Decline (invitee): exercise `WorkflowProposal_Decline`. For all-invitee kinds (onboarding, + add-party, contracts with all signers required, dars) the proposer marks the run `Failed` + ("Peer X declined the invitation") and exercises `WorkflowProposal_Finish { succeeded = + false }`. For quorum kinds (kick, change-threshold) a decline fails the run only when the + remaining invitees cannot reach the quorum (section 6). Sibling invitees of a failed run mark + their rows `Cancelled`. * Retry: idempotent "ensure". The proposer re-reads the accepted serial and re-proposes only - if its proposal is gone. Members re-issue `Authorize { transaction_hash }`. No broadcast. + if its proposal is gone and the base serial still holds. Members re-issue + `Authorize { transaction_hash }`. No broadcast. * Dismiss: unchanged. +* Housekeeping: an archive sweep in the observer tick archives this node's acceptances, + declines, and signatures of finished proposals. -### D11. Projection tables +### D11. Projection tables and concurrency `workflow_runs` stays as the UI projection with the same status vocabulary. New columns: `proposal_cid`, `coordinator_participant` (renamed from `coordinator_pubkey`), -`coordinator_party`, `topology_hashes_json`. `pending_invitations` stays as the local cache -of unaccepted `WorkflowProposal`s that name this node: `coordinator_pubkey` becomes -`coordinator_participant`, plus `proposal_cid`, `coordinator_party`. `workflow_artifacts` -stays for local-only artefacts (keys, offsets, spool file paths). +`coordinator_party`, `topology_hashes_json`, `member_variant` (`Joiner`/`Member`/NULL). +The start handler persists the run row with `proposal_cid` only after `submit_and_wait` +returns; the observer ignores rows without one. Peer `instance_name = +peer-{kind}-{coordinator_participant_short}-{coordinator_runId}`. + +`pending_invitations` stays as the local cache of unaccepted `WorkflowProposal`s that name +this node: `id = proposal_cid`, `coordinator_pubkey` → `coordinator_participant`, plus +`proposal_cid`, `coordinator_party`. `proposal_decisions(proposal_cid, decision, decided_at, +pinned_hashes_json)` is the idempotency guard against re-projecting dismissed or declined +proposals and the store for pinned transaction hashes. `workflow_artifacts` stays for +local-only artefacts (keys, offsets, spool paths). + +Concurrency: there is no long-lived task per run. The observer holds a per-run +`tokio::Mutex` (`try_lock`; a busy run is skipped this tick) so at most one tick drives a run. +Cancel, dismiss, retry are row operations that the driver re-reads before every ledger or +topology write. `/{kind}/status` and the start-handler 409 gate read `workflow_runs` only. +`WorkflowRegistry`, `WorkflowGuard`, and `HttpWorkflowState.abort_handle` are removed. ### D12. Rollout Old builds refuse to start a workflow when any invitee's Noise listener is gone. New builds -refuse until every invitee has a registry entry. So a mixed window is safe. Migration 000020 -fail-marks every `inprogress` run with an explanatory error. Runbook: drain, upgrade each -node inside the window, configure the node identity, verify registry entries, then start -workflows. +refuse until every invitee has vetted the package and published a registry entry. So a mixed +window is safe. Migration 000020 fail-marks every `inprogress` run. + +Runbook: (1) while every node still runs 1.8.x, call `GET /decentralized-parties?refresh=true` +on every node and treat a NULL `dec_party_participant.signing_key` as a blocker; (2) drain and +verify empty `/workflows` and `/invitations` everywhere; (3) each operator, inside the window: +stop, retag to 2.0.0, start; (4) `PUT /node-identity` with an admin JWT; (5) exchange +`participant_id,node_party_id,name` with every peer and re-add every peer; (6) verify +`GET /registry` lists each peer under `peers` and `inbound`, and `/packages/vetted` lists +`decman-coordination-v1`; (7) start workflows. A 409 names the members still to upgrade. + +Rollback is manual and lossy: stop the node, apply `000020_noise_sunset.down.sql` with +`sqlite3`, delete the version-20 row from `_sqlx_migrations`, re-enter Noise peers by hand, +restart 1.8.x. The new build never reads or modifies `data/noise.key`. ## 3. Daml package `decman-coordination-v1` Location: `daml/decman-coordination/` (`name: decman-coordination-v1`, `version: 0.1.0`, `sdk-version: 3.4.11`, `--target=2.2`, dependencies: `daml-prim`, `daml-stdlib` only). -Test package: `daml/decman-coordination-test/` with `daml-script` and `../dars/testlib-0.1.0.dar`. -Both go into `daml/multi-package.yaml`. The built DAR is committed to -`releases/v1/decman-coordination-v1-0.1.0.dar`. CI gets a `dpm test` step for the test package. +Test package: `daml/decman-coordination-test/` with `daml-script` and +`../dars/testlib-0.1.0.dar`. Both join `daml/multi-package.yaml`. The built DAR is committed +to `releases/v1/decman-coordination-v1-0.1.0.dar`. CI gets a `dpm test` step. Text encoding: fingerprints and hashes are Canton hex strings (`1220...`). Public keys are the lowercase hex of the serialized protobuf `SigningPublicKey`. Signatures are lowercase hex of -the raw signature bytes. Every hex field is validated with `DA.Crypto.Text.isHex` -(build option `-Wno-crypto-text-is-alpha`). +the raw signature bytes. Hex validation happens in Rust; templates only bound lengths. ```daml module Decman.Coordination.Types where + data WorkflowKind = Onboarding | AddParty | Kick | ChangeThreshold | Contracts | Dars deriving (Eq, Show) -data DarPin = DarPin with filename : Text; sha256Hex : Text; mainPackageId : Text; sizeBytes : Int + +data DarPin = DarPin + with + filename : Text + sha256Hex : Text + mainPackageId : Text + sizeBytes : Int deriving (Eq, Show) ``` ```daml module Decman.Coordination.Node where + +import DA.Time (addRelTime, seconds) + template DecmanNode with node : Party @@ -256,21 +386,41 @@ template DecmanNode where signatory node observer peers - ensure heartbeatIntervalSecs > 0 && minHeartbeatIntervalSecs >= 0 && coordinationVersion >= 1 + ensure heartbeatIntervalSecs > 0 + && minHeartbeatIntervalSecs >= 1 + && minHeartbeatIntervalSecs <= heartbeatIntervalSecs + && coordinationVersion >= 1 + choice DecmanNode_Heartbeat : ContractId DecmanNode controller node - do now <- getTime - assertMsg "heartbeat too soon" (now >= addRelTime lastActiveAt (seconds minHeartbeatIntervalSecs)) - create this with lastActiveAt = now + do + now <- getTime + assertMsg "heartbeat too soon" + (now >= addRelTime lastActiveAt (seconds minHeartbeatIntervalSecs)) + create this with lastActiveAt = now + choice DecmanNode_Update : ContractId DecmanNode - with newPeers : [Party]; newDisplayName : Text; newVersion : Text; newBuildVersion : Text - newCoordinationVersion : Int; newHeartbeatIntervalSecs : Int; newMinHeartbeatIntervalSecs : Int + with + newPeers : [Party] + newDisplayName : Text + newVersion : Text + newBuildVersion : Text + newCoordinationVersion : Int + newHeartbeatIntervalSecs : Int + newMinHeartbeatIntervalSecs : Int controller node - do now <- getTime - create this with peers = newPeers; displayName = newDisplayName; version = newVersion - buildVersion = newBuildVersion; coordinationVersion = newCoordinationVersion - heartbeatIntervalSecs = newHeartbeatIntervalSecs - minHeartbeatIntervalSecs = newMinHeartbeatIntervalSecs; lastActiveAt = now + do + now <- getTime + create this with + peers = newPeers + displayName = newDisplayName + version = newVersion + buildVersion = newBuildVersion + coordinationVersion = newCoordinationVersion + heartbeatIntervalSecs = newHeartbeatIntervalSecs + minHeartbeatIntervalSecs = newMinHeartbeatIntervalSecs + lastActiveAt = now + choice DecmanNode_Retire : () controller node do pure () @@ -278,11 +428,17 @@ template DecmanNode ```daml module Decman.Coordination.Workflow where + +import Decman.Coordination.Types + template WorkflowProposal with proposer : Party proposerParticipant : Text - instance : Text + proposerNamespaceFingerprint : Optional Text + proposerSigningPublicKeyHex : Optional Text + proposerDamlKeyFingerprint : Optional Text + runId : Text kind : WorkflowKind invitees : [Party] participants : [Text] @@ -290,70 +446,146 @@ template WorkflowProposal prefix : Optional Text threshold : Optional Int previousThreshold : Optional Int + dndBaseSerial : Optional Int + p2pBaseSerial : Optional Int newParticipant : Optional Text kickedParticipant : Optional Text darPins : [DarPin] packageNames : [Text] description : Text createdAt : Time + expiresAt : Time where signatory proposer observer invitees + ensure expiresAt > createdAt + nonconsuming choice WorkflowProposal_Accept : ContractId WorkflowAcceptance - with acceptor : Party; participantId : Text; namespaceFingerprint : Optional Text - signingPublicKeyHex : Optional Text; memberParty : Optional Party + with + acceptor : Party + participantId : Text + namespaceFingerprint : Optional Text + signingPublicKeyHex : Optional Text + damlKeyFingerprint : Optional Text + memberParty : Optional Party controller acceptor - do assertMsg "not invited" (acceptor `elem` invitees) - now <- getTime - create WorkflowAcceptance with proposal = self; proposer; acceptor; observers = invitees - instance; participantId; namespaceFingerprint; signingPublicKeyHex; memberParty; acceptedAt = now + do + assertMsg "not invited" (acceptor `elem` invitees) + now <- getTime + assertMsg "proposal expired" (now < expiresAt) + create WorkflowAcceptance with + proposal = self + proposer + acceptor + observers = invitees + runId + participantId + namespaceFingerprint + signingPublicKeyHex + damlKeyFingerprint + memberParty + acceptedAt = now + nonconsuming choice WorkflowProposal_Decline : ContractId WorkflowDecline - with decliner : Party; reason : Text + with + decliner : Party + reason : Text controller decliner - do assertMsg "not invited" (decliner `elem` invitees) - now <- getTime - create WorkflowDecline with proposal = self; proposer; decliner; observers = invitees; instance; reason; declinedAt = now + do + assertMsg "not invited" (decliner `elem` invitees) + now <- getTime + create WorkflowDecline with + proposal = self + proposer + decliner + observers = invitees + runId + reason + declinedAt = now + choice WorkflowProposal_Cancel : () controller proposer do pure () + choice WorkflowProposal_Finish : ContractId WorkflowOutcome - with succeeded : Bool; error : Optional Text + with + succeeded : Bool + error : Optional Text controller proposer - do now <- getTime - create WorkflowOutcome with proposer; instance; kind; observers = invitees; succeeded; error; finishedAt = now + do + now <- getTime + create WorkflowOutcome with + proposer + runId + kind + observers = invitees + succeeded + error + finishedAt = now template WorkflowAcceptance - with proposal : ContractId WorkflowProposal; proposer : Party; acceptor : Party; observers : [Party] - instance : Text; participantId : Text; namespaceFingerprint : Optional Text - signingPublicKeyHex : Optional Text; memberParty : Optional Party; acceptedAt : Time + with + proposal : ContractId WorkflowProposal + proposer : Party + acceptor : Party + observers : [Party] + runId : Text + participantId : Text + namespaceFingerprint : Optional Text + signingPublicKeyHex : Optional Text + damlKeyFingerprint : Optional Text + memberParty : Optional Party + acceptedAt : Time where signatory acceptor observer proposer, observers - choice WorkflowAcceptance_Archive : () controller acceptor do pure () + + choice WorkflowAcceptance_Archive : () + controller acceptor + do pure () template WorkflowDecline - with proposal : ContractId WorkflowProposal; proposer : Party; decliner : Party; observers : [Party] - instance : Text; reason : Text; declinedAt : Time + with + proposal : ContractId WorkflowProposal + proposer : Party + decliner : Party + observers : [Party] + runId : Text + reason : Text + declinedAt : Time where signatory decliner observer proposer, observers - choice WorkflowDecline_Archive : () controller decliner do pure () + + choice WorkflowDecline_Archive : () + controller decliner + do pure () template WorkflowOutcome - with proposer : Party; instance : Text; kind : WorkflowKind; observers : [Party] - succeeded : Bool; error : Optional Text; finishedAt : Time + with + proposer : Party + runId : Text + kind : WorkflowKind + observers : [Party] + succeeded : Bool + error : Optional Text + finishedAt : Time where signatory proposer observer observers - choice WorkflowOutcome_Archive : () controller proposer do pure () + + choice WorkflowOutcome_Archive : () + controller proposer + do pure () ``` ```daml module Decman.Coordination.Submission where + template SubmissionRound with proposer : Party - instance : Text + runId : Text index : Int signers : [Party] decPartyId : Text @@ -363,34 +595,72 @@ template SubmissionRound preparedHashHex : Text hashingSchemeVersion : Int preparationTime : Time + maxRecordTime : Time deadline : Time where signatory proposer observer signers + ensure deadline <= maxRecordTime + nonconsuming choice SubmissionRound_Sign : ContractId SubmissionSignature - with signer : Party; participantId : Text; signedBy : Text; signatureHex : Text; format : Text; algorithm : Text + with + signer : Party + participantId : Text + signedBy : Text + signatureHex : Text + format : Text + algorithm : Text controller signer - do assertMsg "not a signer" (signer `elem` signers) - now <- getTime - create SubmissionSignature with round = self; proposer; signer; observers = signers; instance; index - participantId; signedBy; signatureHex; format; algorithm; signedAt = now + do + assertMsg "not a signer" (signer `elem` signers) + now <- getTime + assertMsg "round expired" (now < deadline) + create SubmissionSignature with + round = self + proposer + signer + observers = signers + runId + index + participantId + signedBy + signatureHex + format + algorithm + signedAt = now + choice SubmissionRound_Close : () - with result : Text + with + result : Text controller proposer do pure () template SubmissionSignature - with round : ContractId SubmissionRound; proposer : Party; signer : Party; observers : [Party] - instance : Text; index : Int; participantId : Text; signedBy : Text; signatureHex : Text - format : Text; algorithm : Text; signedAt : Time + with + round : ContractId SubmissionRound + proposer : Party + signer : Party + observers : [Party] + runId : Text + index : Int + participantId : Text + signedBy : Text + signatureHex : Text + format : Text + algorithm : Text + signedAt : Time where signatory signer observer proposer, observers - choice SubmissionSignature_Archive : () controller signer do pure () + + choice SubmissionSignature_Archive : () + controller signer + do pure () ``` ```daml module Decman.Coordination.Acs where + template AcsManifest with exporter : Party @@ -406,186 +676,244 @@ template AcsManifest where signatory exporter observer observers - choice AcsManifest_Archive : () controller exporter do pure () + ensure sizeBytes >= 0 && activationSerial >= 1 + + choice AcsManifest_Archive : () + controller exporter + do pure () ``` Daml tests (given/when/then, `dpm test`): heartbeat honours the minimum interval; a -non-invitee cannot accept or decline; accept is repeatable by different invitees; cancel -archives; sign requires membership in `signers`; manifest fields validate. +non-invitee cannot accept or decline; accept is repeatable by different invitees; accept fails +after `expiresAt`; cancel archives; sign requires membership in `signers` and fails after +`deadline`; `DecmanNode` rejects a floor above the interval; manifest fields validate. ## 4. Rust module `crates/decman/src/onledger/` | File | Responsibility | |---|---| | `mod.rs` | Module doc. `OnLedger` facade (config, db, auth, package resolver). `spawn_observer`. | -| `identity.rs` | `NodeIdentity { node_party, participant_id, token_manager }` loaded from `party_credentials` kind `node`. `require_node_identity` error when absent. Hosting verification of a peer node party via `ListPartyToParticipant`. | -| `daml/mod.rs`, `daml/templates.rs`, `daml/codec.rs`, `daml/client.rs` | Template ids via `PackageResolver` key `decman_coordination` (`#decman-coordination-v1`). Record encode/decode for every template. Create/exercise through `CommandService.submit_and_wait` as the node party. ACS reads through the existing `for_each_active_created` / `ledger_paging` helpers filtered by template, as the node party. | -| `registry.rs` | Publish/update/heartbeat `DecmanNode`. Read peers' entries. `PeerHealthSnapshot` in `AppState`. Version gate `preflight_unregistered_peers`. Vetting readiness `fetch_vetted_packages_for(participant)`. | -| `proposals.rs` | Create/accept/decline/cancel/finish `WorkflowProposal`. Read pending proposals, acceptances, declines, outcomes. Project into `pending_invitations` and `workflow_runs`. | -| `topology.rs` | `proposals_query` (`Snapshot(MaxValue)`, `proposals = true`). `list_pending_dnd`, `list_pending_p2p`. `propose_mapping` with explicit serial. `cosign_by_hash`. `wait_effective`. `wait_owner_root_delegations`. Mapping builders for bootstrap, add owner/host, remove owner/host, change threshold (byte-stable order: owners sorted, participants sorted by uid). | -| `observer.rs` | The polling loop. Each tick: refresh registry snapshot; project proposals to invitations; for accepted runs drive the member side (generate keys, co-sign, sign rounds, upload check, ACS wait); for proposer runs drive the coordinator side (await acceptances, build and submit, await effective, next step, finish). Heartbeat when due. Metrics. | -| `validation.rs` | `Expectations` built from the accepted `WorkflowProposal` + acceptances + head state. Checks per kind (section 5). | -| `submission.rs` | Contracts: prepare, open rounds, collect, execute, close. Member: verify, sign, publish. | -| `dars.rs` | Pins, upload verification, vetting observation, completion. | -| `acs.rs` | Export spool + manifest publication; import endpoint logic; empty fast path; flag clearing. | -| `engine/{onboarding,add_party,kick,change_threshold,contracts,dars}.rs` | Per-kind step machines (coordinator and member sides) as pure functions over on-ledger state plus local artefacts. Steps in section 6. | +| `identity.rs` | `NodeIdentity { node_party, participant_id, token_manager }` from `party_credentials` kind `node`. `require_node_identity`. `verify_hosting(party, participant) -> Submission/Confirmation/None` via `ListPartyToParticipant`. | +| `daml/mod.rs`, `daml/templates.rs`, `daml/codec.rs`, `daml/client.rs` | Template ids via `PackageResolver` key `decman_coordination` (`#decman-coordination-v1`). Record encode/decode for every template (unit-tested round trips). Create/exercise through `CommandService.submit_and_wait` as the node party. ACS reads through `for_each_active_created` / `ledger_paging` filtered by template, as the node party. | +| `registry.rs` | Publish/update/heartbeat `DecmanNode` (vetted observers only). Read peers' entries keyed by signatory. `PeerHealthSnapshot` in `AppState`. `preflight_unready_peers` (vetting + entry + version). `fetch_vetted_packages_for(participant)`. | +| `proposals.rs` | Create/accept/decline/cancel/finish `WorkflowProposal`. Read proposals, counted acceptances (D6 predicates), declines, outcomes. Project into `pending_invitations` and `workflow_runs`. Archive sweep. | +| `topology.rs` | `proposals_query` (`Snapshot(MaxValue)`, `proposals = true`, `ADD_REPLACE`). `list_pending_dnd(namespace)`, `list_pending_p2p(party)`. `propose_mapping(serial)`. `transaction_hash_of`. `cosign_by_hash`. `wait_effective`. `wait_owner_root_delegations`. `read_root_delegation_key(fp)`. Mapping builders (owners sorted, participants sorted by uid, hosts as `(uid, permission, onboarding)` tuples). | +| `observer.rs` | The polling loop with the per-run `try_lock`. Each tick: refresh registry snapshot; project proposals; drive member runs and proposer runs; heartbeat when due; unsolicited scan every 60 s; archive sweep; metrics. | +| `validation.rs` | `Expectations` from the accepted `WorkflowProposal`, counted acceptances, head state, local identity. Checks per kind (section 5). | +| `submission.rs` | Contracts: prepare, open rounds, verify and count signatures, execute, close. Member: verify, sign, publish. | +| `dars.rs` | Pins, upload verification with `expected_main_package_id`, vetting observation, completion. Startup coordination-DAR upload task. | +| `acs.rs` | Offset capture on co-sign, export spool + manifest, import endpoint logic, manifest verification, empty fast path, flag clearing, spool cleanup. | +| `engine/{onboarding,add_party,kick,change_threshold,contracts,dars}.rs` | Per-kind step machines (coordinator and member sides) as functions over on-ledger state and local artefacts (section 6). | Removed: `crates/decman/src/noise/` (all), `server/peer_status.rs`, `server/health.rs`, -`server/handlers/keys.rs`, `workflow/{onboarding,add_party,kick,change_threshold,contracts,dars}/{coordinator,peer}.rs`, -`workflow::start_peer`, `WorkflowState` peer-connection gates, `WorkflowStep::to_command`, -all `*InvitePayload` DTOs, `MissingPeerEdge`/`OnboardingMeshErrorResponse`, `KeyStatusResponse`, -Cargo deps `hyper-noise`, `tokio-noise` (and its patch), `secp256k1`, `hyper`, `http` if unused, -the `Timeouts` and `NoiseRetryConfig` config structs, `NodeInfo.{listen_address,port,public_address}`, -CLI flags `--listen-address --noise-port --public-address --timeout-* --noise-retry-*`, -`data/noise.key` handling, `MIN_PEER_VERSION`, `WIRE_VERSION`, `integration-tests/smoke-noise-errors.sh`. - -Kept and reused: `workflow/topology.rs` helpers (`sign_transactions_with_topology_retry`, -`authorize_with_topology_retry`, `synchronizer_store_id`, `head_state_query`, `fetch_*`), -`workflow/signing_keys.rs` (with on-chain-first lookup), `workflow/party_replication/*` -(export session, import bracket, offsets, flag clearing), `canton_hash`, `signing/*`, -`workflow/external_party/*` (unchanged), `workflow/storage.rs`, `workflow/state.rs` reduced -to the run projection, `server/reward_automation.rs` loop shape as the observer template. +`server/handlers/keys.rs`, `workflow/{onboarding,add_party,kick,change_threshold,contracts,dars}/{coordinator,peer}.rs` +and their `steps/proposals/{create,sign,submit}.rs`, `workflow::start_peer`, `WorkflowState` +peer-connection gates, `WorkflowStep::to_command`, `WorkflowRegistry`, `WorkflowGuard`, +`HttpWorkflowState.abort_handle`, `topology::{sign_dns_p2p_proposals, aggregate_dns_p2p_signatures, +dedupe_signatures, submit_dns_then_p2p, DnsP2pArtifactKinds}`, `pipe.rs::{encode_block, decode_data, +decode_end}` (keep `ExportSession`), all `*InvitePayload` DTOs, `MissingPeerEdge`, +`OnboardingMeshErrorResponse`, `KeyStatusResponse`, Cargo deps `hyper-noise`, `tokio-noise` +(and its patch), `secp256k1`, dev-dep `static_assertions` (`hyper` and `http` stay: other +code uses them), the `Timeouts` and `NoiseRetryConfig` config structs, +`NodeInfo.{listen_address,port,public_address}`, CLI flags `--listen-address --noise-port +--public-address --timeout-* --noise-retry-*`, `data/noise.key` handling, `MIN_PEER_VERSION`, +`WIRE_VERSION`, `DECPM_PEER_WAIT_POLL_DELAY_MS`, `DECPM_ACS_BLOCK_BYTES`, +`integration-tests/smoke-noise-errors.sh`, the `deny.toml` rationale that names `hyper-noise`. + +Kept and reused: `workflow/topology.rs` retry helpers and readers, `workflow/signing_keys.rs` +(on-chain-first lookup), `workflow/party_replication/*` (export session, import bracket, +offsets, flag clearing), `canton_hash`, `signing/*`, `workflow/external_party/*` (unchanged), +`workflow/storage.rs`, `workflow/state.rs` reduced to the run projection, +`server/reward_automation.rs` loop shape as the observer template. ## 5. Validation before a member co-signs -Built from: the accepted `WorkflowProposal`, all `WorkflowAcceptance`s for it, the head -state, and local identity. - -Common: the proposal names this node (owner fingerprint or participant); this node has not -signed it; `serial == accepted + 1` (or `1`). - -Onboarding DND: `owners` == set of acceptances' `namespaceFingerprint` ∪ proposer's own -fingerprint (the proposer publishes its key in its own acceptance record, created by itself); -`|owners| == |participants|`; `threshold == proposal.threshold`; `namespace == computeNamespace(owners)`. -Onboarding P2P: `party == prefix::namespace`; hosts == `participants` all Confirmation, no -Onboarding marker; `party_signing_keys.keys` fingerprints == owners; both thresholds == -`proposal.threshold`. - -Add-party DND: owners == head owners ∪ {joiner fingerprint from its acceptance}; threshold -== `proposal.threshold`. Add-party P2P: hosts == head hosts ∪ {joiner, Confirmation, -Onboarding}; keys == head keys ∪ {joiner key}; thresholds == `proposal.threshold`. - -Kick DND: owners == head owners − {kicked owner}; threshold == `proposal.threshold`. -Kick P2P: hosts == head hosts − {kicked}; keys == head keys − {kicked key}; thresholds. - -Change threshold: mapping equal to head except both thresholds == `proposal.threshold`. - -Contracts round: recomputed hash == `preparedHashHex`; `act_as == [decPartyId]`; -`deadline > now`; the round belongs to the accepted proposal instance. - -DAR upload: sha256 of the uploaded file == a pending pin for this node. +Inputs: the accepted `WorkflowProposal` (active, not expired), its counted acceptances (D6), +head state, local identity. + +Common: `BaseResult.operation == ADD_REPLACE`; the proposer's owner fingerprint ∈ +`signed_by_fingerprints`; this node's fingerprint ∉ `signed_by_fingerprints`; `serial == +accepted + 1` and `accepted == base serial` (or `1`); DND requires own fingerprint ∈ +`owners`; P2P requires own participant ∈ hosts at Confirmation (or as the Onboarding joiner). +Hosts compare as full `(participant_uid, permission, onboarding)` tuples; keys compare as full +`SigningPublicKey` byte sets. + +Onboarding DND: `owners == {proposerNamespaceFingerprint} ∪ {a.namespaceFingerprint | counted +a}`; `|owners| == |participants|`; `threshold == proposal.threshold`; `namespace == +computeNamespace(owners)`. +Onboarding P2P: `party == prefix::namespace`; `hosts == {(proposerParticipant, Confirmation, +None)} ∪ {(a.participantId, Confirmation, None)}`; `proposerParticipant` hosts `proposer`; +keys == the root-NSD `target_key` of every owner; both thresholds == `proposal.threshold`. + +Add-party DND: `owners == head owners ∪ {joiner.namespaceFingerprint}`; `threshold == +proposal.threshold`. Add-party P2P: `hosts == head hosts ∪ {(joiner, Confirmation, Onboarding)}` +with every head host unchanged; `keys == head keys ∪ {joiner key from its root NSD}`; +thresholds == `proposal.threshold`. + +Kick DND: `owners == head owners − {kicked fingerprint}`; `|owners| == |head owners| − 1`; +`threshold == proposal.threshold`. The kicked fingerprint comes from this node's local +`dec_party_participant.owner_key` for `kickedParticipant`, recorded from that node's own +acceptance when it joined, never from the proposer. Kick P2P: `hosts == head hosts − +{kicked}` with every survivor unchanged; `|keys| == |head keys| − 1`; the removed key is not +this node's key and is not claimed by any survivor; thresholds == `proposal.threshold`. +Legacy branch (party_signing_keys fingerprints ≠ DND owners): the proposer uses +`known_signing_keys_by_member` plus elimination; members check "exactly one key removed, not +mine, not claimed by a survivor" and refuse otherwise with the existing message. + +Change threshold: DND equal to head except `threshold == proposal.threshold`; P2P equal to +head except `threshold` and `party_signing_keys.threshold` both == `proposal.threshold`. + +Contracts round and DAR upload: as in D7 and D8. Any mismatch: do not sign; mark the peer run `Failed` with the reason; show it in the UI. ## 6. Steps per kind (UI projection) -Names are the `current_step` values. `step_total` is the list length. The frontend -`workflowSteps.ts` mirrors these lists. +Names are the `current_step` values; `step_total` is the list length. The frontend +`WORKFLOW_STEPS` becomes `Record` and +`stepsForRun` selects by `run.role` and `run.member_variant`. `WaitingForPeers` is renamed +`WaitingForAcceptances` everywhere, including `WAITING_FOR_PEERS_STEP`. | Kind | Coordinator | Member | |---|---|---| -| Onboarding | `WaitingForAcceptances`, `GenerateKeys`, `ProposeNamespace`, `AwaitNamespace`, `ProposeParty`, `AwaitParty`, `Complete` | `GenerateKeys`, `CoSignNamespace`, `CoSignParty`, `Complete` | -| AddParty | `WaitingForAcceptances`, `ExportState`, `ProposeChanges`, `AwaitChanges`, `AwaitReplication`, `Complete` | joiner: `GenerateKeys`, `CoSignChanges`, `SyncAcs`, `ClearOnboarding`, `Complete`; others: `CoSignChanges`, `PublishManifest`, `Complete` | -| Kick | `WaitingForAcceptances`, `ExportState`, `ProposeChanges`, `AwaitChanges`, `Complete` | `CoSignChanges`, `Complete` | -| ChangeThreshold | `WaitingForAcceptances`, `ExportState`, `ProposeChanges`, `AwaitChanges`, `Complete` | `CoSignChanges`, `Complete` | +| Onboarding | `GenerateKeys`, `WaitingForAcceptances`, `ProposeNamespace`, `AwaitNamespace`, `ProposeParty`, `AwaitParty`, `Complete` | `GenerateKeys`, `CoSignNamespace`, `CoSignParty`, `Complete` | +| AddParty | `GenerateKeys`, `WaitingForAcceptances`, `ProposeChanges`, `AwaitChanges`, `AwaitReplication`, `Complete` | Joiner: `GenerateKeys`, `CoSignChanges`, `SyncAcs`, `ClearOnboarding`, `Complete`. Member: `CoSignChanges` (captures the export offset first), `PublishManifest`, `Complete` | +| Kick | `WaitingForAcceptances`, `ProposeChanges`, `AwaitChanges`, `Complete` | `CoSignChanges`, `Complete` | +| ChangeThreshold | `WaitingForAcceptances`, `ProposeChanges`, `AwaitChanges`, `Complete` | `CoSignChanges`, `Complete` | | Contracts | `WaitingForAcceptances`, `AwaitDars`, `PrepareSubmissions`, `CollectSignatures`, `ExecuteSubmissions`, `Complete` | `UploadDars`, `SignSubmissions`, `Complete` | | Dars | `WaitingForAcceptances`, `AwaitVetting`, `Complete` | `UploadDars`, `Complete` | -Kick and change-threshold need `threshold` acceptances (quorum), not all. Contracts needs -`party_signing_keys.threshold` signatures. Onboarding and add-party need every invitee. +Quorum: onboarding, add-party, and dars need every invitee. Kick and change-threshold need +`max(previousThreshold, proposal.threshold)` owner signatures per mapping, counting the +proposer; the coordinator leaves `WaitingForAcceptances` when counted acceptances `>= +max(previousThreshold, proposal.threshold) − 1`, and later acceptances still co-sign. +`previousThreshold` is the head DND threshold read at proposal creation. Preflight 409: refuse +a kick when `previousThreshold > |owners| − 1`; refuse any `proposal.threshold > |resulting +owners|`. Contracts needs `party_signing_keys.threshold` verified signatures. ## 7. HTTP surface Unchanged paths and shapes: all `/governance/*`, `/auth/*`, `/party-config*`, `/v0/tenant/*`, token-standard routes, `/workflows*`, `/onboarding*`, `/kick*`, `/add-party*`, -`/change-threshold*`, `/contracts*`, `/dars/*`, `/invitations*`, `/decentralized-parties`, -`/packages/*`, `/network-config`, `/node-config`, `/node-health`, `/healthz`, `/metrics`. +`/change-threshold*`, `/contracts*`, `/dars/distribute*`, `/dars/cancel`, `/invitations*`, +`/decentralized-parties`, `/packages/vetted`, `/node-health`, `/healthz`, `/metrics`. Changed DTOs: -* `Peer { participant_id, name, party: Option }` (drop `address`, `port`, `public_key`). -* `NodeInfo { participant_id }` (drop listener fields). `NodeConfig` drops `timeouts`, `noise_retry`. -* `ConnectionStatus { CurrentNode, Active, Stale, Unknown }`. -* `ParticipantStatus { id, status, node_party?, last_seen_at?, heartbeat_age_secs?, workflow?, version?, build_version? }`. -* `PendingInvitation`: `coordinator_pubkey` → `coordinator_participant`; add `coordinator_party`, `proposal_cid`. -* `WorkflowRun`: `coordinator_pubkey` → `coordinator_participant`; add `coordinator_party`, `proposal_cid`; `connected_peers` keeps its name and means "invitees that accepted". -* `PeerPackageResult { participant_id, name, reachable, error_kind?, packages }` stays; `reachable` means "topology read succeeded"; `PeerErrorKind { TopologyReadFailed, NoVettedPackages, Other }`. -* `KnownMember` unchanged; populated from `WorkflowAcceptance.memberParty` records and `party_credentials`. +* `Peer { participant_id, name, party: Option }` (`GET/POST /network-config`). +* `NodeInfo { participant_id }`; `NodeConfig` drops `timeouts`, `noise_retry` (`GET /node-config`). +* `ConnectionStatus { CurrentNode, Active, Stale, Unknown, Unvetted }`. +* `ParticipantStatus { id, status, node_party?, last_seen_at?, heartbeat_age_secs?, version?, build_version? }` (`workflow` and `latency_ms` removed). +* `PendingInvitation`: `id = proposal_cid`; `coordinator_pubkey` → `coordinator_participant`; add `coordinator_party`, `proposal_cid`, `expires_at`. +* `WorkflowRun`: `coordinator_pubkey` → `coordinator_participant`; add `coordinator_party`, `proposal_cid`, `member_variant`; `connected_peers` keeps its name and means "invitees that accepted". +* `PeerPackageResult { participant_id, name, reachable, error_kind?, packages }` stays; `PeerErrorKind { TopologyReadFailed, NoVettedPackages, Other }`. +* `KnownMember` unchanged; populated from `WorkflowAcceptance.memberParty` and `party_credentials`. +* `POST /dars/upload` accepts optional `pin_instance`. +* `OnboardingRequest.peer_ids` keeps its name. -New: +New: `GET /node-identity`, `PUT /node-identity`, `GET /registry`, `GET /acs-export/{party}/{target}`, +`POST /acs-import/{party}`, `GET /acs-manifests/{party}`, `GET /proposals/unsolicited`. -* `GET /node-identity`, `PUT /node-identity`. -* `GET /registry` → `{ self: DecmanNodeView, peers: [DecmanNodeView], inbound: [DecmanNodeView] }`. -* `GET /acs-export/{party}/{target}?serial=` (admin, streamed), `POST /acs-import/{party}?serial=` (admin, streamed), `GET /acs-manifests/{party}`. -* `POST /dars/upload` accepts optional `pin_instance` to verify against pending pins. -* `GET /proposals/unsolicited` → topology proposals naming this node with no matching accepted `WorkflowProposal`. +Removed: `GET /keys/status`; the 422 mesh error on `POST /onboarding`. -Removed: `GET /keys/status`. +Consumers that change: frontend (`NetworkConfigAccordion`, `App.tsx`, `OnboardingDialog`, +`NotificationsView`, `StatusDot`, `NodeHealthCard`, `AddPartyDialog`, `DarsDialog`, +`PackagesPanel`, `KickDialog` copy, `workflowSteps.ts` + test, mocks), `decman-cli` (`api.rs`, +`app.rs` `peers_to_json`/`validate_peer_form`, `ui.rs` status/peers table/node info), +`bin/gen_types.rs`, `integration-tests/*.sh`, `crates/decman/tests/common/*`, e2e fixtures +and the governance spec (documented as follow-up). -## 8. Database migration `000020_noise_sunset` +## 8. Database migrations `000020_node_identity` and `000021_noise_sunset` -Up, in this order: +`000020_node_identity` (shipped with the on-ledger foundation; compatible with the Noise code): 1. `ALTER TABLE party_credentials ADD COLUMN kind TEXT NOT NULL DEFAULT 'decparty';` +8. `CREATE TABLE proposal_decisions (proposal_cid TEXT PRIMARY KEY NOT NULL, decision TEXT NOT NULL, decided_at INTEGER NOT NULL, pinned_hashes_json TEXT);` + +`000021_noise_sunset` (shipped with the Noise removal), in this order: 2. `UPDATE workflow_runs SET status = 'failed', error = 'Interrupted by the Noise-sunset upgrade. Dismiss this card and start the operation again.', updated_at = strftime('%s','now') WHERE status = 'inprogress';` 3. `UPDATE workflow_runs SET coordinator_pubkey = (SELECT participant_id FROM peers WHERE peers.public_key = workflow_runs.coordinator_pubkey) WHERE coordinator_pubkey IS NOT NULL AND EXISTS (SELECT 1 FROM peers WHERE peers.public_key = workflow_runs.coordinator_pubkey);` 4. `ALTER TABLE workflow_runs RENAME COLUMN coordinator_pubkey TO coordinator_participant;` -5. `ALTER TABLE workflow_runs ADD COLUMN coordinator_party TEXT; ALTER TABLE workflow_runs ADD COLUMN proposal_cid TEXT; ALTER TABLE workflow_runs ADD COLUMN topology_hashes_json TEXT;` -6. `DELETE FROM pending_invitations; ALTER TABLE pending_invitations RENAME COLUMN coordinator_pubkey TO coordinator_participant; ALTER TABLE pending_invitations ADD COLUMN coordinator_party TEXT; ALTER TABLE pending_invitations ADD COLUMN proposal_cid TEXT;` +5. `ALTER TABLE workflow_runs ADD COLUMN coordinator_party TEXT; ALTER TABLE workflow_runs ADD COLUMN proposal_cid TEXT; ALTER TABLE workflow_runs ADD COLUMN topology_hashes_json TEXT; ALTER TABLE workflow_runs ADD COLUMN member_variant TEXT;` +6. `DELETE FROM pending_invitations; ALTER TABLE pending_invitations RENAME COLUMN coordinator_pubkey TO coordinator_participant; ALTER TABLE pending_invitations ADD COLUMN coordinator_party TEXT; ALTER TABLE pending_invitations ADD COLUMN proposal_cid TEXT; ALTER TABLE pending_invitations ADD COLUMN expires_at INTEGER;` 7. `ALTER TABLE peers DROP COLUMN address; ALTER TABLE peers DROP COLUMN port; ALTER TABLE peers DROP COLUMN public_key;` -8. `CREATE TABLE proposal_decisions (proposal_cid TEXT PRIMARY KEY NOT NULL, decision TEXT NOT NULL, decided_at INTEGER NOT NULL);` -Down is documented as lossy (re-adds the three peer columns with defaults). +Down re-adds the three peer columns with defaults, drops the new columns and table, and +renames `coordinator_participant` back. It is applied manually (D12). ## 9. Configuration Removed env: `DECPM_LISTEN_ADDRESS`, `DECPM_NOISE_PORT`, `DECPM_PUBLIC_ADDRESS`, `DECPM_TIMEOUT_*`, `DECPM_NOISE_RETRY_*`, `DECPM_PEER_WAIT_POLL_DELAY_MS`, `DECPM_ACS_BLOCK_BYTES`. -Added env: `DECPM_HEARTBEAT_INTERVAL_SECS` (3600), `DECPM_HEARTBEAT_MIN_INTERVAL_SECS` (60), -`DECPM_PEER_STALE_FACTOR` (3), `DECPM_OBSERVER_POLL_SECS` (3), -`DECPM_AUTO_UPLOAD_COORDINATION_DAR` (true), `DECPM_ACS_SPOOL_DIR` (`{dir}/data/acs`). +Added env: `DECPM_HEARTBEAT_INTERVAL_SECS` (3600), `DECPM_HEARTBEAT_MIN_INTERVAL_SECS` (60, +clamped to the interval), `DECPM_PEER_STALE_FACTOR` (3), `DECPM_OBSERVER_POLL_SECS` (3), +`DECPM_AUTO_UPLOAD_COORDINATION_DAR` (true), `DECPM_ACS_SPOOL_DIR` (`{dir}/data/acs`), +`DECPM_PROPOSAL_TTL_SECS` (604800). -`PackageConfig` gains `decman_coordination: Option` (default `#decman-coordination-v1`). +The coordination package is node-level, not per party: `consts::COORDINATION_PACKAGE_REF = +"#decman-coordination-v1"`, overridable with `DECPM_COORDINATION_PACKAGE_REF`. ## 10. Tests -Unit (cargo): codec round trips for every template; mapping builders are byte-stable; -serial guard; validation per kind (positive and each negative); registry staleness; hash -co-sign idempotency (mocked client); migration 000020 on a seeded DB; DTO snapshots. +Unit (cargo): codec round trips for every template; mapping builders are byte-stable; serial +and base-serial guards; acceptance counting predicates (foreign acceptance rejected, +duplicate acceptor rejected); validation per kind (positive and each negative, including a +REMOVE proposal and a host permission flip); quorum arithmetic (4 owners, previous 3, new 2, +kick one → wait for 3 signatures); signature verification before counting; registry +staleness and signatory keying; hash co-sign idempotency (mocked client); migration 000020 on a +seeded DB; DTO snapshots. + +Daml (`dpm test`): section 3. + +Integration harness (`integration-tests/*.sh`): drop Noise ports and key reads; readiness = +`/node-config` + `/healthz`; wait until `/packages/vetted` lists the coordination package on all +nodes; allocate a node party per node on the JSON Ledger API, grant `ledger-api-user` +CanActAs/CanReadAs, `PUT /node-identity`; configure peers with `party`; wait for each node's +`DecmanNode` to be visible on its peers; set `DECPM_HEARTBEAT_INTERVAL_SECS=5`, +`DECPM_HEARTBEAT_MIN_INTERVAL_SECS=1`, `DECPM_OBSERVER_POLL_SECS=1`. Phases: keep the +ledger-only ones; rewrite the coordination ones to the new steps; delete `peer_health_flip` +(replaced by a staleness flip), `peer_3_strikes_abort`, `retry_with_offline_peer`, +`invite_cap`, `check_peer_dars` (replaced by a vetted-packages comparison). Playwright +`bring-up.sh`, `data-status="unreachable"`, and the kick owner-key wait are follow-up work. + +KMS spike (manual, before the mainnet window): D4. -Daml (`dpm test`): as in section 3. +## 11. Documentation -Integration harness (`integration-tests/*.sh`): drop Noise ports and key reads; allocate a -node party per node on the JSON Ledger API, grant rights, `PUT /node-identity`; wait for -each node's `DecmanNode` to appear on its peers; set heartbeat interval 5 s. Phases: keep -the ledger-only ones; rewrite the coordination ones to the new steps; delete -`peer_health_flip` (replace with a staleness flip), `peer_3_strikes_abort`, `retry_with_offline_peer`, -`invite_cap`, `check_peer_dars` (replace with a vetted-packages comparison). +README, ARCHITECTURE, DEPLOYMENT_GUIDE, USER_GUIDE, CUSTOM_DAML_TEMPLATES, KMS_SIGNING (new +key name, fingerprint-based discovery, the dual-usage trust implication), CONTRIBUTING: remove +every Noise reference; document the node identity, the registry, the proposal model, the DAR +pin flow and startup auto-vetting policy, the ACS handoff and spool sizing, the cutover runbook +and manual rollback, the residual legacy kick-attribution gap, gap #423 (package check) status, +and the new env vars. Kubernetes manifests lose containerPort 9000, the ClusterIP `noise` +port, the `dec-party-manager-noise` LoadBalancer, and the removed CLI args (clap rejects +unknown args). `Dockerfile` exposes 8080 only. -## 11. Documentation +## 12. Identifiers -README, ARCHITECTURE, DEPLOYMENT_GUIDE, USER_GUIDE, CUSTOM_DAML_TEMPLATES, KMS_SIGNING, -CONTRIBUTING: remove every Noise reference; document the node identity, the registry, the -proposal model, the DAR pin flow, the ACS handoff, the cutover runbook, and the new env vars. -Kubernetes manifests lose port 9000 and the Noise LoadBalancer. `Dockerfile` exposes 8080 only. +* `pin_instance` = `WorkflowProposal.runId`. +* `pending_invitations.id` = `proposal_cid`. +* Peer `instance_name` = `peer-{kind}-{coordinator_participant_short}-{coordinator_runId}`. +* Coordinator `instance_name` unchanged (`{prefix}-creation`, `{party}-kick-{n}`, ...). -## 12. Evidence summary +## 13. Evidence summary -* Partial-authorization proposals propagate and merge by fingerprint; co-sign by hash; - no expiry, no cancel; `Snapshot(MaxValue)` for discovery. (Canton `TopologyManager.scala`, +* Partial-authorization proposals propagate and merge by fingerprint; co-sign by hash; no + expiry, no cancel; `Snapshot(MaxValue)` for discovery. (Canton `TopologyManager.scala`, `TopologyStateProcessor.scala`, `GrpcTopologyManagerReadService.scala`; Splice `SvOnboardingPartyToParticipantProposalTrigger.scala`.) -* DND serial 1 needs every owner's signature and every owner's root `NamespaceDelegation` - in the synchronizer store first; a P2P under a decentralized namespace needs the DND - effective first. (`TopologyMappingChecks.scala`, `TransactionAuthorizationCache.scala`.) +* DND serial 1 needs every owner's signature and every owner's root `NamespaceDelegation` in + the synchronizer store first; later DND/P2P changes are authorized by the **stored** DND + threshold; a P2P under a decentralized namespace needs the DND effective first. + (`TopologyMappingChecks.scala`, `TransactionAuthorizationCache.scala`, `AuthorizationGraph.scala`.) +* A P2P REMOVE with unchanged content falls back to party-namespace authorization + (`TopologyMapping.scala` requiredAuth); members must pin `ADD_REPLACE`. * A root `NamespaceDelegation` is self-authorizing and carries the full public key; - `[Namespace, Protocol]` is a valid usage set, also on KMS. (`Signing.scala`, - `KmsPrivateCrypto.scala`.) + `[Namespace, Protocol]` is a valid usage set, also on KMS. (`Signing.scala`, `KmsPrivateCrypto.scala`.) +* A locally hosted submitter needs Submission permission (`AdmissibleSynchronizersComputation.scala`). * Clearing an Onboarding flag needs only the joiner's participant namespace. - (`TopologyMapping.scala` requiredAuth; `PartyReplicationTopologyWorkflow.scala`.) -* Interactive submission: 24 h `preparationTimeRecordTimeTolerance` on the Global - Synchronizer; all signatures in one execute call; any node with `CanExecuteAs` may execute. +* Interactive submission: `preparationTimeRecordTimeTolerance` (24 h Splice default) and the + signed `max_record_time` bound the window; all signatures in one execute call. * Online party replication is alpha with `ProtocolVersion.dev` codecs; unusable on PV 35. -* Daml visibility is stakeholder-only; a registry needs explicit observers; traffic cost of a - 1 KB create with 4 observers is well under the free base rate. -* Canton requires every observer participant to have vetted the package before a create. +* Daml visibility is stakeholder-only; a create fails when an observer's participant has not + vetted the package (`UsableSynchronizers.scala`). From 0672526bc612e020b536c558ef53fda22580945e Mon Sep 17 00:00:00 2001 From: scolear Date: Mon, 14 Sep 2026 17:09:02 +0200 Subject: [PATCH 03/24] feat(onledger): add the Daml coordination package and the Rust foundation Decman nodes will coordinate through Canton instead of Noise. This commit adds the pieces every later change builds on: the Daml package decman-coordination-v1 (node registry, workflow proposals and acceptances, submission rounds, ACS manifests) with its test package and pinned release DAR; a node identity stored as a party_credentials row of kind 'node' with GET/PUT /node-identity; and the onledger Rust module with the Daml codec and client, the registry with heartbeat and peer health, proposal projection, synchronizer-store proposal discovery and co-signing by transaction hash, per-kind validation, the observer loop, and the engine API with stub drivers for every kind. Noise code is untouched here; the removal follows once the drivers are in place. --- crates/common/src/coordination.rs | 142 ++ crates/common/src/lib.rs | 1 + crates/common/src/types.rs | 2 +- .../migrations/000020_node_identity.down.sql | 2 + .../migrations/000020_node_identity.up.sql | 18 + crates/decman/src/auth/mod.rs | 1 + crates/decman/src/auth/validators/jwt.rs | 7 +- crates/decman/src/bin/gen_types.rs | 8 +- crates/decman/src/config.rs | 46 + crates/decman/src/consts.rs | 124 ++ crates/decman/src/db/rows.rs | 99 +- crates/decman/src/db/schema.rs | 40 +- crates/decman/src/db/sqlite.rs | 216 +- crates/decman/src/lib.rs | 1 + crates/decman/src/onledger/README.md | 739 ++++++ crates/decman/src/onledger/acs.rs | 205 ++ crates/decman/src/onledger/daml/client.rs | 246 ++ crates/decman/src/onledger/daml/codec.rs | 1355 +++++++++++ crates/decman/src/onledger/daml/mod.rs | 16 + crates/decman/src/onledger/daml/templates.rs | 214 ++ crates/decman/src/onledger/dars.rs | 206 ++ .../decman/src/onledger/engine/add_party.rs | 75 + .../src/onledger/engine/change_threshold.rs | 61 + .../decman/src/onledger/engine/contracts.rs | 117 + crates/decman/src/onledger/engine/dars.rs | 49 + crates/decman/src/onledger/engine/kick.rs | 62 + crates/decman/src/onledger/engine/mod.rs | 1973 +++++++++++++++++ .../decman/src/onledger/engine/onboarding.rs | 58 + crates/decman/src/onledger/identity.rs | 351 +++ crates/decman/src/onledger/mod.rs | 393 ++++ crates/decman/src/onledger/observer.rs | 372 ++++ crates/decman/src/onledger/proposals.rs | 834 +++++++ crates/decman/src/onledger/registry.rs | 1002 +++++++++ crates/decman/src/onledger/submission.rs | 307 +++ crates/decman/src/onledger/topology.rs | 1295 +++++++++++ crates/decman/src/onledger/validation.rs | 1513 +++++++++++++ crates/decman/src/server/handlers/auth.rs | 17 +- .../decman/src/server/handlers/governance.rs | 1 + crates/decman/src/server/handlers/mod.rs | 2 + .../src/server/handlers/node_identity.rs | 424 ++++ crates/decman/src/server/handlers/parties.rs | 1 + .../src/server/handlers/party_config.rs | 14 +- crates/decman/src/server/middleware/auth.rs | 14 +- crates/decman/src/server/mod.rs | 33 +- crates/decman/src/server/reward_automation.rs | 1 + daml/decman-coordination-test/daml.yaml | 17 + .../Decman/Coordination/Test/AcsTest.daml | 120 + .../Decman/Coordination/Test/NodeTest.daml | 181 ++ .../Coordination/Test/SubmissionTest.daml | 143 ++ .../Coordination/Test/WorkflowTest.daml | 230 ++ .../daml/Decman/Coordination/TestUtils.daml | 214 ++ daml/decman-coordination/daml.yaml | 11 + .../daml/Decman/Coordination/Acs.daml | 32 + .../daml/Decman/Coordination/Node.daml | 73 + .../daml/Decman/Coordination/Submission.daml | 91 + .../daml/Decman/Coordination/Types.daml | 22 + .../daml/Decman/Coordination/Workflow.daml | 169 ++ daml/multi-package.yaml | 2 + releases/v1/decman-coordination-v1-0.1.0.dar | Bin 0 -> 498531 bytes 59 files changed, 13941 insertions(+), 21 deletions(-) create mode 100644 crates/common/src/coordination.rs create mode 100644 crates/decman/migrations/000020_node_identity.down.sql create mode 100644 crates/decman/migrations/000020_node_identity.up.sql create mode 100644 crates/decman/src/onledger/README.md create mode 100644 crates/decman/src/onledger/acs.rs create mode 100644 crates/decman/src/onledger/daml/client.rs create mode 100644 crates/decman/src/onledger/daml/codec.rs create mode 100644 crates/decman/src/onledger/daml/mod.rs create mode 100644 crates/decman/src/onledger/daml/templates.rs create mode 100644 crates/decman/src/onledger/dars.rs create mode 100644 crates/decman/src/onledger/engine/add_party.rs create mode 100644 crates/decman/src/onledger/engine/change_threshold.rs create mode 100644 crates/decman/src/onledger/engine/contracts.rs create mode 100644 crates/decman/src/onledger/engine/dars.rs create mode 100644 crates/decman/src/onledger/engine/kick.rs create mode 100644 crates/decman/src/onledger/engine/mod.rs create mode 100644 crates/decman/src/onledger/engine/onboarding.rs create mode 100644 crates/decman/src/onledger/identity.rs create mode 100644 crates/decman/src/onledger/mod.rs create mode 100644 crates/decman/src/onledger/observer.rs create mode 100644 crates/decman/src/onledger/proposals.rs create mode 100644 crates/decman/src/onledger/registry.rs create mode 100644 crates/decman/src/onledger/submission.rs create mode 100644 crates/decman/src/onledger/topology.rs create mode 100644 crates/decman/src/onledger/validation.rs create mode 100644 crates/decman/src/server/handlers/node_identity.rs create mode 100644 daml/decman-coordination-test/daml.yaml create mode 100644 daml/decman-coordination-test/daml/Decman/Coordination/Test/AcsTest.daml create mode 100644 daml/decman-coordination-test/daml/Decman/Coordination/Test/NodeTest.daml create mode 100644 daml/decman-coordination-test/daml/Decman/Coordination/Test/SubmissionTest.daml create mode 100644 daml/decman-coordination-test/daml/Decman/Coordination/Test/WorkflowTest.daml create mode 100644 daml/decman-coordination-test/daml/Decman/Coordination/TestUtils.daml create mode 100644 daml/decman-coordination/daml.yaml create mode 100644 daml/decman-coordination/daml/Decman/Coordination/Acs.daml create mode 100644 daml/decman-coordination/daml/Decman/Coordination/Node.daml create mode 100644 daml/decman-coordination/daml/Decman/Coordination/Submission.daml create mode 100644 daml/decman-coordination/daml/Decman/Coordination/Types.daml create mode 100644 daml/decman-coordination/daml/Decman/Coordination/Workflow.daml create mode 100644 releases/v1/decman-coordination-v1-0.1.0.dar diff --git a/crates/common/src/coordination.rs b/crates/common/src/coordination.rs new file mode 100644 index 000000000..8828fb356 --- /dev/null +++ b/crates/common/src/coordination.rs @@ -0,0 +1,142 @@ +//! Wire DTOs for the Canton-native coordination surface: the node identity +//! (`GET`/`PUT /node-identity`) and the on-ledger registry (`GET /registry`). +//! +//! Pure data-transfer types, like [`crate::api`]: no server-only +//! dependencies, `utoipa` behind `openapi`, `ts-rs` behind `typegen`. + +use serde::{Deserialize, Serialize}; + +use crate::{canton_id::CantonId, types::Permission}; + +/// Body of `PUT /node-identity`. The same shape as `PartyConfigRequest` +/// without `dec_party_id`: the node party takes the member party's place, and +/// the row stores it in both columns. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] +pub struct NodeIdentityRequest { + /// The node party. Must be hosted on this participant with Submission + /// permission. + pub node_party_id: CantonId, + /// Ledger API user with `CanActAs` and `CanReadAs` on the node party. + pub user_id: String, + /// Keycloak server URL + #[serde(default)] + pub keycloak_url: String, + /// Keycloak realm name + #[serde(default)] + pub keycloak_realm: String, + /// OAuth2 client ID + #[serde(default)] + pub keycloak_client_id: String, + /// Client secret for M2M flow (None = keep existing, "" = clear) + #[serde(default)] + pub keycloak_client_secret: Option, + /// Username for password flow (None = keep existing, "" = clear) + #[serde(default)] + pub keycloak_username: Option, + /// Password for password flow (None = keep existing, "" = clear) + #[serde(default)] + pub keycloak_password: Option, + /// Auth0 tenant domain. When set together with the other auth0_* + /// fields, supersedes the Keycloak fields. + #[serde(default)] + pub auth0_domain: Option, + /// Auth0 API audience. + #[serde(default)] + pub auth0_audience: Option, + /// Auth0 M2M client ID. + #[serde(default)] + pub auth0_client_id: Option, + /// Auth0 M2M client secret. None = keep existing, "" = clear. + #[serde(default)] + pub auth0_client_secret: Option, +} + +/// Response of `GET /node-identity` and of a successful `PUT`. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] +pub struct NodeIdentityResponse { + /// Whether a `kind = 'node'` credentials row exists. + pub configured: bool, + /// The node party, when configured. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub node_party_id: Option, + /// This node's Canton participant id. + pub participant_id: CantonId, + /// The Ledger API user of the node party, when configured. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, + /// How this participant hosts the node party in the synchronizer head + /// state. `None` when not configured or when the topology read failed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hosting_permission: Option, +} + +/// How fresh a peer's registry entry is, as the observer loop last saw it. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] +#[serde(rename_all = "PascalCase")] +pub enum PeerHealthStatus { + /// A registry entry is visible and its last heartbeat is recent. + Active, + /// A registry entry is visible but `now - lastActiveAt` exceeds the + /// stale factor times the peer's heartbeat interval. + Stale, + /// The peer has vetted the coordination package but no entry signed by + /// its node party is visible to this node. + Unknown, + /// The peer's participant has not vetted the coordination package, so it + /// cannot see or publish registry entries. + Unvetted, +} + +/// One `DecmanNode` contract as this node sees it. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] +pub struct DecmanNodeView { + /// The contract id of the current entry. + pub contract_id: String, + /// The signatory node party. Registry reads are keyed by this field. + pub node_party: CantonId, + /// The participant the entry claims to run on. Cross-checked against the + /// synchronizer topology before use; `hosting_verified` says whether the + /// check passed. + pub participant_id: String, + /// Whether the topology store confirms `node_party` is hosted on + /// `participant_id` with Submission permission. + pub hosting_verified: bool, + pub display_name: String, + /// decman semver the peer runs. + pub version: String, + /// Display build identity (image tag / short SHA / `-dev`). + pub build_version: String, + pub coordination_version: i64, + /// The node parties the publisher named as observers. + pub peers: Vec, + /// Unix seconds of the last heartbeat. + pub last_active_at: i64, + pub heartbeat_interval_secs: i64, + pub min_heartbeat_interval_secs: i64, + /// Seconds since `last_active_at` at the time of the read. + pub heartbeat_age_secs: i64, + pub status: PeerHealthStatus, +} + +/// Response of `GET /registry`. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] +pub struct RegistryResponse { + /// This node's own entry, when published. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub self_entry: Option, + /// Entries signed by a node party that is in this node's peers table. + pub peers: Vec, + /// Entries visible to this node whose signatory is not a configured peer: + /// another operator added this node before this operator added them. + pub inbound: Vec, +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 5f7c35ef6..c070fbdcd 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -9,6 +9,7 @@ pub mod api; pub mod canton_id; +pub mod coordination; pub mod error; pub mod fingerprint; pub mod types; diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs index 062f911d1..31b9f2f3a 100644 --- a/crates/common/src/types.rs +++ b/crates/common/src/types.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use crate::canton_id::CantonId; /// Participant permission level -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] #[serde(rename_all = "lowercase")] diff --git a/crates/decman/migrations/000020_node_identity.down.sql b/crates/decman/migrations/000020_node_identity.down.sql new file mode 100644 index 000000000..80ca46268 --- /dev/null +++ b/crates/decman/migrations/000020_node_identity.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS proposal_decisions; +ALTER TABLE party_credentials DROP COLUMN kind; diff --git a/crates/decman/migrations/000020_node_identity.up.sql b/crates/decman/migrations/000020_node_identity.up.sql new file mode 100644 index 000000000..1fa00883f --- /dev/null +++ b/crates/decman/migrations/000020_node_identity.up.sql @@ -0,0 +1,18 @@ +-- Node identity (design D1) and the proposal-decision guard (design D11). +-- +-- A node identity is one `party_credentials` row with `kind = 'node'`. Its +-- `dec_party_id` and `member_party_id` both hold the node party, so +-- `AuthRegistry::get(node_party)` returns its token manager unchanged. Every +-- existing row is a decparty row, so the column defaults to 'decparty'. +ALTER TABLE party_credentials ADD COLUMN kind TEXT NOT NULL DEFAULT 'decparty'; + +-- One row per `WorkflowProposal` this node has decided on. The row stops the +-- observer from re-projecting a dismissed or declined proposal into +-- `pending_invitations`, and it pins the topology transaction hashes the node +-- agreed to co-sign, so a later tick never signs from a cached match. +CREATE TABLE proposal_decisions ( + proposal_cid TEXT PRIMARY KEY NOT NULL, + decision TEXT NOT NULL, + decided_at INTEGER NOT NULL, + pinned_hashes_json TEXT +); diff --git a/crates/decman/src/auth/mod.rs b/crates/decman/src/auth/mod.rs index 3ccf7582b..9c68cb542 100644 --- a/crates/decman/src/auth/mod.rs +++ b/crates/decman/src/auth/mod.rs @@ -510,6 +510,7 @@ mod tests { let mut namespace = [0u8; NAMESPACE_LENGTH]; namespace[0] = name; PartyCredentials { + kind: crate::config::CredentialKind::Decparty, dec_party_id: CantonId::new(format!("party{name}"), Namespace::new(namespace)), member_party_id: CantonId::new(format!("member{name}"), Namespace::new(namespace)), user_id: format!("user{name}"), diff --git a/crates/decman/src/auth/validators/jwt.rs b/crates/decman/src/auth/validators/jwt.rs index 173705f73..08474e89e 100644 --- a/crates/decman/src/auth/validators/jwt.rs +++ b/crates/decman/src/auth/validators/jwt.rs @@ -322,7 +322,12 @@ impl JwtValidator { }); } let creds = self.party_credentials.read().await; - for party in creds.iter() { + // A node identity row (design D1) mints outbound tokens only. It + // never widens who may log in, so it is not a trusted issuer. + for party in creds + .iter() + .filter(|p| p.kind != crate::config::CredentialKind::Node) + { if let Some(ref a) = party.auth0 && format!("https://{}", a.domain.trim_end_matches('/')) == issuer { diff --git a/crates/decman/src/bin/gen_types.rs b/crates/decman/src/bin/gen_types.rs index ef34b717e..dfb86c25a 100644 --- a/crates/decman/src/bin/gen_types.rs +++ b/crates/decman/src/bin/gen_types.rs @@ -16,7 +16,7 @@ use ts_rs::{Config, TS}; // (governance/decimal cluster + node config). Glob the `common` modules; the // `decman`-only types are imported by name (a glob over `server` would clash // with its re-exports of `common`). -use common::{api::*, types::*}; +use common::{api::*, coordination::*, types::*}; use dec_party_manager::{ config::{ Auth0Config, CantonConfig, CantonTlsConfig, KeycloakConfig, Network, NetworkConfig, @@ -160,6 +160,12 @@ fn main() -> Result<(), Box> { WorkflowRun, WorkflowRunsResponse, WorkflowStatusResponse, + // on-ledger coordination (node identity, registry) + DecmanNodeView, + NodeIdentityRequest, + NodeIdentityResponse, + PeerHealthStatus, + RegistryResponse, // decman governance / decimal cluster AcceptTransferDetails, ActionType, diff --git a/crates/decman/src/config.rs b/crates/decman/src/config.rs index 54bb0a4cc..9b0665b39 100644 --- a/crates/decman/src/config.rs +++ b/crates/decman/src/config.rs @@ -147,9 +147,55 @@ pub struct Auth0M2MConfig { /// it); re-exported so `crate::config::PackageConfig` resolves unchanged. pub use common::api::PackageConfig; +/// What a `party_credentials` row stands for. +/// +/// A `Decparty` row maps one decentralized party to the member party that +/// acts for it on this node. A `Node` row is this node's own identity (design +/// D1): `dec_party_id` and `member_party_id` both hold the node party, so +/// `AuthRegistry::get(node_party)` returns its token manager unchanged. +/// Decparty views filter on `Decparty`; the inbound JWT trust set skips `Node`. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum CredentialKind { + #[default] + Decparty, + Node, +} + +impl CredentialKind { + /// The stored column value; matches the serde representation. + pub fn as_str(self) -> &'static str { + match self { + Self::Decparty => "decparty", + Self::Node => "node", + } + } +} + +impl std::fmt::Display for CredentialKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for CredentialKind { + type Err = anyhow::Error; + + fn from_str(s: &str) -> std::result::Result { + match s { + "decparty" => Ok(Self::Decparty), + "node" => Ok(Self::Node), + other => Err(anyhow::anyhow!("unknown credential kind: {other}")), + } + } +} + /// Credentials for a specific decentralized party #[derive(Clone, Debug, Deserialize, Serialize, utoipa::ToSchema)] pub struct PartyCredentials { + /// Whether this row is a decparty mapping or the node identity. + #[serde(default)] + pub kind: CredentialKind, /// The decentralized party ID (shared among all members) pub dec_party_id: CantonId, /// The member party ID (local to this node, owns the credentials) diff --git a/crates/decman/src/consts.rs b/crates/decman/src/consts.rs index c3a65c6d2..5c8f6af1c 100644 --- a/crates/decman/src/consts.rs +++ b/crates/decman/src/consts.rs @@ -147,6 +147,130 @@ pub fn topology_propagation_delay_secs() -> u64 { .unwrap_or(TOPOLOGY_PROPAGATION_DELAY_SECS) } +// --------------------------------------------------------------------------- +// On-ledger coordination (design sections 3, 9) +// --------------------------------------------------------------------------- + +/// The `#package-name` ref of the coordination package. Node-level, not per +/// party: the registry entry exists before any decentralized party does. +/// Default value; the actual ref is read via [`coordination_package_ref`]. +pub const COORDINATION_PACKAGE_REF: &str = "#decman-coordination-v1"; + +/// The coordination package ref, overridable with +/// `DECPM_COORDINATION_PACKAGE_REF`. Defaults to [`COORDINATION_PACKAGE_REF`]. +pub fn coordination_package_ref() -> String { + std::env::var("DECPM_COORDINATION_PACKAGE_REF") + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| COORDINATION_PACKAGE_REF.to_string()) +} + +/// The coordination protocol version this build speaks. A proposer refuses to +/// start a workflow with an invitee whose registry entry reports a lower +/// `coordinationVersion` (design D3). +pub const COORDINATION_VERSION: i64 = 1; + +/// Default seconds between `DecmanNode_Heartbeat` exercises. +pub const HEARTBEAT_INTERVAL_SECS: u64 = 3600; + +/// Heartbeat cadence, configurable via `DECPM_HEARTBEAT_INTERVAL_SECS`. +/// Zero is clamped to one second so the heartbeat can never spin. +pub fn heartbeat_interval_secs() -> u64 { + std::env::var("DECPM_HEARTBEAT_INTERVAL_SECS") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(HEARTBEAT_INTERVAL_SECS) + .max(1) +} + +/// Default floor the `DecmanNode` template enforces between heartbeats. +pub const HEARTBEAT_MIN_INTERVAL_SECS: u64 = 60; + +/// The heartbeat floor written into `DecmanNode.minHeartbeatIntervalSecs`, +/// configurable via `DECPM_HEARTBEAT_MIN_INTERVAL_SECS`. Clamped to +/// `[1, heartbeat_interval_secs()]` because the template's `ensure` rejects a +/// floor above the interval. +pub fn heartbeat_min_interval_secs() -> u64 { + std::env::var("DECPM_HEARTBEAT_MIN_INTERVAL_SECS") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(HEARTBEAT_MIN_INTERVAL_SECS) + .clamp(1, heartbeat_interval_secs()) +} + +/// Default multiplier of a peer's heartbeat interval after which its entry +/// reads as `Stale`. +pub const PEER_STALE_FACTOR: u64 = 3; + +/// Staleness multiplier, configurable via `DECPM_PEER_STALE_FACTOR`. Zero is +/// clamped to one so a fresh heartbeat is never stale. +pub fn peer_stale_factor() -> u64 { + std::env::var("DECPM_PEER_STALE_FACTOR") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(PEER_STALE_FACTOR) + .max(1) +} + +/// Default seconds between observer ticks. +pub const OBSERVER_POLL_SECS: u64 = 3; + +/// Observer tick cadence, configurable via `DECPM_OBSERVER_POLL_SECS`. +/// Mainnet guidance is 10. Zero is clamped to one. +pub fn observer_poll_secs() -> u64 { + std::env::var("DECPM_OBSERVER_POLL_SECS") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(OBSERVER_POLL_SECS) + .max(1) +} + +/// Default lifetime of a `WorkflowProposal`: seven days. +pub const PROPOSAL_TTL_SECS: u64 = 604_800; + +/// Proposal lifetime, configurable via `DECPM_PROPOSAL_TTL_SECS`. Zero is +/// clamped to one because the template requires `expiresAt > createdAt`. +pub fn proposal_ttl_secs() -> u64 { + std::env::var("DECPM_PROPOSAL_TTL_SECS") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(PROPOSAL_TTL_SECS) + .max(1) +} + +/// Seconds between the observer's unfiltered proposal scans and archive +/// sweeps (design D5). The scan feeds the UI only and never signs. +pub const UNSOLICITED_SCAN_INTERVAL_SECS: u64 = 60; + +/// Default for `DECPM_AUTO_UPLOAD_COORDINATION_DAR` (design D8). +pub const AUTO_UPLOAD_COORDINATION_DAR: bool = true; + +/// Whether a startup task uploads and vets the embedded coordination DAR, +/// configurable via `DECPM_AUTO_UPLOAD_COORDINATION_DAR` (`true`/`false`, +/// `1`/`0`). Defaults to [`AUTO_UPLOAD_COORDINATION_DAR`]. +pub fn auto_upload_coordination_dar() -> bool { + match std::env::var("DECPM_AUTO_UPLOAD_COORDINATION_DAR") { + Ok(v) => match v.trim().to_ascii_lowercase().as_str() { + "0" | "false" | "no" | "off" => false, + "1" | "true" | "yes" | "on" => true, + _ => AUTO_UPLOAD_COORDINATION_DAR, + }, + Err(_) => AUTO_UPLOAD_COORDINATION_DAR, + } +} + +/// Directory name of the ACS spool inside the data directory (design D9). +pub const ACS_SPOOL_DIR_NAME: &str = "acs"; + +/// Where add-party ACS snapshots are spooled, configurable via +/// `DECPM_ACS_SPOOL_DIR`. Defaults to `{data_dir}/acs`. +pub fn acs_spool_dir(data_dir: &std::path::Path) -> std::path::PathBuf { + match std::env::var("DECPM_ACS_SPOOL_DIR") { + Ok(v) if !v.trim().is_empty() => std::path::PathBuf::from(v), + _ => data_dir.join(ACS_SPOOL_DIR_NAME), + } +} + // Base directory names (relative to root directory) /// Data directory name (contains the Noise key, SQLite database, and DARs) pub const DATA_DIR: &str = "data"; diff --git a/crates/decman/src/db/rows.rs b/crates/decman/src/db/rows.rs index 44eeb506a..51bf6a788 100644 --- a/crates/decman/src/db/rows.rs +++ b/crates/decman/src/db/rows.rs @@ -4,7 +4,9 @@ use anyhow::Context; use crate::{ canton_id::CantonId, - config::{Auth0M2MConfig, KeycloakConfig, PackageConfig, PartyCredentials, Peer}, + config::{ + Auth0M2MConfig, CredentialKind, KeycloakConfig, PackageConfig, PartyCredentials, Peer, + }, db::crypto, error::Result, server::{ @@ -49,6 +51,8 @@ impl PeerRow { #[derive(Debug, sqlx::FromRow)] pub struct PartyCredentialsRow { + /// `decparty` or `node`; see [`CredentialKind`]. + pub kind: String, pub dec_party_id: String, pub member_party_id: String, pub user_id: String, @@ -77,6 +81,7 @@ impl PartyCredentialsRow { None => (None, None, None, None), }; Ok(Self { + kind: creds.kind.as_str().to_string(), dec_party_id: creds.dec_party_id.to_string(), member_party_id: creds.member_party_id.to_string(), user_id: creds.user_id.clone(), @@ -111,6 +116,8 @@ impl PartyCredentialsRow { _ => None, }; Ok(PartyCredentials { + kind: CredentialKind::from_str(&self.kind) + .with_context(|| format!("party_credentials row {}", self.dec_party_id))?, dec_party_id: CantonId::parse(&self.dec_party_id)?, member_party_id: CantonId::parse(&self.member_party_id)?, user_id: self.user_id, @@ -132,6 +139,95 @@ impl PartyCredentialsRow { } } +/// What this node decided about one `WorkflowProposal`. +/// +/// The row is the idempotency guard of the observer: a proposal with a +/// decision is never projected into `pending_invitations` again. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProposalDecision { + /// The operator accepted. The node co-signs matching topology proposals. + Accepted, + /// The operator declined. `WorkflowProposal_Decline` was exercised. + Declined, + /// The operator dismissed the card without answering. + Dismissed, +} + +impl ProposalDecision { + /// The stored column value. + pub fn as_str(self) -> &'static str { + match self { + Self::Accepted => "accepted", + Self::Declined => "declined", + Self::Dismissed => "dismissed", + } + } +} + +impl std::fmt::Display for ProposalDecision { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for ProposalDecision { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s { + "accepted" => Ok(Self::Accepted), + "declined" => Ok(Self::Declined), + "dismissed" => Ok(Self::Dismissed), + other => Err(anyhow::anyhow!("unknown proposal decision: {other}")), + } + } +} + +/// One `proposal_decisions` row, decoded. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProposalDecisionEntry { + /// The `WorkflowProposal` contract id the decision is about. + pub proposal_cid: String, + pub decision: ProposalDecision, + /// Unix seconds. + pub decided_at: i64, + /// Topology transaction hashes (Canton hex) this node agreed to co-sign. + /// Empty until the observer pins them. + pub pinned_hashes: Vec, +} + +#[derive(Debug, sqlx::FromRow)] +pub struct ProposalDecisionRow { + pub proposal_cid: String, + pub decision: String, + pub decided_at: i64, + pub pinned_hashes_json: Option, +} + +impl ProposalDecisionRow { + pub fn from_domain(entry: &ProposalDecisionEntry) -> Result { + Ok(Self { + proposal_cid: entry.proposal_cid.clone(), + decision: entry.decision.as_str().to_string(), + decided_at: entry.decided_at, + pinned_hashes_json: encode_list(&entry.pinned_hashes, "proposal pinned hashes")?, + }) + } + + pub fn into_domain(self) -> Result { + let decision = ProposalDecision::from_str(&self.decision) + .with_context(|| format!("proposal_decisions row {}", self.proposal_cid))?; + let pinned_hashes = + decode_list(self.pinned_hashes_json, &self.proposal_cid, "pinned_hashes")?; + Ok(ProposalDecisionEntry { + proposal_cid: self.proposal_cid, + decision, + decided_at: self.decided_at, + pinned_hashes, + }) + } +} + #[derive(Debug, sqlx::FromRow)] pub struct DecPartyRow { pub party_id: String, @@ -605,6 +701,7 @@ mod tests { // the other tests use as raw strings they never parse. let ns = "1220c4010d6883f367c7f45d55b2449501620130f9b21e96379f17dea455ac7a5892"; PartyCredentialsRow { + kind: "decparty".to_string(), dec_party_id: format!("dec::{ns}"), member_party_id: format!("member::{ns}"), user_id: "user-1".to_string(), diff --git a/crates/decman/src/db/schema.rs b/crates/decman/src/db/schema.rs index f1912e4fa..4eaf9f510 100644 --- a/crates/decman/src/db/schema.rs +++ b/crates/decman/src/db/schema.rs @@ -1,6 +1,6 @@ use super::rows::{ ChainAuditCacheRow, DecPartyContractRow, DecPartyParticipantRow, DecPartyRow, - GovernanceAuditRow, + GovernanceAuditRow, ProposalDecision, ProposalDecisionEntry, }; use crate::{ canton_id::CantonId, @@ -165,6 +165,15 @@ pub trait SchemaRead { dec_party_id: &CantonId, artifact_kind: &str, ) -> Result)>>; + + /// This node's decision about one `WorkflowProposal`, if it made one. + async fn get_proposal_decision( + &self, + proposal_cid: &str, + ) -> Result>; + + /// Every recorded proposal decision, oldest first. + async fn get_all_proposal_decisions(&self) -> Result>; } /// Write operations on the database @@ -209,6 +218,35 @@ pub trait Commitable { /// Insert or replace party credentials async fn upsert_party_credentials(&mut self, creds: &PartyCredentials) -> Result; + /// Delete one party credentials row (no-op if absent). Used when the node + /// identity moves to a different node party, so only one `kind = 'node'` + /// row exists at a time. + async fn delete_party_credentials(&mut self, dec_party_id: &CantonId) -> Result; + + /// Record a first decision about a proposal. Returns `false` and changes + /// nothing when a row already exists: the first decision wins, so a racing + /// accept and decline cannot both take effect. + async fn insert_proposal_decision(&mut self, entry: &ProposalDecisionEntry) -> Result; + + /// Change the decision on an existing row. No-op if the row is absent. + async fn update_proposal_decision( + &mut self, + proposal_cid: &str, + decision: ProposalDecision, + decided_at: i64, + ) -> Result; + + /// Replace the pinned topology hashes on an existing row. No-op if the row + /// is absent. + async fn set_proposal_pinned_hashes( + &mut self, + proposal_cid: &str, + pinned_hashes: &[String], + ) -> Result; + + /// Forget a decision, e.g. once the proposal is archived and swept. + async fn delete_proposal_decision(&mut self, proposal_cid: &str) -> Result; + /// Upsert a decentralized party async fn upsert_dec_party(&mut self, row: &DecPartyRow) -> Result; diff --git a/crates/decman/src/db/sqlite.rs b/crates/decman/src/db/sqlite.rs index 6c0791bb4..66fa7fa3d 100644 --- a/crates/decman/src/db/sqlite.rs +++ b/crates/decman/src/db/sqlite.rs @@ -12,7 +12,8 @@ use super::{ rows::{ ChainAuditCacheRow, DecPartyContractRow, DecPartyIdentityRow, DecPartyParticipantRow, DecPartyRow, GovernanceAuditRow, PartyCredentialsRow, PeerRow, PendingInvitationRow, - WorkflowArtifactRow, WorkflowRunRow, + ProposalDecision, ProposalDecisionEntry, ProposalDecisionRow, WorkflowArtifactRow, + WorkflowRunRow, }, schema::{Commitable, SchemaRead, SchemaWrite}, }; @@ -562,6 +563,30 @@ impl SchemaRead for SqlitePool { .map(|r| Ok((r.peer_id, crypto::decrypt_bytes(&r.payload)?))) .collect() } + + async fn get_proposal_decision( + &self, + proposal_cid: &str, + ) -> Result> { + let row = sqlx::query_as::<_, ProposalDecisionRow>( + "SELECT * FROM proposal_decisions WHERE proposal_cid = ?", + ) + .bind(proposal_cid) + .fetch_optional(self) + .await?; + + row.map(|r| r.into_domain()).transpose() + } + + async fn get_all_proposal_decisions(&self) -> Result> { + let rows = sqlx::query_as::<_, ProposalDecisionRow>( + "SELECT * FROM proposal_decisions ORDER BY decided_at ASC, proposal_cid ASC", + ) + .fetch_all(self) + .await?; + + rows.into_iter().map(|r| r.into_domain()).collect() + } } impl SchemaWrite for SqlitePool { @@ -658,6 +683,7 @@ impl Commitable for sqlx::Transaction<'static, sqlx::Sqlite> { sqlx::query( r" INSERT OR REPLACE INTO party_credentials ( + kind, dec_party_id, member_party_id, user_id, @@ -671,9 +697,10 @@ impl Commitable for sqlx::Transaction<'static, sqlx::Sqlite> { auth0_audience, auth0_client_id, auth0_client_secret - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ", ) + .bind(&row.kind) .bind(&row.dec_party_id) .bind(&row.member_party_id) .bind(&row.user_id) @@ -693,6 +720,79 @@ impl Commitable for sqlx::Transaction<'static, sqlx::Sqlite> { Ok(()) } + async fn delete_party_credentials(&mut self, dec_party_id: &CantonId) -> Result { + sqlx::query("DELETE FROM party_credentials WHERE dec_party_id = ?") + .bind(dec_party_id.to_string()) + .execute(&mut **self) + .await?; + + Ok(()) + } + + async fn insert_proposal_decision(&mut self, entry: &ProposalDecisionEntry) -> Result { + let row = ProposalDecisionRow::from_domain(entry)?; + + let result = sqlx::query( + "INSERT OR IGNORE INTO proposal_decisions \ + (proposal_cid, decision, decided_at, pinned_hashes_json) \ + VALUES (?, ?, ?, ?)", + ) + .bind(&row.proposal_cid) + .bind(&row.decision) + .bind(row.decided_at) + .bind(&row.pinned_hashes_json) + .execute(&mut **self) + .await?; + + Ok(result.rows_affected() > 0) + } + + async fn update_proposal_decision( + &mut self, + proposal_cid: &str, + decision: ProposalDecision, + decided_at: i64, + ) -> Result { + sqlx::query( + "UPDATE proposal_decisions SET decision = ?, decided_at = ? WHERE proposal_cid = ?", + ) + .bind(decision.as_str()) + .bind(decided_at) + .bind(proposal_cid) + .execute(&mut **self) + .await?; + + Ok(()) + } + + async fn set_proposal_pinned_hashes( + &mut self, + proposal_cid: &str, + pinned_hashes: &[String], + ) -> Result { + let json = if pinned_hashes.is_empty() { + None + } else { + Some(serde_json::to_string(pinned_hashes).context("encode pinned hashes")?) + }; + sqlx::query("UPDATE proposal_decisions SET pinned_hashes_json = ? WHERE proposal_cid = ?") + .bind(json) + .bind(proposal_cid) + .execute(&mut **self) + .await?; + + Ok(()) + } + + async fn delete_proposal_decision(&mut self, proposal_cid: &str) -> Result { + sqlx::query("DELETE FROM proposal_decisions WHERE proposal_cid = ?") + .bind(proposal_cid) + .execute(&mut **self) + .await?; + + Ok(()) + } + async fn upsert_dec_party(&mut self, row: &DecPartyRow) -> Result { // ON CONFLICT DO UPDATE preserves the dec_party row's identity, so the // ON DELETE CASCADE on dec_party_participant.dec_party_id does NOT fire. @@ -1294,9 +1394,14 @@ mod tests { use crate::{ canton_id::CantonId, - config::{Auth0M2MConfig, KeycloakConfig, PackageConfig, PartyCredentials, Peer}, + config::{ + Auth0M2MConfig, CredentialKind, KeycloakConfig, PackageConfig, PartyCredentials, Peer, + }, db::{ - rows::{DecPartyContractRow, DecPartyParticipantRow, DecPartyRow}, + rows::{ + DecPartyContractRow, DecPartyParticipantRow, DecPartyRow, ProposalDecision, + ProposalDecisionEntry, + }, schema::{Commitable, SchemaRead, SchemaWrite}, }, error::Result, @@ -1325,6 +1430,7 @@ mod tests { fn test_creds(prefix: &str) -> PartyCredentials { PartyCredentials { + kind: CredentialKind::Decparty, dec_party_id: CantonId::parse(&format!("{prefix}::{TEST_NS}")).unwrap(), member_party_id: CantonId::parse(&format!("member::{TEST_NS}")).unwrap(), user_id: "test-user".to_string(), @@ -1591,6 +1697,108 @@ mod tests { Ok(()) } + /// Migration 000020: a row written before the column existed reads back as + /// a decparty row, and a node row keeps its kind across the round trip. + #[sqlx::test(migrator = "MIGRATOR")] + async fn party_credentials_kind_round_trips_and_defaults(pool: SqlitePool) -> Result { + let mut tx = pool.begin_transaction().await?; + tx.upsert_party_credentials(&test_creds("party-a")).await?; + let node = PartyCredentials { + kind: CredentialKind::Node, + dec_party_id: CantonId::parse(&format!("node::{TEST_NS}"))?, + member_party_id: CantonId::parse(&format!("node::{TEST_NS}"))?, + ..test_creds("node") + }; + tx.upsert_party_credentials(&node).await?; + Commitable::commit(tx).await?; + + // A pre-000020 writer never names the column, so the default applies. + sqlx::query( + "INSERT INTO party_credentials \ + (dec_party_id, member_party_id, user_id, keycloak_url, keycloak_realm, \ + keycloak_client_id) VALUES (?, ?, 'u', '', '', '')", + ) + .bind(format!("legacy::{TEST_NS}")) + .bind(format!("member::{TEST_NS}")) + .execute(&pool) + .await?; + + let all = pool.get_all_party_credentials().await?; + let kind_of = |prefix: &str| { + all.iter() + .find(|c| c.dec_party_id.to_string().starts_with(prefix)) + .map(|c| c.kind) + }; + assert_eq!(kind_of("party-a"), Some(CredentialKind::Decparty)); + assert_eq!(kind_of("node"), Some(CredentialKind::Node)); + assert_eq!(kind_of("legacy"), Some(CredentialKind::Decparty)); + + let mut tx = pool.begin_transaction().await?; + tx.delete_party_credentials(&node.dec_party_id).await?; + Commitable::commit(tx).await?; + assert!( + pool.get_party_credentials(&node.dec_party_id) + .await? + .is_none() + ); + + Ok(()) + } + + /// The first decision wins; later writers update in place and pin hashes. + #[sqlx::test(migrator = "MIGRATOR")] + async fn proposal_decisions_first_write_wins_then_updates(pool: SqlitePool) -> Result { + let entry = ProposalDecisionEntry { + proposal_cid: "00proposal".to_string(), + decision: ProposalDecision::Accepted, + decided_at: 100, + pinned_hashes: vec![], + }; + + let mut tx = pool.begin_transaction().await?; + assert!(tx.insert_proposal_decision(&entry).await?); + let racing = ProposalDecisionEntry { + decision: ProposalDecision::Declined, + decided_at: 101, + ..entry.clone() + }; + assert!( + !tx.insert_proposal_decision(&racing).await?, + "a second insert must not replace the first decision" + ); + Commitable::commit(tx).await?; + + let stored = pool + .get_proposal_decision("00proposal") + .await? + .ok_or_else(|| anyhow::anyhow!("decision missing"))?; + assert_eq!(stored, entry); + assert!(pool.get_proposal_decision("other").await?.is_none()); + + let hashes = vec!["1220aa".to_string(), "1220bb".to_string()]; + let mut tx = pool.begin_transaction().await?; + tx.set_proposal_pinned_hashes("00proposal", &hashes).await?; + tx.update_proposal_decision("00proposal", ProposalDecision::Dismissed, 200) + .await?; + Commitable::commit(tx).await?; + + let stored = pool + .get_proposal_decision("00proposal") + .await? + .ok_or_else(|| anyhow::anyhow!("decision missing"))?; + assert_eq!(stored.decision, ProposalDecision::Dismissed); + assert_eq!(stored.decided_at, 200); + assert_eq!(stored.pinned_hashes, hashes); + assert_eq!(pool.get_all_proposal_decisions().await?.len(), 1); + + let mut tx = pool.begin_transaction().await?; + tx.delete_proposal_decision("00proposal").await?; + Commitable::commit(tx).await?; + assert!(pool.get_all_proposal_decisions().await?.is_empty()); + + Ok(()) + } + // ==================================================================== // Decentralized Parties // ==================================================================== diff --git a/crates/decman/src/lib.rs b/crates/decman/src/lib.rs index fa909c319..b888ebfdf 100644 --- a/crates/decman/src/lib.rs +++ b/crates/decman/src/lib.rs @@ -12,6 +12,7 @@ pub mod consts; pub mod db; pub mod error; pub mod noise; +pub mod onledger; // The `dec-party-manager` and `gen-types` binaries, plus the `tests/` integration // suite, are separate crates from this lib and reach into `server::` (e.g. // `server::start_server`, the `gen-types` wire DTOs, `server::GovernanceResponse`), diff --git a/crates/decman/src/onledger/README.md b/crates/decman/src/onledger/README.md new file mode 100644 index 000000000..8b712f59e --- /dev/null +++ b/crates/decman/src/onledger/README.md @@ -0,0 +1,739 @@ +# `onledger`: Canton-native coordination + +This module is the on-ledger foundation of the Noise sunset +(`docs/NOISE_SUNSET_DESIGN.md`, section 4). Part 1 gives one node identity, +one Daml codec, one Ledger API client, the registry, and the proposal +lifecycle. Part 2 adds the topology co-sign primitives, the member +validation rules, the run engine and its per-kind driver contract, the +observer loop, and the signatures of the contracts, DAR, and ACS pieces. +The per-kind step bodies (`engine/{onboarding,...}.rs`) and the bodies of +`submission.rs`, `dars.rs`, `acs.rs` are stubs for the kind agents. + +Every timestamp in this module is an `i64` of **microseconds since the epoch** +(the Ledger API `Timestamp` unit) unless a name or comment says seconds. + +## Files + +| File | Responsibility | +|---|---| +| `mod.rs` | `OnLedger` facade held by `AppState`; `now_micros()`, `now_secs()`. | +| `identity.rs` | `NodeIdentity` from the `kind = 'node'` credentials row; `verify_hosting`. | +| `daml/templates.rs` | Template ids, module paths, choice names, `CoordinationPackage` resolver. | +| `daml/codec.rs` | Rust records for every template and choice argument; `Record` encode/decode. | +| `daml/client.rs` | `CoordinationClient`: create, exercise, and ACS reads as the node party. | +| `registry.rs` | `DecmanNode` publish/update/heartbeat, peer entries, `PeerHealthSnapshot`, version gate. | +| `proposals.rs` | `WorkflowProposal` create/accept/decline/cancel/finish, D6 counting, `pending_invitations` projection, archive sweep. | +| `topology.rs` | Discovery queries, pending/accepted reads, `propose_mapping`, `cosign_by_hash`, waits, root delegations, canonical mapping builders, unsolicited scan. | +| `validation.rs` | `Expectations` and the pure per-kind checks a member runs before it co-signs (design section 5). | +| `engine/mod.rs` | `StartRequest`, `start_run`, `accept_invitation`, `decline_invitation`, `cancel_run`, `retry_run`, `RunMeta`, `TickCtx`, `KindDriver`, row helpers, quorum arithmetic. | +| `engine/{onboarding,add_party,kick,change_threshold,contracts,dars}.rs` | One `KindDriver` per kind with the section-6 step lists. Tick bodies are stubs. | +| `observer.rs` | `spawn_observer`: the polling loop, per-run `try_lock`, metrics. | +| `submission.rs` | Design D7 types and signatures (contracts rounds). Pure helpers and ACS reads implemented; ledger writes are stubs. | +| `dars.rs` | Design D8 types and signatures (DAR pins, embedded coordination DAR). Pure helpers implemented; participant calls are stubs. | +| `acs.rs` | Design D9 types and signatures (spool, manifests, import). Path helpers and the manifest read implemented; the rest are stubs. | + +## Storage + +Migration `000020_node_identity`: + +```sql +ALTER TABLE party_credentials ADD COLUMN kind TEXT NOT NULL DEFAULT 'decparty'; +CREATE TABLE proposal_decisions ( + proposal_cid TEXT PRIMARY KEY NOT NULL, + decision TEXT NOT NULL, -- 'accepted' | 'declined' | 'dismissed' + decided_at INTEGER NOT NULL, -- unix seconds + pinned_hashes_json TEXT -- JSON array of Canton hex hashes, or NULL +); +``` + +Rust side (`config.rs`, `db/rows.rs`, `db/schema.rs`): + +```rust +pub enum CredentialKind { Decparty, Node } // config.rs; serde lowercase; Default = Decparty +pub struct PartyCredentials { pub kind: CredentialKind, /* unchanged fields */ } + +pub enum ProposalDecision { Accepted, Declined, Dismissed } // db/rows.rs +pub struct ProposalDecisionEntry { + pub proposal_cid: String, pub decision: ProposalDecision, + pub decided_at: i64, pub pinned_hashes: Vec, +} + +// SchemaRead (SqlitePool) +async fn get_proposal_decision(&self, proposal_cid: &str) -> Result>; +async fn get_all_proposal_decisions(&self) -> Result>; + +// Commitable (sqlx::Transaction) +async fn delete_party_credentials(&mut self, dec_party_id: &CantonId) -> Result; +async fn insert_proposal_decision(&mut self, entry: &ProposalDecisionEntry) -> Result; // false = row existed, unchanged +async fn update_proposal_decision(&mut self, proposal_cid: &str, decision: ProposalDecision, decided_at: i64) -> Result; +async fn set_proposal_pinned_hashes(&mut self, proposal_cid: &str, pinned_hashes: &[String]) -> Result; +async fn delete_proposal_decision(&mut self, proposal_cid: &str) -> Result; +``` + +`insert_proposal_decision` is `INSERT OR IGNORE`: the first decision wins, so +a racing accept and decline cannot both take effect. + +## Configuration (`consts.rs`) + +```rust +pub const COORDINATION_PACKAGE_REF: &str = "#decman-coordination-v1"; +pub fn coordination_package_ref() -> String; // DECPM_COORDINATION_PACKAGE_REF +pub const COORDINATION_VERSION: i64 = 1; +pub fn heartbeat_interval_secs() -> u64; // DECPM_HEARTBEAT_INTERVAL_SECS, default 3600, min 1 +pub fn heartbeat_min_interval_secs() -> u64; // DECPM_HEARTBEAT_MIN_INTERVAL_SECS, default 60, clamped to [1, interval] +pub fn peer_stale_factor() -> u64; // DECPM_PEER_STALE_FACTOR, default 3, min 1 +pub fn observer_poll_secs() -> u64; // DECPM_OBSERVER_POLL_SECS, default 3, min 1 +pub fn proposal_ttl_secs() -> u64; // DECPM_PROPOSAL_TTL_SECS, default 604800, min 1 +pub const UNSOLICITED_SCAN_INTERVAL_SECS: u64 = 60; // unsolicited scan + archive sweep cadence +pub fn auto_upload_coordination_dar() -> bool; // DECPM_AUTO_UPLOAD_COORDINATION_DAR, default true +pub fn acs_spool_dir(data_dir: &Path) -> PathBuf; // DECPM_ACS_SPOOL_DIR, default {data_dir}/acs +``` + +## `mod.rs` + +```rust +pub fn now_micros() -> i64; +pub fn now_secs() -> i64; + +pub struct OnLedger { /* private */ } +impl OnLedger { + pub async fn new(config: NodeConfig, db: SqlitePool, + auth: Arc>>, + party_credentials: Arc>>, + test_mode: bool) -> Arc; // loads the identity once; logs on failure + pub fn detached(/* same args */) -> Arc; // no identity load + pub fn config(&self) -> &NodeConfig; + pub fn db(&self) -> &SqlitePool; + pub fn package(&self) -> &CoordinationPackage; + pub fn test_mode(&self) -> bool; + pub async fn reload_identity(&self) -> Result>; // call after reload_auth + pub async fn identity(&self) -> Option; + pub async fn require_identity(&self) -> Result; + pub async fn client(&self) -> Result; + pub fn registry(&self) -> Arc>; + pub async fn registry_snapshot(&self) -> PeerHealthSnapshot; + pub async fn vetted_peers(&self) -> Result>; + pub async fn refresh_registry(&self) -> Result; // observer tick + pub async fn publish_registry_entry(&self) -> Result; // observer tick / startup task + pub async fn heartbeat_if_due(&self) -> Result>; // observer tick + pub async fn read_registry(&self) -> Result; // GET /registry + + // Part 2 caches, replaced by the observer loop + pub async fn pending_invitations(&self) -> Vec; // GET /invitations + pub async fn set_pending_invitations(&self, list: Vec); + pub async fn remove_pending_invitation(&self, proposal_cid: &str); + pub async fn unsolicited_proposals(&self) -> Vec; // GET /proposals/unsolicited + pub async fn set_unsolicited(&self, list: Vec); + pub async fn run_lock(&self, instance_name: &str) -> Arc>; // try_lock per run + pub async fn prune_run_locks(&self, live: &HashSet); +} + +// Re-exports at the module root +pub use engine::{AcceptedInvitation, KindDriver, MemberVariant, PreflightRejected, RunMeta, StartRequest, + StartedRun, TickCtx, accept_invitation, cancel_run, decline_invitation, retry_run, start_run}; +pub use observer::spawn_observer; +pub use topology::UnsolicitedProposal; +``` + +`AppState.onledger: Arc` is constructed in `start_server` after +the auth registry, with the same `auth` and `party_credentials` `Arc`s, so a +credentials change followed by `reload_identity()` is visible at once. Tests +use `OnLedger::placeholder()` (`#[cfg(test)]`). + +`start_server` spawns the observer right after the reward automation task: +`crate::onledger::spawn_observer(app_state.onledger.clone())`. The loop +idles until a node identity exists. + +## `identity.rs` + +```rust +pub enum NodeTokenSource { Keycloak(Arc), Mock(Arc) } +impl NodeTokenSource { pub async fn token(&self) -> Result; } + +#[derive(Clone)] +pub struct NodeIdentity { pub node_party: CantonId, pub participant_id: CantonId, pub user_id: String, /* tokens */ } +impl NodeIdentity { + pub fn new(node_party: CantonId, participant_id: CantonId, user_id: String, tokens: NodeTokenSource) -> Self; + pub async fn token(&self) -> Result; + pub fn tokens(&self) -> &NodeTokenSource; +} + +pub fn node_credentials(rows: &[PartyCredentials]) -> Option<&PartyCredentials>; // the kind == Node row +pub async fn load_node_identity(config: &NodeConfig, rows: &[PartyCredentials], + auth: Option<&WorkflowAuth>) -> Result>; +pub fn require_node_identity(identity: Option<&NodeIdentity>) -> Result<&NodeIdentity>; + +pub struct HostingCheck { + pub mapping_exists: bool, pub hosted: bool, pub permission: Option, + pub onboarding: bool, pub threshold: u32, +} +impl HostingCheck { pub fn has_submission(&self) -> bool; pub fn describe(&self) -> String; } +pub fn hosting_check_from_mapping(mapping: Option<&PartyToParticipant>, participant: &CantonId) -> HostingCheck; // pure +pub async fn verify_hosting(config: &NodeConfig, party: &CantonId, participant: &CantonId) -> Result; +``` + +`verify_hosting` reads the synchronizer head state over the tokenless Admin +API through `workflow::external_party::add_hosts::read_party_to_participant`, +which already requires `mapping.party == party`. `has_submission()` is the +one trust predicate of design D2: use it before naming a party as observer, +before counting an acceptance, and before accepting a node identity. + +## `daml/templates.rs` + +```rust +pub const PACKAGE_KEY: &str = "decman_coordination"; +pub struct CoordinationPackage(/* ref */); +impl CoordinationPackage { + pub fn from_env() -> Self; // consts::coordination_package_ref() + pub fn new(package_ref: impl Into) -> Self; + pub fn reference(&self) -> &str; // "#decman-coordination-v1" + pub fn package_name(&self) -> &str; // "decman-coordination-v1" +} +impl PackageResolver for CoordinationPackage; // answers only PACKAGE_KEY + +pub enum CoordinationTemplate { DecmanNode, WorkflowProposal, WorkflowAcceptance, WorkflowDecline, + WorkflowOutcome, SubmissionRound, SubmissionSignature, AcsManifest } +impl CoordinationTemplate { + pub fn module(self) -> &'static str; + pub fn entity(self) -> &'static str; + pub fn identifier(self, package: &CoordinationPackage) -> Identifier; + pub fn matches(self, id: &Identifier) -> bool; // module + entity only +} +impl TemplateInfo for CoordinationTemplate; + +pub mod modules { TYPES, NODE, WORKFLOW, SUBMISSION, ACS } // "Decman.Coordination.*" +pub mod choices { DECMAN_NODE_HEARTBEAT, DECMAN_NODE_UPDATE, DECMAN_NODE_RETIRE, + WORKFLOW_PROPOSAL_ACCEPT, WORKFLOW_PROPOSAL_DECLINE, WORKFLOW_PROPOSAL_CANCEL, + WORKFLOW_PROPOSAL_FINISH, WORKFLOW_ACCEPTANCE_ARCHIVE, WORKFLOW_DECLINE_ARCHIVE, + WORKFLOW_OUTCOME_ARCHIVE, SUBMISSION_ROUND_SIGN, SUBMISSION_ROUND_CLOSE, + SUBMISSION_SIGNATURE_ARCHIVE, ACS_MANIFEST_ARCHIVE } +``` + +## `daml/codec.rs` + +```rust +pub use common::types::WorkflowKind; // Onboarding | Kick | Contracts | Dars | AddParty | ChangeThreshold + +pub trait TemplateRecord: Sized { + const TEMPLATE: CoordinationTemplate; + fn to_record(&self) -> Record; + fn from_record(rec: &Record) -> anyhow::Result; + fn to_value(&self) -> Value; +} +pub trait ChoiceArgument { fn to_record(&self) -> Record; fn to_value(&self) -> Value; } +pub fn unit_argument() -> Value; // for choices without arguments + +pub struct DarPin { filename, sha256_hex, main_package_id, size_bytes: i64 } + +// Template records (fields in Daml order; Option<_> for Optional; i64 micros for Time) +pub struct DecmanNodeRecord { node: CantonId, participant_id: String, display_name, version, build_version, + coordination_version: i64, peers: Vec, last_active_at: i64, heartbeat_interval_secs: i64, + min_heartbeat_interval_secs: i64 } +pub struct WorkflowProposalRecord { proposer, proposer_participant, proposer_namespace_fingerprint: Option, + proposer_signing_public_key_hex: Option, proposer_daml_key_fingerprint: Option, run_id, + kind: WorkflowKind, invitees: Vec, participants: Vec, dec_party_id: Option, + prefix: Option, threshold: Option, previous_threshold: Option, dnd_base_serial: Option, + p2p_base_serial: Option, new_participant: Option, kicked_participant: Option, + dar_pins: Vec, package_names: Vec, description, created_at: i64, expires_at: i64 } +pub struct WorkflowAcceptanceRecord { proposal: String /* cid */, proposer, acceptor, observers, run_id, + participant_id, namespace_fingerprint, signing_public_key_hex, daml_key_fingerprint, + member_party: Option, accepted_at } +pub struct WorkflowDeclineRecord { proposal, proposer, decliner, observers, run_id, reason, declined_at } +pub struct WorkflowOutcomeRecord { proposer, run_id, kind, observers, succeeded: bool, error: Option, finished_at } +pub struct SubmissionRoundRecord { proposer, run_id, index: i64, signers, dec_party_id, act_as, description, + prepared_transaction_hex, prepared_hash_hex, hashing_scheme_version: i64, preparation_time, max_record_time, deadline } +pub struct SubmissionSignatureRecord { round: String /* cid */, proposer, signer, observers, run_id, index, + participant_id, signed_by, signature_hex, format, algorithm, signed_at } +pub struct AcsManifestRecord { exporter, exporter_participant, observers, dec_party_id, target_participant, + activation_serial: i64, size_bytes: i64, sha256_hex, package_ids: Vec, exported_at } + +// Choice arguments +pub struct DecmanNodeUpdateArgs { new_peers, new_display_name, new_version, new_build_version, + new_coordination_version, new_heartbeat_interval_secs, new_min_heartbeat_interval_secs } +impl DecmanNodeUpdateArgs { pub fn from_desired(desired: &DecmanNodeRecord) -> Self; } +pub struct AcceptArgs { acceptor: CantonId, participant_id, namespace_fingerprint, signing_public_key_hex, + daml_key_fingerprint, member_party: Option } +pub struct DeclineArgs { decliner: CantonId, reason } +pub struct FinishArgs { succeeded: bool, error: Option } +pub struct SignArgs { signer: CantonId, participant_id, signed_by, signature_hex, format, algorithm } +pub struct CloseArgs { result } +``` + +Decoders are strict: a missing field, a wrong shape, or an unknown enum +constructor is an error. Every template record is unit-tested for an +encode-decode round trip with all `Optional` fields `None` and `Some`. + +## `daml/client.rs` + +```rust +pub struct ExerciseOutcome { pub created_contract_id: Option, pub update_id: String } +pub struct ActiveContract { pub contract_id: String, pub offset: i64, pub record: T } + +#[derive(Clone)] +pub struct CoordinationClient { /* config, identity, package, test_mode */ } +impl CoordinationClient { + pub fn new(config: NodeConfig, identity: NodeIdentity, package: CoordinationPackage, test_mode: bool) -> Self; + pub fn node_party(&self) -> &CantonId; + pub fn participant_id(&self) -> &CantonId; + pub fn identity(&self) -> &NodeIdentity; + pub fn package(&self) -> &CoordinationPackage; + pub fn config(&self) -> &NodeConfig; + pub fn test_mode(&self) -> bool; + pub async fn create(&self, record: &T) -> Result; // -> contract id + pub async fn create_record(&self, template: CoordinationTemplate, arguments: Record) -> Result; + pub async fn exercise(&self, template: CoordinationTemplate, contract_id: &str, choice: &str, + argument: Value) -> Result; + pub async fn for_each_active(&self, template: CoordinationTemplate, visit: F) -> Result<()> + where F: FnMut(&str, i64, &Record) -> Result<()>; + pub async fn list_active(&self) -> Result>>; // skips undecodable rows with a warning +} +``` + +Writes use `commands_envelope(uuid, node_party, node_party, cmds, [])` and +`CommandService.submit_and_wait_for_transaction`; `created_contract_id` is +the first created event, so a choice that returns a `ContractId` reports the +new contract and a choice that returns `()` reports `None`. Reads use +`server::reward_automation::for_each_active_created` as the node party +(wildcard filter plus in-memory match in test mode). + +## `registry.rs` + +```rust +pub fn desired_node_record(config: &NodeConfig, identity: &NodeIdentity, peers: &[Peer], + vetted_peers: &HashSet, last_active_at: i64) -> DecmanNodeRecord; +pub fn needs_update(current: &DecmanNodeRecord, desired: &DecmanNodeRecord) -> bool; // ignores lastActiveAt +pub fn heartbeat_due(last_active_at: i64, heartbeat_interval_secs: i64, now: i64) -> bool; +pub fn is_stale(last_active_at: i64, heartbeat_interval_secs: i64, now: i64, factor: u64) -> bool; + +pub async fn coordination_package_ids(config: &NodeConfig, package_name: &str) -> Result>; +pub async fn fetch_vetted_packages_for(config: &NodeConfig, participant_id: &CantonId) -> Result>; +pub async fn participant_has_vetted(config: &NodeConfig, participant_id: &CantonId, + coordination_ids: &HashSet) -> Result; +pub async fn vetted_peers(config: &NodeConfig, package_name: &str, self_participant: &CantonId, + peers: &[Peer]) -> Result>; + +pub enum PublishOutcome { Created(String), Updated(String), Unchanged(String) } +impl PublishOutcome { pub fn contract_id(&self) -> &str; } +pub async fn read_own_entry(client: &CoordinationClient) -> Result>>; +pub async fn publish_or_update(client: &CoordinationClient, desired: &DecmanNodeRecord) -> Result; +pub async fn heartbeat_if_due(client: &CoordinationClient, current: &ActiveContract, + now: i64) -> Result>; +pub async fn retire(client: &CoordinationClient) -> Result>; + +pub struct PeerEntry { pub contract_id: String, pub offset: i64, pub record: DecmanNodeRecord, + pub hosting: Option } +impl PeerEntry { pub fn hosting_verified(&self) -> bool; pub fn participant(&self) -> Option; } +pub async fn read_peer_entries(client: &CoordinationClient, config: &NodeConfig) -> Result>; + +pub struct PeerHealth { pub node_party: Option, pub version: Option, pub build_version: Option, + pub coordination_version: Option, pub last_active_at: Option, pub heartbeat_interval_secs: Option, + pub vetted: bool, pub status: PeerHealthStatus } +#[derive(Default)] +pub struct PeerHealthSnapshot { pub entries: HashMap, pub refreshed_at: Option } +impl PeerHealthSnapshot { pub fn get(&self, participant: &CantonId) -> Option<&PeerHealth>; } +pub fn build_snapshot(peers: &[Peer], self_participant: &CantonId, entries: &[PeerEntry], vetted: &HashSet, + now: i64, stale_factor: u64, refreshed_at: i64) -> PeerHealthSnapshot; // pure +pub fn preflight_unready_peers(snapshot: &PeerHealthSnapshot, invitees: &[CantonId]) -> Vec<(CantonId, String)>; // pure + +pub fn view_of(entry: &PeerEntry, now: i64, stale_factor: u64) -> DecmanNodeView; +pub fn registry_response(own: Option<&ActiveContract>, entries: &[PeerEntry], peers: &[Peer], + now: i64, stale_factor: u64) -> RegistryResponse; +``` + +Keying rule: an entry counts for a configured peer only when its signatory +(`record.node`) equals `peers.party`, its `participantId` claim equals +`peers.participant_id`, and `verify_hosting` reported Submission. Statuses: +`Active`, `Stale` (age > factor x interval), `Unknown` (vetted, no matching +entry), `Unvetted` (participant has not vetted the coordination package). +`preflight_unready_peers` is the design D3 version gate; the 409 text of a +start handler should join its reasons. + +## `proposals.rs` + +```rust +pub type ActiveProposal = ActiveContract; +pub type Acceptance = ActiveContract; +pub type Decline = ActiveContract; +pub type Outcome = ActiveContract; + +pub fn proposal_lifetime(now: i64) -> (i64, i64); // (createdAt, expiresAt = now + DECPM_PROPOSAL_TTL_SECS) + +pub async fn create_proposal(client: &CoordinationClient, record: &WorkflowProposalRecord) -> Result; +pub async fn cancel(client: &CoordinationClient, proposal_cid: &str) -> Result<()>; +pub async fn finish(client: &CoordinationClient, proposal_cid: &str, succeeded: bool, error: Option) -> Result; +pub async fn accept(client: &CoordinationClient, proposal_cid: &str, args: &AcceptArgs) -> Result; +pub async fn decline(client: &CoordinationClient, proposal_cid: &str, reason: &str) -> Result; + +pub async fn read_active_proposals_for_me(client: &CoordinationClient) -> Result>; // invitee, not proposer +pub async fn read_my_proposals(client: &CoordinationClient) -> Result>; // proposer == node party +pub async fn read_proposal(client: &CoordinationClient, proposal_cid: &str) -> Result>; // D5 step 5 re-read +pub async fn read_acceptances(client: &CoordinationClient, proposal_cid: &str) -> Result>; // raw +pub async fn read_declines(client: &CoordinationClient, proposal_cid: &str) -> Result>; +pub async fn read_outcomes(client: &CoordinationClient) -> Result>; + +pub fn counted_acceptances(proposal: &ActiveProposal, acceptances: &[Acceptance], + hosted: &dyn Fn(&CantonId, &CantonId) -> bool) -> Result>; // pure, D6 +pub async fn counted_acceptances_verified(config: &NodeConfig, proposal: &ActiveProposal, + acceptances: &[Acceptance]) -> Result>; + +pub fn invitation_from_proposal(proposal: &ActiveProposal, peers: &[Peer], received_at: i64) -> PendingInvitation; +pub fn plan_projection(proposals: &[ActiveProposal], decisions: &[ProposalDecisionEntry], existing: &[PendingInvitation], + peers: &[Peer], now_micros: i64) -> (Vec, Vec); // (upserts, deletes) +pub async fn project_pending_invitations(db: &SqlitePool, proposals: &[ActiveProposal], decisions: &[ProposalDecisionEntry], + peers: &[Peer], now_micros: i64) -> Result>; + +pub struct ArchiveSweepReport { pub acceptances: usize, pub declines: usize } +pub async fn archive_sweep(client: &CoordinationClient, active_proposal_cids: &HashSet) -> Result; +``` + +`counted_acceptances` keeps an acceptance only when `proposal == cid`, +`proposer` matches, the acceptor is an invitee and not the proposer, +`participantId` is in `participants`, and `hosted(acceptor, participant)` +holds. A second counted acceptance from one acceptor is an error (fail +closed). `project_pending_invitations` returns the whole table afterwards; +the caller replaces `AppState.pending_invitations` with it. + +Transitional rule: until migration `000021` renames the column, an on-ledger +`pending_invitations` row stores the proposer's participant id in +`coordinator_pubkey`, and `id` is the proposal contract id. Rows whose +`coordinator_pubkey` is not a Canton id are Noise rows and are left alone. + +## HTTP surface (`server/handlers/node_identity.rs`) + +| Route | Auth | Body / response | +|---|---|---| +| `GET /node-identity` | admin | `NodeIdentityResponse { configured, node_party_id?, participant_id, user_id?, hosting_permission? }` | +| `PUT /node-identity` | admin; unauthenticated only while `party_credentials` is empty | `NodeIdentityRequest` -> `NodeIdentityResponse`; 400 unless this participant hosts the party with Submission | +| `GET /registry` | admin | `RegistryResponse { self_entry?, peers, inbound }`; 409 without a node identity | + +DTOs live in `crates/common/src/coordination.rs` and are exported by +`gen-types`: `NodeIdentityRequest`, `NodeIdentityResponse`, `PeerHealthStatus` +(`Active | Stale | Unknown | Unvetted`), `DecmanNodeView`, `RegistryResponse`. + +Side effects of the PUT: the row is written with `kind = 'node'` and the node +party in both `dec_party_id` and `member_party_id`; any older node row is +deleted; `reload_auth` rebuilds the registry; `OnLedger::reload_identity` +picks up the token manager. `GET /auth/status` and the inbound JWT trust set +(`find_trusted`) skip `kind = 'node'` rows. + +## `topology.rs` + +Every read targets the physical synchronizer store with +`time_query = Snapshot(9999-12-31T23:59:59.999999Z)` and +`operation = ADD_REPLACE`. `HeadState` hides a fresh proposal until the node +observes a sequencer timestamp past `valid_from`; "effective" is therefore +`valid_from <= now` on the local clock. + +```rust +pub const HASH_PURPOSE_TOPOLOGY_TRANSACTION_SIGNATURE: i32 = 11; +pub const HASH_PURPOSE_DECENTRALIZED_NAMESPACE: i32 = 37; + +pub fn max_timestamp() -> Timestamp; // Daml-LF max +pub fn proposals_query(sync_id: &str) -> BaseQuery; // proposals = true +pub fn accepted_query(sync_id: &str) -> BaseQuery; // proposals = false +pub fn timestamp_micros(ts: &Timestamp) -> i64; +pub fn is_effective(valid_from: Option<&Timestamp>, now: i64) -> bool; + +pub struct PendingProposal { hash_hex: String, serial: u32, signed_by: Vec, operation: i32, + mapping: M, sequenced: Option, valid_from: Option } +impl PendingProposal { pub fn is_add_replace(&self) -> bool; pub fn is_signed_by(&self, fp: &str) -> bool; } +pub struct AcceptedMapping { serial: u32, mapping: M, valid_from: Option } +pub fn hash_hex_of(context: &BaseResult) -> String; // lowercase hex of transaction_hash + +pub async fn list_pending_dnd(config, sync_id, namespace: &str) -> Result>>; +pub async fn list_pending_p2p(config, sync_id, party: &CantonId) -> Result>>; +pub async fn read_accepted_dnd(config, sync_id, namespace: &str) -> Result>>; +pub async fn read_accepted_p2p(config, sync_id, party: &CantonId) -> Result>>; + +pub fn transaction_hash_of(versioned_transaction: &[u8]) -> String; // multihash sha256(be32(11) || bytes) +pub fn compute_namespace<'a>(owners: impl IntoIterator) -> String; // sorted, deduped + +pub struct ProposedTx { hash_hex: String, transaction: SignedTopologyTransaction } +pub async fn propose_mapping(config, sync_id, mapping: TopologyMapping, serial: u32) -> Result; +pub enum CosignOutcome { NotFound, Signed, AlreadySigned } +pub async fn cosign_by_hash(config, sync_id, hash_hex: &str, previously_signed_by: &[String]) -> Result; + +pub struct WaitBudget { max_attempts: usize, delay: Duration } // Default = DECPM_TOPOLOGY_RETRY_* +pub enum MappingKey { Dnd(String), P2p(CantonId) } +pub struct AcceptedState { serial: u32, valid_from: Option } +pub async fn read_accepted_state(config, sync_id, key: &MappingKey) -> Result>; +pub async fn wait_effective(config, sync_id, key: &MappingKey, serial: u32, budget: WaitBudget) -> Result; + +pub async fn read_root_delegation(config, sync_id, fp: &str) -> Result)>>; +pub async fn read_root_delegation_key(config, sync_id, fp: &str) -> Result; // asserts fingerprint +pub async fn wait_owner_root_delegations(config, sync_id, fps: &[String], budget: WaitBudget) -> Result>; + +pub struct UnsolicitedProposal { mapping: String, key: String, hash_hex: String, serial: u32, signed_by: Vec, sequenced_at: Option } +pub async fn scan_unsolicited(config, sync_id) -> Result>; + +// Builders: owners sorted, hosts sorted by uid, keys sorted by fingerprint, one per key +pub fn dnd_of(m: &TopologyMapping) -> Option<&DecentralizedNamespaceDefinition>; +pub fn p2p_of(m: &TopologyMapping) -> Option<&PartyToParticipant>; +pub fn build_dnd(owners: &[String], threshold: u32) -> TopologyMapping; +pub fn build_bootstrap_p2p(prefix: &str, namespace: &str, hosts: &[CantonId], keys: &[SigningPublicKey], threshold: u32) -> TopologyMapping; +pub fn build_add_party_dnd(head: &DecentralizedNamespaceDefinition, joiner_fp: &str, threshold: u32) -> TopologyMapping; +pub fn build_add_party_p2p(head: &PartyToParticipant, joiner: &CantonId, joiner_key: &SigningPublicKey, threshold: u32) -> TopologyMapping; +pub fn build_kick_dnd(head: &DecentralizedNamespaceDefinition, kicked_fp: &str, threshold: u32) -> TopologyMapping; +pub fn build_kick_p2p(head: &PartyToParticipant, kicked: &CantonId, kicked_key_fp: &str, threshold: u32) -> TopologyMapping; +pub fn build_change_threshold_dnd(head: &DecentralizedNamespaceDefinition, threshold: u32) -> TopologyMapping; +pub fn build_change_threshold_p2p(head: &PartyToParticipant, threshold: u32) -> TopologyMapping; +pub fn mapping_bytes(m: &TopologyMapping) -> Vec; +``` + +`propose_mapping` and `cosign_by_hash` go through +`workflow::topology::authorize_with_topology_retry` with +`must_fully_authorize = false`, `signed_by = []`, no force flags, and the +synchronizer store. `cosign_by_hash` maps `TOPOLOGY_TRANSACTION_NOT_FOUND` +to `NotFound` (retry next poll). `UnsolicitedProposal` belongs in +`common/src/coordination.rs` (TODO in the source); it lives here until that +file is open for edits. The repository holds no external vector for the +topology hash; the unit test pins the construction +`sha256(be32(11) || bytes)` against an independently computed value. + +## `validation.rs` + +```rust +pub struct ValidationError(pub String); // Display + Error +pub type Check = Result<(), ValidationError>; + +pub struct HostTuple { uid: String, permission: i32, onboarding: bool } +pub fn host_tuples(m: &PartyToParticipant) -> BTreeSet; +pub fn key_bytes(m: &PartyToParticipant) -> BTreeSet>; // serialized SigningPublicKey +pub fn key_fingerprints(m: &PartyToParticipant) -> BTreeSet; + +pub struct LocalIdentity { participant_id: CantonId, owner_fingerprints: BTreeSet, daml_key_fingerprint: Option } +pub struct KickedMember { participant_id: String, owner_fingerprint: String, signing_key_fingerprint: Option } +pub struct CountedAcceptance { acceptor, participant_id, namespace_fingerprint, signing_public_key_hex, daml_key_fingerprint } +pub struct HeadState { dnd: Option, p2p: Option } + +pub struct Expectations { kind, proposer_participant, proposer_fingerprint: Option, proposer_hosting_ok: bool, + participants, prefix, dec_party_id, threshold: Option, previous_threshold, dnd_base_serial, p2p_base_serial, + new_participant, kicked_participant, accepted: Vec, head: HeadState, + owner_keys: BTreeMap, identity: LocalIdentity, kicked: Option, + survivor_key_claims: BTreeMap } +impl Expectations { + pub fn new(proposal: &WorkflowProposalRecord, counted: &[Acceptance], head: HeadState, identity: LocalIdentity) -> Self; + pub fn with_owner_keys(self, keys) -> Self; pub fn with_kicked(self, kicked) -> Self; + pub fn with_survivor_key_claims(self, claims) -> Self; pub fn with_proposer_hosting(self, ok: bool) -> Self; + pub fn required_threshold(&self) -> Result; + pub fn required_proposer_fingerprint(&self) -> Result<&str, ValidationError>; + pub fn onboarding_owners(&self) -> Result, ValidationError>; + pub fn onboarding_hosts(&self) -> BTreeSet; + pub fn joiner(&self) -> Result<&CountedAcceptance, ValidationError>; +} + +pub struct SerialContext { accepted: Option, base: Option } +pub fn check_common(pending: &PendingProposal, exp: &Expectations, serials: &SerialContext) -> Check; +pub fn check_own_owner(dnd, exp) -> Check; +pub fn check_own_host(p2p, exp, as_joiner: bool) -> Check; +pub fn check_onboarding_dnd(pending, exp) -> Check; pub fn check_onboarding_p2p(pending, exp) -> Check; +pub fn check_add_party_dnd(pending, exp) -> Check; pub fn check_add_party_p2p(pending, exp) -> Check; +pub fn check_kick_dnd(pending, exp) -> Check; pub fn check_kick_p2p(pending, exp) -> Check; // legacy branch inside +pub fn check_change_threshold_dnd(pending, exp) -> Check; pub fn check_change_threshold_p2p(pending, exp) -> Check; +pub fn validate_dnd(pending, exp, accepted_serial: Option) -> Check; // common + kind dispatch, DND base serial +pub fn validate_p2p(pending, exp, accepted_serial: Option) -> Check; // common + kind dispatch, P2P base serial +``` + +Every check fails closed: a missing threshold, proposer fingerprint, head +mapping, cached kicked owner key, or root delegation key is an error. The +caller sets `proposer_hosting_ok` from `verify_hosting(proposer, +proposer_participant).has_submission()` and fills `owner_keys` from +`topology::wait_owner_root_delegations`. The kick P2P rule pins the removed +key to the kicked owner fingerprint on a dual-key party (signing key +fingerprints equal the DND owners) and uses elimination alone on a legacy +party: exactly one key removed, not this node's, not claimed by a survivor. + +## `engine/mod.rs` + +```rust +pub const RUN_META_KEY: &str = "onledger"; // config_json key of RunMeta (until migration 000021) +pub const COMPLETE_STEP: &str = "Complete"; +pub const WAITING_FOR_ACCEPTANCES_STEP: &str = "WaitingForAcceptances"; + +pub enum StartRequest { + Onboarding { party_id_prefix, peer_ids: Vec, threshold: Option, instance_name }, + AddParty { dec_party_id, new_participant_id, new_threshold: i32, previous_threshold: i32, instance_name }, + Kick { dec_party_id, participant_id, new_threshold, previous_threshold, instance_name }, + ChangeThreshold { dec_party_id, new_threshold, previous_threshold, instance_name }, + Contracts { dec_party_id, participant_ids, participant_parties, operator_party, contracts: Vec, instance_name }, + Dars { dar_files: Vec, peer_ids, instance_name }, +} +impl StartRequest { pub fn kind(&self) -> WorkflowKind; pub fn instance_name(&self) -> &str; + pub fn dec_party_id(&self) -> Option<&CantonId>; pub fn threshold(&self) -> Option; + pub fn previous_threshold(&self) -> Option; } // 0 = unknown -> None + +pub struct PreflightRejected { message: String, peers: Vec<(CantonId, String)> } // Error; downcast for 409 +pub struct ProposerKeyMaterial { namespace_fingerprint, signing_public_key_hex, daml_key_fingerprint: Option } +pub struct ProposalExtras { keys: ProposerKeyMaterial, dar_pins: Vec, package_names: Vec } +pub struct StartedRun { instance_name: String, proposal_cid: String } + +pub enum MemberVariant { Joiner, Member } // serde PascalCase +pub struct RunMeta { proposal_cid, coordinator_party: CantonId, coordinator_participant: CantonId, + member_variant: Option, topology_hashes: BTreeMap } +pub fn read_run_meta(run: &WorkflowRun) -> Option; // None = not an on-ledger row +pub async fn write_run_meta(db, instance_name, meta) -> Result<()>; +pub async fn pin_topology_hash(db, instance_name, key: &str /* dnd | p2p | clear */, hash_hex) -> Result<()>; + +pub fn steps_for(kind, role, variant: Option) -> &'static [&'static str]; +pub fn required_owner_signatures(previous_threshold: u32, new_threshold: u32) -> u32; // max(prev, new, 1) +pub fn acceptances_needed(previous_threshold: u32, new_threshold: u32) -> u32; // required - 1 +pub fn needs_every_invitee(kind) -> bool; // !(Kick | ChangeThreshold) + +pub async fn advance_step(db, run: &WorkflowRun, step: &str) -> Result<()>; +pub async fn fail_run(db, run, error: &str) -> Result<()>; +pub async fn cancel_run_row(db, run, reason: &str) -> Result<()>; +pub async fn complete_run(db, run) -> Result<()>; + +pub struct ProposalSnapshot { proposals, acceptances, declines, outcomes, decisions: Vec } +impl ProposalSnapshot { + pub async fn read(client, db) -> Result; + pub fn proposal(&self, cid) -> Option<&ActiveProposal>; pub fn acceptances_for(&self, cid) -> Vec; + pub fn declines_for(&self, cid) -> Vec<&Decline>; pub fn outcome_for(&self, proposer, run_id) -> Option<&Outcome>; + pub fn decision(&self, cid) -> Option<&ProposalDecisionEntry>; pub fn for_me(&self, me) -> Vec; + pub fn active_cids(&self) -> HashSet; +} +pub struct TickCtx<'a> { ol: &'a OnLedger, client: &'a CoordinationClient, identity: &'a NodeIdentity, sync_id: String, + participant_id: CantonId, proposals: &'a ProposalSnapshot, registry: &'a PeerHealthSnapshot, now_micros: i64 } +impl TickCtx<'_> { pub fn db(&self) -> &SqlitePool; pub fn is_expired(&self, p: &ActiveProposal) -> bool; } + +#[allow(async_fn_in_trait)] +pub trait KindDriver { + fn kind() -> WorkflowKind; + fn coordinator_steps() -> &'static [&'static str]; + fn member_steps(variant: Option) -> &'static [&'static str]; + async fn preflight(ol: &OnLedger, req: &StartRequest) -> Result<()>; // default Ok; return PreflightRejected for 409 + async fn prepare(ol: &OnLedger, req: &StartRequest) -> Result; // default empty; key material, pins, package names + async fn tick_coordinator(ctx: &TickCtx<'_>, run: &WorkflowRun, meta: &RunMeta) -> Result<()>; + async fn tick_member(ctx: &TickCtx<'_>, run: &WorkflowRun, meta: &RunMeta) -> Result<()>; +} +pub enum Driven { Skipped, Stopped, Ticked } +pub async fn drive(ctx: &TickCtx<'_>, run: &WorkflowRun) -> Result; // reconcile (D10) then dispatch + +pub async fn start_run(ol: &OnLedger, req: StartRequest) -> Result; +pub struct AcceptedInvitation { instance_name: String, member_variant: Option } +pub async fn accept_invitation(ol, proposal_cid) -> Result; +pub async fn decline_invitation(ol, proposal_cid, reason) -> Result; +pub async fn cancel_run(ol, instance_name) -> Result<()>; +pub async fn retry_run(ol, instance_name) -> Result<()>; +pub fn peer_instance_name(kind, coordinator_participant: &str, run_id: &str) -> String; // peer-{kind}-{16 chars}-{run_id} +pub fn member_variant_for(proposal: &WorkflowProposalRecord, me: &CantonId) -> Option; +pub fn accept_args(identity, keys: &ProposerKeyMaterial, member_party: Option) -> AcceptArgs; +``` + +`start_run` resolves the participants (head P2P hosts for party kinds, +`peer_ids` plus self otherwise), refuses with `PreflightRejected` when a +peer has no node party, is unready (`registry::preflight_unready_peers`), +or a threshold is out of range (section 6), runs the kind's `preflight` and +`prepare`, creates the `WorkflowProposal` with the base serials read from +the accepted DND/P2P, and only then writes the coordinator row at its first +step with `RunMeta`. `accept_invitation` writes `proposal_decisions` +(`INSERT OR IGNORE`, so a race with decline resolves to the first writer), +the peer row at its first step, and drops the card; the observer does the +rest. `drive` applies design D10 before the kind driver: a coordinator fails +its run when the proposal vanished, expired, or a decline breaks the quorum +(all invitees, or `acceptances_needed` for kick and change-threshold) and +exercises `Finish { succeeded = false }`; a peer completes or cancels its +row from the `WorkflowOutcome` when the proposal vanished. + +Transitional rule: `RunMeta` lives in `config_json["onledger"]` and +`coordinator_pubkey` carries the coordinator's participant id. Migration +`000021` moves both to their own columns; the `TODO(migration 000021)` +comments mark every site. + +## `engine/{kind}.rs` + +Each file exports `pub struct ;` implementing `KindDriver`, plus the +step lists as `pub const COORDINATOR_STEPS` and `pub const MEMBER_STEPS` +(add-party also `JOINER_STEPS`). Every `prepare` that needs proposer key +material or DAR pins returns `bail!("not implemented: ...")`, so a start +fails closed until the kind agent lands; `Contracts::prepare` returns the +package names from `contracts[].package_id`. Every tick calls +`engine::not_implemented(run)` (debug log) and returns `Ok(())`. + +## `observer.rs` + +```rust +pub fn spawn_observer(ol: Arc) -> JoinHandle<()>; // registers metrics, spawns run_observer_loop +pub async fn run_observer_loop(ol: Arc); // interval(observer_poll_secs), MissedTickBehavior::Skip +pub struct ObserverState { /* last unsolicited scan */ } +pub struct TickReport { had_identity, runs_seen, runs_driven, runs_busy, runs_stopped, invitations: usize } +pub async fn tick(ol: &OnLedger, state: &mut ObserverState) -> TickReport; // never panics +``` + +Tick order: identity (re-loaded when missing) -> `refresh_registry`, +`publish_registry_entry`, `heartbeat_if_due` -> in-progress runs -> +`ProposalSnapshot::read` -> `project_pending_invitations` into +`OnLedger::set_pending_invitations` -> synchronizer id -> `engine::drive` +for every row with `RunMeta`, concurrently, each under `run_lock().try_lock()` +(busy runs are skipped) -> every `UNSOLICITED_SCAN_INTERVAL_SECS`: +`topology::scan_unsolicited` into `set_unsolicited` and +`proposals::archive_sweep`. Runs are read before the snapshot so a row that +exists always has its proposal in the same tick. + +Metrics: `decman_observer_tick_total`, `decman_observer_tick_seconds` +(histogram), `decman_observer_last_tick_seconds` (gauge), +`decman_observer_runs_driven_total`, `decman_observer_errors_total{stage}` +(`identity | registry | runs | proposals | projection | synchronizer | drive | +unsolicited | archive`), `decman_observer_no_identity_total`. + +## `submission.rs` (design D7) + +```rust +pub const MAX_RECORD_TIME_HORIZON_MICROS: i64; // 20 h +pub const DEADLINE_SAFETY_MARGIN_MICROS: i64; // 30 min +pub struct PreparedRound { index, description, prepared_transaction_hex, prepared_hash_hex, hashing_scheme_version, preparation_time, max_record_time, deadline } +pub struct VerifiedSignature { signed_by, signature: Vec, format, algorithm, participant_id } +pub fn deadline_for(preparation_time, max_record_time, tolerance_micros) -> i64; // implemented +pub async fn read_record_time_tolerance(config, sync_id) -> Result; // stub +pub async fn prepare_rounds(ol, run, dec_party_id, contracts) -> Result>; // stub +pub async fn open_rounds(client, run_id, signers, dec_party_id, act_as, rounds) -> Result>; // stub +pub async fn read_rounds_for_run(client, run_id) -> Result>>; // implemented +pub async fn read_signatures_for_round(client, round_cid) -> Result>>; // implemented +pub fn verify_signature(head_p2p, sig, hash) -> Result; // stub +pub fn dedupe_verified(sigs) -> BTreeMap; // implemented +pub async fn execute_round(ol, dec_party_id, round, signatures) -> Result; // stub +pub async fn close_round(client, round_cid, result) -> Result<()>; // implemented +pub fn check_round(round, accepted, head_p2p, own_key_fp, now_micros) -> Result<()>; // stub (member rules) +pub async fn sign_round(ol, round, dec_party_id) -> Result; // stub +pub async fn archive_own_signatures(client, active_round_cids) -> Result; // implemented +``` + +## `dars.rs` (design D8) + +```rust +pub const COORDINATION_DAR_FILENAME: &str; +pub fn embedded_coordination_dar() -> &'static [u8]; // include_bytes! of releases/v1/decman-coordination-v1-0.1.0.dar +pub fn hash_dar(bytes) -> String; // lowercase hex sha256 +pub fn decode_dar_files(files: &[DarFile]) -> Result)>>; +pub fn pin_dar_files(files, main_package_ids: &BTreeMap) -> Result>; +pub fn matching_pin<'a>(pins: &'a [DarPin], bytes) -> Option<&'a DarPin>; +pub async fn upload_and_vet_locally(config, filename, bytes, expected_main_package_id: Option<&str>) -> Result; // stub +pub async fn unvetted_pins_by_participant(config, participants, pins) -> Result>>; // stub +pub struct CoordinationDarState { uploaded, vetted, attempts, last_error } +pub async fn ensure_coordination_dar(config) -> Result; // stub +``` + +## `acs.rs` (design D9) + +```rust +pub struct SpoolFile { path, size_bytes, sha256_hex, package_ids } +pub fn spool_dir(config) -> PathBuf; // consts::acs_spool_dir +pub fn spool_path(config, party, target, activation_serial) -> PathBuf; // {prefix}-{namespace}-{target prefix}-{serial}.acs.gz +pub async fn capture_export_offset(db, config, run, party, joiner, base_serial) -> Result; // stub +pub async fn export_snapshot(config, party, target, begin_offset_exclusive, path) -> Result; // stub +pub async fn publish_manifest(client, observers, party, target, activation_serial, file) -> Result; // stub +pub async fn read_manifests(client, party) -> Result>>; // implemented +pub fn verify_manifest(manifest, exporter_hosting, head_p2p, peers, joiner, activation_serial) -> Result<()>; // stub (D9 rules 1-4) +pub async fn import_snapshot(config, db, party, manifest, path) -> Result<()>; // stub +pub async fn clear_onboarding_flag(config, party, pre_activation_offset) -> Result<()>; // stub +pub async fn cleanup_spool(config, party, target) -> Result; // stub +``` + +## Verification + +``` +DECMAN_SKIP_FRONTEND=1 cargo check -p decman --tests +DECMAN_SKIP_FRONTEND=1 cargo test -p decman --lib onledger +DECMAN_SKIP_FRONTEND=1 cargo clippy -p decman --all-targets --all-features --no-deps -- -D warnings +cargo fmt -p decman -- --check +``` diff --git a/crates/decman/src/onledger/acs.rs b/crates/decman/src/onledger/acs.rs new file mode 100644 index 000000000..9d063188f --- /dev/null +++ b/crates/decman/src/onledger/acs.rs @@ -0,0 +1,205 @@ +//! ACS synchronization for add-party (design D9): offline replication with +//! an operator-moved file and an on-ledger `AcsManifest`. +//! +//! Every current host captures its export offset as the first action of +//! `CoSignChanges`. When the P2P that marks the joiner `Onboarding` becomes +//! effective, each host exports the snapshot to the spool directory and +//! publishes an `AcsManifest`. The joiner verifies the manifest, imports the +//! file through the existing disconnect / `ImportPartyAcs` / reconnect +//! bracket, and clears its onboarding flag. +//! +//! Path helpers and the manifest read are implemented; every export, import, +//! and topology step is a stub the ACS agent fills in. + +use std::path::{Path, PathBuf}; + +use anyhow::{Result, bail}; +use canton_proto_rs::com::digitalasset::canton::protocol::v30::PartyToParticipant; +use common::{canton_id::CantonId, types::WorkflowRun}; +use sqlx::SqlitePool; + +use crate::{ + config::{NodeConfig, Peer}, + consts, +}; + +use super::{ + daml::{ActiveContract, CoordinationClient, codec::AcsManifestRecord}, + identity::HostingCheck, +}; + +/// A snapshot file in the spool directory. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SpoolFile { + pub path: PathBuf, + pub size_bytes: i64, + pub sha256_hex: String, + /// Package ids of the contracts in the snapshot; the joiner vets them + /// before the import. + pub package_ids: Vec, +} + +/// The spool directory (`DECPM_ACS_SPOOL_DIR`, default `{data}/acs`). +pub fn spool_dir(config: &NodeConfig) -> PathBuf { + consts::acs_spool_dir(&config.data_dir()) +} + +/// Where the snapshot of `party` for `target` at `activation_serial` lives. +pub fn spool_path( + config: &NodeConfig, + party: &CantonId, + target: &CantonId, + activation_serial: u32, +) -> PathBuf { + spool_dir(config).join(format!( + "{}-{}-{}-{activation_serial}.acs.gz", + party.prefix, + party.namespace.to_hex(), + target.prefix + )) +} + +/// Capture this host's export offset once, keyed by party, joiner, and base +/// serial (`capture_offset_once`), before its `Authorize`. Returns the +/// offset in force. +/// +/// # Errors +/// Returns an error when the ledger end cannot be read. +pub async fn capture_export_offset( + _db: &SqlitePool, + _config: &NodeConfig, + _run: &WorkflowRun, + _party: &CantonId, + _joiner: &CantonId, + _base_serial: u32, +) -> Result { + bail!("not implemented: export offset capture on co-sign (design D9)") +} + +/// Export the party's ACS for `target` from `begin_offset_exclusive` into +/// `path`, gzip-compressed, and describe the file. +/// +/// # Errors +/// Returns an error when the export stream fails or the file cannot be +/// written. +pub async fn export_snapshot( + _config: &NodeConfig, + _party: &CantonId, + _target: &CantonId, + _begin_offset_exclusive: i64, + _path: &Path, +) -> Result { + bail!("not implemented: ExportPartyAcs into the spool directory (design D9)") +} + +/// Publish an `AcsManifest` for a spool file. Returns the contract id. +/// +/// # Errors +/// Returns an error when the create fails. +pub async fn publish_manifest( + _client: &CoordinationClient, + _observers: &[CantonId], + _party: &CantonId, + _target: &CantonId, + _activation_serial: u32, + _file: &SpoolFile, +) -> Result { + bail!("not implemented: create AcsManifest (design D9)") +} + +/// Every active `AcsManifest` for `party` visible to this node. +/// +/// # Errors +/// Returns an error when the read fails. +pub async fn read_manifests( + client: &CoordinationClient, + party: &CantonId, +) -> Result>> { + let party = party.to_string(); + Ok(client + .list_active::() + .await? + .into_iter() + .filter(|m| m.record.dec_party_id == party) + .collect()) +} + +/// The joiner's four acceptance rules (design D9): the exporter is hosted +/// on `exporterParticipant` with Submission; that participant is a head host +/// with `onboarding == None`; the exporter is the node party the peers table +/// records for it; `activationSerial` is the earliest serial that marks the +/// joiner `Onboarding` and the head still marks it so. These rules also gate +/// the `sizeBytes == 0` fast path. +/// +/// # Errors +/// Returns an error naming the first rule that fails. +pub fn verify_manifest( + _manifest: &AcsManifestRecord, + _exporter_hosting: &HostingCheck, + _head_p2p: &PartyToParticipant, + _peers: &[Peer], + _joiner: &CantonId, + _activation_serial: u32, +) -> Result<()> { + bail!("not implemented: AcsManifest verification (design D9)") +} + +/// Import a spool file on the joiner: verify size and hash against the +/// manifest, verify every package id is vetted locally, then run the +/// disconnect / `ImportPartyAcs` / reconnect bracket. +/// +/// # Errors +/// Returns an error when a check fails or the import fails. +pub async fn import_snapshot( + _config: &NodeConfig, + _db: &SqlitePool, + _party: &CantonId, + _manifest: &AcsManifestRecord, + _path: &Path, +) -> Result<()> { + bail!("not implemented: verified ImportPartyAcs (design D9)") +} + +/// `ClearPartyOnboardingFlag` on the joiner, polled until `onboarded == +/// true` (design D5: no co-sign round exists for it). +/// +/// # Errors +/// Returns an error when the flag does not clear within the budget. +pub async fn clear_onboarding_flag( + _config: &NodeConfig, + _party: &CantonId, + _pre_activation_offset: i64, +) -> Result<()> { + bail!("not implemented: ClearPartyOnboardingFlag until onboarded (design D9)") +} + +/// Delete the spool files of `party` for `target` once the joiner is +/// observed `onboarded == true` or the run is dismissed. Returns how many +/// files were removed. +/// +/// # Errors +/// Returns an error when the directory cannot be read. +pub async fn cleanup_spool( + _config: &NodeConfig, + _party: &CantonId, + _target: &CantonId, +) -> Result { + bail!("not implemented: spool cleanup (design D9)") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spool_path_names_party_target_and_serial() { + let ns = "1220c4010d6883f367c7f45d55b2449501620130f9b21e96379f17dea455ac7a5892"; + let config = NodeConfig::default(); + let party = CantonId::parse(&format!("cbtc::{ns}")).expect("id"); + let target = CantonId::parse(&format!("participant4::{ns}")).expect("id"); + let path = spool_path(&config, &party, &target, 7); + assert!(path.starts_with(spool_dir(&config))); + let name = path.file_name().and_then(|n| n.to_str()).expect("name"); + assert_eq!(name, format!("cbtc-{ns}-participant4-7.acs.gz")); + } +} diff --git a/crates/decman/src/onledger/daml/client.rs b/crates/decman/src/onledger/daml/client.rs new file mode 100644 index 000000000..eae24a51e --- /dev/null +++ b/crates/decman/src/onledger/daml/client.rs @@ -0,0 +1,246 @@ +//! The Ledger API client bound to the node identity. +//! +//! Every write goes through `CommandService.submit_and_wait_for_transaction` +//! with `act_as = read_as = [node party]`, the same envelope the governance +//! handlers use with the member party. Every read is a decoded ACS walk as +//! the node party, so only contracts that name the node party as signatory +//! or observer are visible, which is the whole visibility model. + +use anyhow::{Context, Result, anyhow}; +use canton_proto_rs::com::daml::ledger::api::v2::{ + Command, CreateCommand, ExerciseCommand, Record, SubmitAndWaitForTransactionRequest, Value, + command, command_service_client::CommandServiceClient, +}; +use common::canton_id::CantonId; +use decman_lib::framework::commands::{commands_envelope, first_created_contract_id}; + +use crate::{ + config::NodeConfig, + server::reward_automation::{ContractFilter, Entity, Module, for_each_active_created}, + utils, +}; + +use super::{ + codec::TemplateRecord, + templates::{CoordinationPackage, CoordinationTemplate}, +}; +use crate::onledger::identity::NodeIdentity; + +/// What one exercise produced. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ExerciseOutcome { + /// The first contract the choice created, when it created one. `None` + /// for choices that return `()` (cancel, retire, archive, close). + pub created_contract_id: Option, + /// The ledger update id, for logs. + pub update_id: String, +} + +/// One active contract of a coordination template, decoded. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ActiveContract { + pub contract_id: String, + /// The created-event offset; orders contracts by creation. + pub offset: i64, + pub record: T, +} + +/// Submits and reads as the node party. +#[derive(Clone)] +pub struct CoordinationClient { + config: NodeConfig, + identity: NodeIdentity, + package: CoordinationPackage, + /// Insecure/test mode: the mock token lacks `TemplateFilter` permission, + /// so ACS reads use a wildcard filter and match in memory. + test_mode: bool, +} + +impl CoordinationClient { + pub fn new( + config: NodeConfig, + identity: NodeIdentity, + package: CoordinationPackage, + test_mode: bool, + ) -> Self { + Self { + config, + identity, + package, + test_mode, + } + } + + pub fn node_party(&self) -> &CantonId { + &self.identity.node_party + } + + pub fn participant_id(&self) -> &CantonId { + &self.identity.participant_id + } + + pub fn identity(&self) -> &NodeIdentity { + &self.identity + } + + pub fn package(&self) -> &CoordinationPackage { + &self.package + } + + pub fn config(&self) -> &NodeConfig { + &self.config + } + + pub fn test_mode(&self) -> bool { + self.test_mode + } + + /// Create a contract from a typed record and return its contract id. + /// + /// # Errors + /// Returns an error when the token cannot be minted, the ledger rejects + /// the command, or the transaction carries no created event. + pub async fn create(&self, record: &T) -> Result { + self.create_record(T::TEMPLATE, record.to_record()).await + } + + /// Create a contract from raw create arguments and return its contract id. + /// + /// # Errors + /// As [`Self::create`]. + pub async fn create_record( + &self, + template: CoordinationTemplate, + arguments: Record, + ) -> Result { + let cmd = Command { + command: Some(command::Command::Create(CreateCommand { + template_id: Some(template.identifier(&self.package)), + create_arguments: Some(arguments), + })), + }; + let outcome = self + .submit(vec![cmd]) + .await + .with_context(|| format!("create {}", template.entity()))?; + outcome.created_contract_id.ok_or_else(|| { + anyhow!( + "create {} committed (update {}) but the transaction carried no created event", + template.entity(), + outcome.update_id + ) + }) + } + + /// Exercise a choice as the node party. + /// + /// # Errors + /// Returns an error when the token cannot be minted or the ledger rejects + /// the command. + pub async fn exercise( + &self, + template: CoordinationTemplate, + contract_id: &str, + choice: &str, + argument: Value, + ) -> Result { + let cmd = Command { + command: Some(command::Command::Exercise(ExerciseCommand { + template_id: Some(template.identifier(&self.package)), + contract_id: contract_id.to_string(), + choice: choice.to_string(), + choice_argument: Some(argument), + })), + }; + self.submit(vec![cmd]) + .await + .with_context(|| format!("exercise {choice} on {contract_id}")) + } + + /// Visit every active contract of `template` visible to the node party, + /// one at a time, as `(contract_id, offset, &Record)`. + /// + /// # Errors + /// Returns an error when the read fails or `visit` returns one. + pub async fn for_each_active(&self, template: CoordinationTemplate, visit: F) -> Result<()> + where + F: FnMut(&str, i64, &Record) -> Result<()>, + { + let token = self.identity.token().await?; + let filter = ContractFilter::template( + self.package.reference(), + Module(template.module()), + Entity(template.entity()), + ); + for_each_active_created( + &self.config, + &self.identity.node_party, + Some(token), + self.test_mode, + filter, + visit, + ) + .await + .with_context(|| format!("read active {}", template.entity())) + } + + /// Every active contract of `T` visible to the node party, decoded. + /// + /// A contract that fails to decode is logged and skipped: one malformed + /// contract from another node must not blind this node to the rest. + /// + /// # Errors + /// Returns an error when the read itself fails. + pub async fn list_active(&self) -> Result>> { + let mut out = Vec::new(); + self.for_each_active(T::TEMPLATE, |cid, offset, rec| { + match T::from_record(rec) { + Ok(record) => out.push(ActiveContract { + contract_id: cid.to_string(), + offset, + record, + }), + Err(e) => tracing::warn!( + template = T::TEMPLATE.entity(), + contract_id = cid, + error = %e, + "skipping a coordination contract that does not decode" + ), + } + Ok(()) + }) + .await?; + Ok(out) + } + + async fn submit(&self, commands: Vec) -> Result { + let token = self.identity.token().await?; + let envelope = commands_envelope( + uuid::Uuid::new_v4().to_string(), + &self.identity.node_party, + &self.identity.node_party, + commands, + vec![], + ); + let channel = self.config.ledger_channel().await?; + let mut client = CommandServiceClient::new(channel) + .max_decoding_message_size(utils::MAX_GRPC_MESSAGE_SIZE); + let mut req = tonic::Request::new(SubmitAndWaitForTransactionRequest { + commands: Some(envelope), + transaction_format: None, + }); + let auth = format!("Bearer {token}") + .parse() + .context("authorization header is not a valid metadata value")?; + req.metadata_mut().insert("authorization", auth); + let response = client + .submit_and_wait_for_transaction(req) + .await? + .into_inner(); + let transaction = response.transaction.as_ref(); + Ok(ExerciseOutcome { + created_contract_id: transaction.and_then(first_created_contract_id), + update_id: transaction.map(|t| t.update_id.clone()).unwrap_or_default(), + }) + } +} diff --git a/crates/decman/src/onledger/daml/codec.rs b/crates/decman/src/onledger/daml/codec.rs new file mode 100644 index 000000000..882a25de2 --- /dev/null +++ b/crates/decman/src/onledger/daml/codec.rs @@ -0,0 +1,1355 @@ +//! Rust mirrors of every `decman-coordination-v1` template and choice +//! argument, with `Record` encode (create and exercise arguments) and decode +//! (verbose ACS reads). +//! +//! Field names and order follow design section 3 exactly. Every `Time` is an +//! `i64` of microseconds since the epoch, the Ledger API `Timestamp` unit. +//! Fingerprints, hashes, keys, and signatures are hex `String`s; the Daml +//! side only bounds their length, so the Rust callers validate hex. +//! +//! The decoders read fields by label, which a `verbose` read supplies. Every +//! decoder is strict: a missing or wrong-shaped field is an error, never a +//! default, because a silently defaulted field could make a node co-sign +//! against the wrong expectation. + +use anyhow::{Context, Result, anyhow}; +use canton_proto_rs::com::daml::ledger::api::v2::{Enum, Optional, Record, Value, value}; +use common::canton_id::CantonId; +use decman_lib::framework::{ + encode::{ + field, make_bool, make_contract_id, make_int64, make_list, make_party, make_record, + make_text, + }, + record::{ + extract_contract_id, extract_int64, extract_party_id, extract_text, field_party_list, + get_field, record_field, + }, +}; + +pub use common::types::WorkflowKind; + +use super::templates::CoordinationTemplate; + +// --------------------------------------------------------------------------- +// Encode helpers. +// TODO(decman-lib): these belong in `decman_lib::framework::encode` next to +// `make_optional_list`; kept private here until the lib grows them. +// --------------------------------------------------------------------------- + +fn make_timestamp(micros: i64) -> Value { + Value { + sum: Some(value::Sum::Timestamp(micros)), + } +} + +fn make_enum(constructor: &str) -> Value { + Value { + sum: Some(value::Sum::Enum(Enum { + enum_id: None, + constructor: constructor.to_string(), + })), + } +} + +fn make_optional(inner: Option) -> Value { + Value { + sum: Some(value::Sum::Optional(Box::new(Optional { + value: inner.map(Box::new), + }))), + } +} + +fn make_optional_text(v: &Option) -> Value { + make_optional(v.as_deref().map(make_text)) +} + +fn make_optional_int64(v: &Option) -> Value { + make_optional(v.map(make_int64)) +} + +fn make_optional_party(v: &Option) -> Value { + make_optional(v.as_ref().map(make_party)) +} + +fn make_text_list(items: &[String]) -> Value { + make_list(items.iter().map(|s| make_text(s)).collect()) +} + +fn make_party_list(items: &[CantonId]) -> Value { + make_list(items.iter().map(make_party).collect()) +} + +/// The empty record a choice without arguments takes. +pub fn unit_argument() -> Value { + make_record(vec![]) +} + +// --------------------------------------------------------------------------- +// Decode helpers (strict). +// TODO(decman-lib): these belong in `decman_lib::framework::record`; kept +// private here until the lib grows them. +// --------------------------------------------------------------------------- + +fn req_text(rec: &Record, label: &str) -> Result { + extract_text(get_field(rec, label)?).with_context(|| format!("field `{label}`")) +} + +fn req_party(rec: &Record, label: &str) -> Result { + extract_party_id(get_field(rec, label)?).with_context(|| format!("field `{label}`")) +} + +fn req_int64(rec: &Record, label: &str) -> Result { + extract_int64(get_field(rec, label)?).with_context(|| format!("field `{label}`")) +} + +fn req_contract_id(rec: &Record, label: &str) -> Result { + extract_contract_id(get_field(rec, label)?).with_context(|| format!("field `{label}`")) +} + +fn req_bool(rec: &Record, label: &str) -> Result { + match record_field(rec, label) { + Some(value::Sum::Bool(b)) => Ok(*b), + _ => Err(anyhow!("field `{label}`: expected a Bool value")), + } +} + +fn req_timestamp(rec: &Record, label: &str) -> Result { + match record_field(rec, label) { + Some(value::Sum::Timestamp(t)) => Ok(*t), + _ => Err(anyhow!("field `{label}`: expected a Time value")), + } +} + +fn req_enum(rec: &Record, label: &str) -> Result { + match record_field(rec, label) { + Some(value::Sum::Enum(e)) => Ok(e.constructor.clone()), + _ => Err(anyhow!("field `{label}`: expected an Enum value")), + } +} + +fn req_list<'a>(rec: &'a Record, label: &str) -> Result<&'a [Value]> { + match record_field(rec, label) { + Some(value::Sum::List(l)) => Ok(&l.elements), + _ => Err(anyhow!("field `{label}`: expected a List value")), + } +} + +fn req_text_list(rec: &Record, label: &str) -> Result> { + req_list(rec, label)? + .iter() + .map(|v| extract_text(v).with_context(|| format!("field `{label}`: element"))) + .collect() +} + +fn req_party_list(rec: &Record, label: &str) -> Result> { + field_party_list(rec, label).with_context(|| format!("field `{label}`")) +} + +fn req_record_list<'a>(rec: &'a Record, label: &str) -> Result> { + req_list(rec, label)? + .iter() + .map(|v| match &v.sum { + Some(value::Sum::Record(r)) => Ok(r), + _ => Err(anyhow!("field `{label}`: element is not a Record")), + }) + .collect() +} + +/// The inner value of an `Optional` field, or `None` for `Optional None`. A +/// missing field or a non-optional shape is an error. +fn opt_value<'a>(rec: &'a Record, label: &str) -> Result> { + match record_field(rec, label) { + Some(value::Sum::Optional(opt)) => Ok(opt.value.as_deref()), + _ => Err(anyhow!("field `{label}`: expected an Optional value")), + } +} + +fn opt_text(rec: &Record, label: &str) -> Result> { + opt_value(rec, label)? + .map(|v| extract_text(v).with_context(|| format!("field `{label}`"))) + .transpose() +} + +fn opt_int64(rec: &Record, label: &str) -> Result> { + opt_value(rec, label)? + .map(|v| extract_int64(v).with_context(|| format!("field `{label}`"))) + .transpose() +} + +fn opt_party(rec: &Record, label: &str) -> Result> { + opt_value(rec, label)? + .map(|v| extract_party_id(v).with_context(|| format!("field `{label}`"))) + .transpose() +} + +fn parse_kind(constructor: &str) -> Result { + constructor + .parse::() + .with_context(|| format!("field `kind`: unknown WorkflowKind `{constructor}`")) +} + +// --------------------------------------------------------------------------- +// Traits +// --------------------------------------------------------------------------- + +/// A template's create arguments: encode for `CreateCommand`, decode from a +/// verbose `CreatedEvent.create_arguments`. +pub trait TemplateRecord: Sized { + /// Which template this record creates. + const TEMPLATE: CoordinationTemplate; + + fn to_record(&self) -> Record; + + /// # Errors + /// Returns an error when a field is missing or has the wrong shape. + fn from_record(rec: &Record) -> Result; + + /// The record wrapped as a `Value`, the shape a choice argument takes. + fn to_value(&self) -> Value { + Value { + sum: Some(value::Sum::Record(self.to_record())), + } + } +} + +/// A choice's arguments: encode for `ExerciseCommand.choice_argument`. +pub trait ChoiceArgument { + fn to_record(&self) -> Record; + + fn to_value(&self) -> Value { + Value { + sum: Some(value::Sum::Record(self.to_record())), + } + } +} + +fn record(fields: Vec) -> Record { + Record { + record_id: None, + fields, + } +} + +// --------------------------------------------------------------------------- +// Decman.Coordination.Types +// --------------------------------------------------------------------------- + +/// `DarPin`: one file of a `Dars` proposal. Bytes travel out of band; the +/// pin is what each invitee verifies the local upload against. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DarPin { + pub filename: String, + pub sha256_hex: String, + pub main_package_id: String, + pub size_bytes: i64, +} + +impl DarPin { + pub fn to_record(&self) -> Record { + record(vec![ + field("filename", make_text(&self.filename)), + field("sha256Hex", make_text(&self.sha256_hex)), + field("mainPackageId", make_text(&self.main_package_id)), + field("sizeBytes", make_int64(self.size_bytes)), + ]) + } + + /// # Errors + /// Returns an error when a field is missing or has the wrong shape. + pub fn from_record(rec: &Record) -> Result { + Ok(Self { + filename: req_text(rec, "filename")?, + sha256_hex: req_text(rec, "sha256Hex")?, + main_package_id: req_text(rec, "mainPackageId")?, + size_bytes: req_int64(rec, "sizeBytes")?, + }) + } + + fn to_value(&self) -> Value { + Value { + sum: Some(value::Sum::Record(self.to_record())), + } + } +} + +// --------------------------------------------------------------------------- +// Decman.Coordination.Node +// --------------------------------------------------------------------------- + +/// `DecmanNode`: a node's self-signed registry entry. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DecmanNodeRecord { + /// Signatory. + pub node: CantonId, + /// Claimed hosting participant; readers cross-check it in topology. + pub participant_id: String, + pub display_name: String, + pub version: String, + pub build_version: String, + pub coordination_version: i64, + /// Observers. + pub peers: Vec, + /// Micros since the epoch. + pub last_active_at: i64, + pub heartbeat_interval_secs: i64, + pub min_heartbeat_interval_secs: i64, +} + +impl TemplateRecord for DecmanNodeRecord { + const TEMPLATE: CoordinationTemplate = CoordinationTemplate::DecmanNode; + + fn to_record(&self) -> Record { + record(vec![ + field("node", make_party(&self.node)), + field("participantId", make_text(&self.participant_id)), + field("displayName", make_text(&self.display_name)), + field("version", make_text(&self.version)), + field("buildVersion", make_text(&self.build_version)), + field("coordinationVersion", make_int64(self.coordination_version)), + field("peers", make_party_list(&self.peers)), + field("lastActiveAt", make_timestamp(self.last_active_at)), + field( + "heartbeatIntervalSecs", + make_int64(self.heartbeat_interval_secs), + ), + field( + "minHeartbeatIntervalSecs", + make_int64(self.min_heartbeat_interval_secs), + ), + ]) + } + + fn from_record(rec: &Record) -> Result { + Ok(Self { + node: req_party(rec, "node")?, + participant_id: req_text(rec, "participantId")?, + display_name: req_text(rec, "displayName")?, + version: req_text(rec, "version")?, + build_version: req_text(rec, "buildVersion")?, + coordination_version: req_int64(rec, "coordinationVersion")?, + peers: req_party_list(rec, "peers")?, + last_active_at: req_timestamp(rec, "lastActiveAt")?, + heartbeat_interval_secs: req_int64(rec, "heartbeatIntervalSecs")?, + min_heartbeat_interval_secs: req_int64(rec, "minHeartbeatIntervalSecs")?, + }) + } +} + +/// Arguments of `DecmanNode_Update`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DecmanNodeUpdateArgs { + pub new_peers: Vec, + pub new_display_name: String, + pub new_version: String, + pub new_build_version: String, + pub new_coordination_version: i64, + pub new_heartbeat_interval_secs: i64, + pub new_min_heartbeat_interval_secs: i64, +} + +impl DecmanNodeUpdateArgs { + /// The update that turns any current entry into `desired`. + pub fn from_desired(desired: &DecmanNodeRecord) -> Self { + Self { + new_peers: desired.peers.clone(), + new_display_name: desired.display_name.clone(), + new_version: desired.version.clone(), + new_build_version: desired.build_version.clone(), + new_coordination_version: desired.coordination_version, + new_heartbeat_interval_secs: desired.heartbeat_interval_secs, + new_min_heartbeat_interval_secs: desired.min_heartbeat_interval_secs, + } + } +} + +impl ChoiceArgument for DecmanNodeUpdateArgs { + fn to_record(&self) -> Record { + record(vec![ + field("newPeers", make_party_list(&self.new_peers)), + field("newDisplayName", make_text(&self.new_display_name)), + field("newVersion", make_text(&self.new_version)), + field("newBuildVersion", make_text(&self.new_build_version)), + field( + "newCoordinationVersion", + make_int64(self.new_coordination_version), + ), + field( + "newHeartbeatIntervalSecs", + make_int64(self.new_heartbeat_interval_secs), + ), + field( + "newMinHeartbeatIntervalSecs", + make_int64(self.new_min_heartbeat_interval_secs), + ), + ]) + } +} + +// --------------------------------------------------------------------------- +// Decman.Coordination.Workflow +// --------------------------------------------------------------------------- + +/// `WorkflowProposal`: the intent and reference set of one run. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WorkflowProposalRecord { + /// Signatory. + pub proposer: CantonId, + pub proposer_participant: String, + pub proposer_namespace_fingerprint: Option, + pub proposer_signing_public_key_hex: Option, + pub proposer_daml_key_fingerprint: Option, + pub run_id: String, + pub kind: WorkflowKind, + /// Observers. + pub invitees: Vec, + /// Participant ids of every member of the run, proposer included. + pub participants: Vec, + pub dec_party_id: Option, + pub prefix: Option, + pub threshold: Option, + pub previous_threshold: Option, + pub dnd_base_serial: Option, + pub p2p_base_serial: Option, + pub new_participant: Option, + pub kicked_participant: Option, + pub dar_pins: Vec, + pub package_names: Vec, + pub description: String, + /// Micros since the epoch. + pub created_at: i64, + /// Micros since the epoch. The template requires `expires_at > created_at`. + pub expires_at: i64, +} + +impl TemplateRecord for WorkflowProposalRecord { + const TEMPLATE: CoordinationTemplate = CoordinationTemplate::WorkflowProposal; + + fn to_record(&self) -> Record { + record(vec![ + field("proposer", make_party(&self.proposer)), + field("proposerParticipant", make_text(&self.proposer_participant)), + field( + "proposerNamespaceFingerprint", + make_optional_text(&self.proposer_namespace_fingerprint), + ), + field( + "proposerSigningPublicKeyHex", + make_optional_text(&self.proposer_signing_public_key_hex), + ), + field( + "proposerDamlKeyFingerprint", + make_optional_text(&self.proposer_daml_key_fingerprint), + ), + field("runId", make_text(&self.run_id)), + field("kind", make_enum(self.kind.as_str())), + field("invitees", make_party_list(&self.invitees)), + field("participants", make_text_list(&self.participants)), + field("decPartyId", make_optional_text(&self.dec_party_id)), + field("prefix", make_optional_text(&self.prefix)), + field("threshold", make_optional_int64(&self.threshold)), + field( + "previousThreshold", + make_optional_int64(&self.previous_threshold), + ), + field("dndBaseSerial", make_optional_int64(&self.dnd_base_serial)), + field("p2pBaseSerial", make_optional_int64(&self.p2p_base_serial)), + field("newParticipant", make_optional_text(&self.new_participant)), + field( + "kickedParticipant", + make_optional_text(&self.kicked_participant), + ), + field( + "darPins", + make_list(self.dar_pins.iter().map(DarPin::to_value).collect()), + ), + field("packageNames", make_text_list(&self.package_names)), + field("description", make_text(&self.description)), + field("createdAt", make_timestamp(self.created_at)), + field("expiresAt", make_timestamp(self.expires_at)), + ]) + } + + fn from_record(rec: &Record) -> Result { + Ok(Self { + proposer: req_party(rec, "proposer")?, + proposer_participant: req_text(rec, "proposerParticipant")?, + proposer_namespace_fingerprint: opt_text(rec, "proposerNamespaceFingerprint")?, + proposer_signing_public_key_hex: opt_text(rec, "proposerSigningPublicKeyHex")?, + proposer_daml_key_fingerprint: opt_text(rec, "proposerDamlKeyFingerprint")?, + run_id: req_text(rec, "runId")?, + kind: parse_kind(&req_enum(rec, "kind")?)?, + invitees: req_party_list(rec, "invitees")?, + participants: req_text_list(rec, "participants")?, + dec_party_id: opt_text(rec, "decPartyId")?, + prefix: opt_text(rec, "prefix")?, + threshold: opt_int64(rec, "threshold")?, + previous_threshold: opt_int64(rec, "previousThreshold")?, + dnd_base_serial: opt_int64(rec, "dndBaseSerial")?, + p2p_base_serial: opt_int64(rec, "p2pBaseSerial")?, + new_participant: opt_text(rec, "newParticipant")?, + kicked_participant: opt_text(rec, "kickedParticipant")?, + dar_pins: req_record_list(rec, "darPins")? + .into_iter() + .map(DarPin::from_record) + .collect::>>() + .context("field `darPins`")?, + package_names: req_text_list(rec, "packageNames")?, + description: req_text(rec, "description")?, + created_at: req_timestamp(rec, "createdAt")?, + expires_at: req_timestamp(rec, "expiresAt")?, + }) + } +} + +/// Arguments of `WorkflowProposal_Accept`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AcceptArgs { + pub acceptor: CantonId, + pub participant_id: String, + pub namespace_fingerprint: Option, + pub signing_public_key_hex: Option, + pub daml_key_fingerprint: Option, + pub member_party: Option, +} + +impl ChoiceArgument for AcceptArgs { + fn to_record(&self) -> Record { + record(vec![ + field("acceptor", make_party(&self.acceptor)), + field("participantId", make_text(&self.participant_id)), + field( + "namespaceFingerprint", + make_optional_text(&self.namespace_fingerprint), + ), + field( + "signingPublicKeyHex", + make_optional_text(&self.signing_public_key_hex), + ), + field( + "damlKeyFingerprint", + make_optional_text(&self.daml_key_fingerprint), + ), + field("memberParty", make_optional_party(&self.member_party)), + ]) + } +} + +/// Arguments of `WorkflowProposal_Decline`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeclineArgs { + pub decliner: CantonId, + pub reason: String, +} + +impl ChoiceArgument for DeclineArgs { + fn to_record(&self) -> Record { + record(vec![ + field("decliner", make_party(&self.decliner)), + field("reason", make_text(&self.reason)), + ]) + } +} + +/// Arguments of `WorkflowProposal_Finish`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FinishArgs { + pub succeeded: bool, + pub error: Option, +} + +impl ChoiceArgument for FinishArgs { + fn to_record(&self) -> Record { + record(vec![ + field("succeeded", make_bool(self.succeeded)), + field("error", make_optional_text(&self.error)), + ]) + } +} + +/// `WorkflowAcceptance`: one invitee's consent plus its key material. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WorkflowAcceptanceRecord { + /// The `WorkflowProposal` contract id this acceptance answers. + pub proposal: String, + pub proposer: CantonId, + /// Signatory. + pub acceptor: CantonId, + pub observers: Vec, + pub run_id: String, + pub participant_id: String, + pub namespace_fingerprint: Option, + pub signing_public_key_hex: Option, + pub daml_key_fingerprint: Option, + pub member_party: Option, + /// Micros since the epoch. + pub accepted_at: i64, +} + +impl TemplateRecord for WorkflowAcceptanceRecord { + const TEMPLATE: CoordinationTemplate = CoordinationTemplate::WorkflowAcceptance; + + fn to_record(&self) -> Record { + record(vec![ + field("proposal", make_contract_id(&self.proposal)), + field("proposer", make_party(&self.proposer)), + field("acceptor", make_party(&self.acceptor)), + field("observers", make_party_list(&self.observers)), + field("runId", make_text(&self.run_id)), + field("participantId", make_text(&self.participant_id)), + field( + "namespaceFingerprint", + make_optional_text(&self.namespace_fingerprint), + ), + field( + "signingPublicKeyHex", + make_optional_text(&self.signing_public_key_hex), + ), + field( + "damlKeyFingerprint", + make_optional_text(&self.daml_key_fingerprint), + ), + field("memberParty", make_optional_party(&self.member_party)), + field("acceptedAt", make_timestamp(self.accepted_at)), + ]) + } + + fn from_record(rec: &Record) -> Result { + Ok(Self { + proposal: req_contract_id(rec, "proposal")?, + proposer: req_party(rec, "proposer")?, + acceptor: req_party(rec, "acceptor")?, + observers: req_party_list(rec, "observers")?, + run_id: req_text(rec, "runId")?, + participant_id: req_text(rec, "participantId")?, + namespace_fingerprint: opt_text(rec, "namespaceFingerprint")?, + signing_public_key_hex: opt_text(rec, "signingPublicKeyHex")?, + daml_key_fingerprint: opt_text(rec, "damlKeyFingerprint")?, + member_party: opt_party(rec, "memberParty")?, + accepted_at: req_timestamp(rec, "acceptedAt")?, + }) + } +} + +/// `WorkflowDecline`: one invitee's refusal. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WorkflowDeclineRecord { + pub proposal: String, + pub proposer: CantonId, + /// Signatory. + pub decliner: CantonId, + pub observers: Vec, + pub run_id: String, + pub reason: String, + /// Micros since the epoch. + pub declined_at: i64, +} + +impl TemplateRecord for WorkflowDeclineRecord { + const TEMPLATE: CoordinationTemplate = CoordinationTemplate::WorkflowDecline; + + fn to_record(&self) -> Record { + record(vec![ + field("proposal", make_contract_id(&self.proposal)), + field("proposer", make_party(&self.proposer)), + field("decliner", make_party(&self.decliner)), + field("observers", make_party_list(&self.observers)), + field("runId", make_text(&self.run_id)), + field("reason", make_text(&self.reason)), + field("declinedAt", make_timestamp(self.declined_at)), + ]) + } + + fn from_record(rec: &Record) -> Result { + Ok(Self { + proposal: req_contract_id(rec, "proposal")?, + proposer: req_party(rec, "proposer")?, + decliner: req_party(rec, "decliner")?, + observers: req_party_list(rec, "observers")?, + run_id: req_text(rec, "runId")?, + reason: req_text(rec, "reason")?, + declined_at: req_timestamp(rec, "declinedAt")?, + }) + } +} + +/// `WorkflowOutcome`: the proposer's final word on a run. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WorkflowOutcomeRecord { + /// Signatory. + pub proposer: CantonId, + pub run_id: String, + pub kind: WorkflowKind, + pub observers: Vec, + pub succeeded: bool, + pub error: Option, + /// Micros since the epoch. + pub finished_at: i64, +} + +impl TemplateRecord for WorkflowOutcomeRecord { + const TEMPLATE: CoordinationTemplate = CoordinationTemplate::WorkflowOutcome; + + fn to_record(&self) -> Record { + record(vec![ + field("proposer", make_party(&self.proposer)), + field("runId", make_text(&self.run_id)), + field("kind", make_enum(self.kind.as_str())), + field("observers", make_party_list(&self.observers)), + field("succeeded", make_bool(self.succeeded)), + field("error", make_optional_text(&self.error)), + field("finishedAt", make_timestamp(self.finished_at)), + ]) + } + + fn from_record(rec: &Record) -> Result { + Ok(Self { + proposer: req_party(rec, "proposer")?, + run_id: req_text(rec, "runId")?, + kind: parse_kind(&req_enum(rec, "kind")?)?, + observers: req_party_list(rec, "observers")?, + succeeded: req_bool(rec, "succeeded")?, + error: opt_text(rec, "error")?, + finished_at: req_timestamp(rec, "finishedAt")?, + }) + } +} + +// --------------------------------------------------------------------------- +// Decman.Coordination.Submission +// --------------------------------------------------------------------------- + +/// `SubmissionRound`: one prepared Daml transaction awaiting signatures. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SubmissionRoundRecord { + /// Signatory. + pub proposer: CantonId, + pub run_id: String, + pub index: i64, + /// Observers. + pub signers: Vec, + pub dec_party_id: String, + pub act_as: String, + pub description: String, + pub prepared_transaction_hex: String, + pub prepared_hash_hex: String, + pub hashing_scheme_version: i64, + /// Micros since the epoch. + pub preparation_time: i64, + /// Micros since the epoch. + pub max_record_time: i64, + /// Micros since the epoch. The template requires `deadline <= max_record_time`. + pub deadline: i64, +} + +impl TemplateRecord for SubmissionRoundRecord { + const TEMPLATE: CoordinationTemplate = CoordinationTemplate::SubmissionRound; + + fn to_record(&self) -> Record { + record(vec![ + field("proposer", make_party(&self.proposer)), + field("runId", make_text(&self.run_id)), + field("index", make_int64(self.index)), + field("signers", make_party_list(&self.signers)), + field("decPartyId", make_text(&self.dec_party_id)), + field("actAs", make_text(&self.act_as)), + field("description", make_text(&self.description)), + field( + "preparedTransactionHex", + make_text(&self.prepared_transaction_hex), + ), + field("preparedHashHex", make_text(&self.prepared_hash_hex)), + field( + "hashingSchemeVersion", + make_int64(self.hashing_scheme_version), + ), + field("preparationTime", make_timestamp(self.preparation_time)), + field("maxRecordTime", make_timestamp(self.max_record_time)), + field("deadline", make_timestamp(self.deadline)), + ]) + } + + fn from_record(rec: &Record) -> Result { + Ok(Self { + proposer: req_party(rec, "proposer")?, + run_id: req_text(rec, "runId")?, + index: req_int64(rec, "index")?, + signers: req_party_list(rec, "signers")?, + dec_party_id: req_text(rec, "decPartyId")?, + act_as: req_text(rec, "actAs")?, + description: req_text(rec, "description")?, + prepared_transaction_hex: req_text(rec, "preparedTransactionHex")?, + prepared_hash_hex: req_text(rec, "preparedHashHex")?, + hashing_scheme_version: req_int64(rec, "hashingSchemeVersion")?, + preparation_time: req_timestamp(rec, "preparationTime")?, + max_record_time: req_timestamp(rec, "maxRecordTime")?, + deadline: req_timestamp(rec, "deadline")?, + }) + } +} + +/// Arguments of `SubmissionRound_Sign`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SignArgs { + pub signer: CantonId, + pub participant_id: String, + pub signed_by: String, + pub signature_hex: String, + pub format: String, + pub algorithm: String, +} + +impl ChoiceArgument for SignArgs { + fn to_record(&self) -> Record { + record(vec![ + field("signer", make_party(&self.signer)), + field("participantId", make_text(&self.participant_id)), + field("signedBy", make_text(&self.signed_by)), + field("signatureHex", make_text(&self.signature_hex)), + field("format", make_text(&self.format)), + field("algorithm", make_text(&self.algorithm)), + ]) + } +} + +/// Arguments of `SubmissionRound_Close`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CloseArgs { + pub result: String, +} + +impl ChoiceArgument for CloseArgs { + fn to_record(&self) -> Record { + record(vec![field("result", make_text(&self.result))]) + } +} + +/// `SubmissionSignature`: one signer's signature over a round. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SubmissionSignatureRecord { + /// The `SubmissionRound` contract id. + pub round: String, + pub proposer: CantonId, + /// Signatory. + pub signer: CantonId, + pub observers: Vec, + pub run_id: String, + pub index: i64, + pub participant_id: String, + pub signed_by: String, + pub signature_hex: String, + pub format: String, + pub algorithm: String, + /// Micros since the epoch. + pub signed_at: i64, +} + +impl TemplateRecord for SubmissionSignatureRecord { + const TEMPLATE: CoordinationTemplate = CoordinationTemplate::SubmissionSignature; + + fn to_record(&self) -> Record { + record(vec![ + field("round", make_contract_id(&self.round)), + field("proposer", make_party(&self.proposer)), + field("signer", make_party(&self.signer)), + field("observers", make_party_list(&self.observers)), + field("runId", make_text(&self.run_id)), + field("index", make_int64(self.index)), + field("participantId", make_text(&self.participant_id)), + field("signedBy", make_text(&self.signed_by)), + field("signatureHex", make_text(&self.signature_hex)), + field("format", make_text(&self.format)), + field("algorithm", make_text(&self.algorithm)), + field("signedAt", make_timestamp(self.signed_at)), + ]) + } + + fn from_record(rec: &Record) -> Result { + Ok(Self { + round: req_contract_id(rec, "round")?, + proposer: req_party(rec, "proposer")?, + signer: req_party(rec, "signer")?, + observers: req_party_list(rec, "observers")?, + run_id: req_text(rec, "runId")?, + index: req_int64(rec, "index")?, + participant_id: req_text(rec, "participantId")?, + signed_by: req_text(rec, "signedBy")?, + signature_hex: req_text(rec, "signatureHex")?, + format: req_text(rec, "format")?, + algorithm: req_text(rec, "algorithm")?, + signed_at: req_timestamp(rec, "signedAt")?, + }) + } +} + +// --------------------------------------------------------------------------- +// Decman.Coordination.Acs +// --------------------------------------------------------------------------- + +/// `AcsManifest`: pins an offline ACS snapshot the operator moves by hand. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AcsManifestRecord { + /// Signatory. + pub exporter: CantonId, + pub exporter_participant: String, + pub observers: Vec, + pub dec_party_id: String, + pub target_participant: String, + pub activation_serial: i64, + pub size_bytes: i64, + pub sha256_hex: String, + pub package_ids: Vec, + /// Micros since the epoch. + pub exported_at: i64, +} + +impl TemplateRecord for AcsManifestRecord { + const TEMPLATE: CoordinationTemplate = CoordinationTemplate::AcsManifest; + + fn to_record(&self) -> Record { + record(vec![ + field("exporter", make_party(&self.exporter)), + field("exporterParticipant", make_text(&self.exporter_participant)), + field("observers", make_party_list(&self.observers)), + field("decPartyId", make_text(&self.dec_party_id)), + field("targetParticipant", make_text(&self.target_participant)), + field("activationSerial", make_int64(self.activation_serial)), + field("sizeBytes", make_int64(self.size_bytes)), + field("sha256Hex", make_text(&self.sha256_hex)), + field("packageIds", make_text_list(&self.package_ids)), + field("exportedAt", make_timestamp(self.exported_at)), + ]) + } + + fn from_record(rec: &Record) -> Result { + Ok(Self { + exporter: req_party(rec, "exporter")?, + exporter_participant: req_text(rec, "exporterParticipant")?, + observers: req_party_list(rec, "observers")?, + dec_party_id: req_text(rec, "decPartyId")?, + target_participant: req_text(rec, "targetParticipant")?, + activation_serial: req_int64(rec, "activationSerial")?, + size_bytes: req_int64(rec, "sizeBytes")?, + sha256_hex: req_text(rec, "sha256Hex")?, + package_ids: req_text_list(rec, "packageIds")?, + exported_at: req_timestamp(rec, "exportedAt")?, + }) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + const NS: &str = "1220c4010d6883f367c7f45d55b2449501620130f9b21e96379f17dea455ac7a5892"; + + pub(crate) fn party(prefix: &str) -> CantonId { + CantonId::parse(&format!("{prefix}::{NS}")).expect("valid canton id") + } + + fn participant(n: u8) -> String { + format!("participant{n}::{NS}") + } + + fn labels(rec: &Record) -> Vec<&str> { + rec.fields.iter().map(|f| f.label.as_str()).collect() + } + + fn round_trip(value: &T) { + let decoded = T::from_record(&value.to_record()).expect("decodes"); + assert_eq!(&decoded, value); + } + + pub(crate) fn node_record(prefix: &str, peers: &[&str]) -> DecmanNodeRecord { + DecmanNodeRecord { + node: party(prefix), + participant_id: participant(1), + display_name: "Node One".into(), + version: "2.0.0".into(), + build_version: "2.0.0-dev".into(), + coordination_version: 1, + peers: peers.iter().map(|p| party(p)).collect(), + last_active_at: 1_700_000_000_000_000, + heartbeat_interval_secs: 3600, + min_heartbeat_interval_secs: 60, + } + } + + pub(crate) fn proposal_full() -> WorkflowProposalRecord { + WorkflowProposalRecord { + proposer: party("node-a"), + proposer_participant: participant(1), + proposer_namespace_fingerprint: Some("1220aa".into()), + proposer_signing_public_key_hex: Some("0a1b2c".into()), + proposer_daml_key_fingerprint: Some("1220bb".into()), + run_id: "cbtc-creation".into(), + kind: WorkflowKind::Onboarding, + invitees: vec![party("node-b"), party("node-c")], + participants: vec![participant(1), participant(2), participant(3)], + dec_party_id: Some(format!("cbtc::{NS}")), + prefix: Some("cbtc".into()), + threshold: Some(2), + previous_threshold: Some(3), + dnd_base_serial: Some(4), + p2p_base_serial: Some(5), + new_participant: Some(participant(4)), + kicked_participant: Some(participant(3)), + dar_pins: vec![DarPin { + filename: "governance-core-v1-0.1.0.dar".into(), + sha256_hex: "deadbeef".into(), + main_package_id: "abc123".into(), + size_bytes: 368_723, + }], + package_names: vec!["governance-core-v1".into()], + description: "Create the cbtc party".into(), + created_at: 1_700_000_000_000_000, + expires_at: 1_700_604_800_000_000, + } + } + + pub(crate) fn proposal_minimal() -> WorkflowProposalRecord { + WorkflowProposalRecord { + proposer_namespace_fingerprint: None, + proposer_signing_public_key_hex: None, + proposer_daml_key_fingerprint: None, + dec_party_id: None, + prefix: None, + threshold: None, + previous_threshold: None, + dnd_base_serial: None, + p2p_base_serial: None, + new_participant: None, + kicked_participant: None, + dar_pins: vec![], + package_names: vec![], + kind: WorkflowKind::Dars, + ..proposal_full() + } + } + + pub(crate) fn acceptance_full(proposal: &str, acceptor: &str) -> WorkflowAcceptanceRecord { + WorkflowAcceptanceRecord { + proposal: proposal.into(), + proposer: party("node-a"), + acceptor: party(acceptor), + observers: vec![party("node-b"), party("node-c")], + run_id: "cbtc-creation".into(), + participant_id: participant(2), + namespace_fingerprint: Some("1220cc".into()), + signing_public_key_hex: Some("0a0b".into()), + daml_key_fingerprint: Some("1220dd".into()), + member_party: Some(party("member-b")), + accepted_at: 1_700_000_100_000_000, + } + } + + fn acceptance_minimal() -> WorkflowAcceptanceRecord { + WorkflowAcceptanceRecord { + namespace_fingerprint: None, + signing_public_key_hex: None, + daml_key_fingerprint: None, + member_party: None, + ..acceptance_full("00proposal", "node-b") + } + } + + fn decline() -> WorkflowDeclineRecord { + WorkflowDeclineRecord { + proposal: "00proposal".into(), + proposer: party("node-a"), + decliner: party("node-c"), + observers: vec![party("node-b"), party("node-c")], + run_id: "cbtc-creation".into(), + reason: "not now".into(), + declined_at: 1_700_000_200_000_000, + } + } + + fn outcome(error: Option<&str>) -> WorkflowOutcomeRecord { + WorkflowOutcomeRecord { + proposer: party("node-a"), + run_id: "cbtc-creation".into(), + kind: WorkflowKind::Kick, + observers: vec![party("node-b")], + succeeded: error.is_none(), + error: error.map(str::to_string), + finished_at: 1_700_000_300_000_000, + } + } + + fn round() -> SubmissionRoundRecord { + SubmissionRoundRecord { + proposer: party("node-a"), + run_id: "cbtc-contracts-1".into(), + index: 0, + signers: vec![party("node-b"), party("node-c")], + dec_party_id: format!("cbtc::{NS}"), + act_as: format!("cbtc::{NS}"), + description: "GovernanceRules".into(), + prepared_transaction_hex: "0a0b0c".into(), + prepared_hash_hex: "1220ee".into(), + hashing_scheme_version: 2, + preparation_time: 1_700_000_000_000_000, + max_record_time: 1_700_072_000_000_000, + deadline: 1_700_070_200_000_000, + } + } + + fn signature() -> SubmissionSignatureRecord { + SubmissionSignatureRecord { + round: "00round".into(), + proposer: party("node-a"), + signer: party("node-b"), + observers: vec![party("node-b"), party("node-c")], + run_id: "cbtc-contracts-1".into(), + index: 0, + participant_id: participant(2), + signed_by: "1220ff".into(), + signature_hex: "abcd".into(), + format: "SIGNATURE_FORMAT_CONCAT".into(), + algorithm: "SIGNING_ALGORITHM_SPEC_ED25519".into(), + signed_at: 1_700_000_400_000_000, + } + } + + fn manifest() -> AcsManifestRecord { + AcsManifestRecord { + exporter: party("node-a"), + exporter_participant: participant(1), + observers: vec![party("node-d")], + dec_party_id: format!("cbtc::{NS}"), + target_participant: participant(4), + activation_serial: 7, + size_bytes: 1024, + sha256_hex: "00ff".into(), + package_ids: vec!["abc".into(), "def".into()], + exported_at: 1_700_000_500_000_000, + } + } + + #[test] + fn decman_node_round_trips_with_and_without_peers() { + round_trip(&node_record("node-a", &["node-b", "node-c"])); + round_trip(&node_record("node-a", &[])); + } + + #[test] + fn decman_node_field_order_matches_the_template() { + let rec = node_record("node-a", &[]).to_record(); + assert_eq!( + labels(&rec), + [ + "node", + "participantId", + "displayName", + "version", + "buildVersion", + "coordinationVersion", + "peers", + "lastActiveAt", + "heartbeatIntervalSecs", + "minHeartbeatIntervalSecs", + ] + ); + } + + #[test] + fn workflow_proposal_round_trips_all_some_and_all_none() { + round_trip(&proposal_full()); + round_trip(&proposal_minimal()); + } + + #[test] + fn workflow_proposal_field_order_matches_the_template() { + let rec = proposal_full().to_record(); + assert_eq!( + labels(&rec), + [ + "proposer", + "proposerParticipant", + "proposerNamespaceFingerprint", + "proposerSigningPublicKeyHex", + "proposerDamlKeyFingerprint", + "runId", + "kind", + "invitees", + "participants", + "decPartyId", + "prefix", + "threshold", + "previousThreshold", + "dndBaseSerial", + "p2pBaseSerial", + "newParticipant", + "kickedParticipant", + "darPins", + "packageNames", + "description", + "createdAt", + "expiresAt", + ] + ); + } + + #[test] + fn every_workflow_kind_encodes_as_its_daml_constructor() { + for kind in [ + WorkflowKind::Onboarding, + WorkflowKind::AddParty, + WorkflowKind::Kick, + WorkflowKind::ChangeThreshold, + WorkflowKind::Contracts, + WorkflowKind::Dars, + ] { + let proposal = WorkflowProposalRecord { + kind, + ..proposal_minimal() + }; + let rec = proposal.to_record(); + assert_eq!(req_enum(&rec, "kind").expect("enum"), kind.as_str()); + round_trip(&proposal); + } + } + + #[test] + fn workflow_acceptance_round_trips_all_some_and_all_none() { + round_trip(&acceptance_full("00proposal", "node-b")); + round_trip(&acceptance_minimal()); + } + + #[test] + fn workflow_decline_round_trips() { + round_trip(&decline()); + } + + #[test] + fn workflow_outcome_round_trips_with_and_without_error() { + round_trip(&outcome(None)); + round_trip(&outcome(Some("Peer declined"))); + } + + #[test] + fn submission_round_and_signature_round_trip() { + round_trip(&round()); + round_trip(&signature()); + } + + #[test] + fn acs_manifest_round_trips() { + round_trip(&manifest()); + round_trip(&AcsManifestRecord { + package_ids: vec![], + observers: vec![], + ..manifest() + }); + } + + #[test] + fn choice_arguments_carry_the_daml_labels() { + let update = DecmanNodeUpdateArgs::from_desired(&node_record("node-a", &["node-b"])); + assert_eq!( + labels(&update.to_record()), + [ + "newPeers", + "newDisplayName", + "newVersion", + "newBuildVersion", + "newCoordinationVersion", + "newHeartbeatIntervalSecs", + "newMinHeartbeatIntervalSecs", + ] + ); + let accept = AcceptArgs { + acceptor: party("node-b"), + participant_id: participant(2), + namespace_fingerprint: None, + signing_public_key_hex: None, + daml_key_fingerprint: None, + member_party: None, + }; + assert_eq!( + labels(&accept.to_record()), + [ + "acceptor", + "participantId", + "namespaceFingerprint", + "signingPublicKeyHex", + "damlKeyFingerprint", + "memberParty", + ] + ); + let decline = DeclineArgs { + decliner: party("node-c"), + reason: "no".into(), + }; + assert_eq!(labels(&decline.to_record()), ["decliner", "reason"]); + let finish = FinishArgs { + succeeded: false, + error: Some("x".into()), + }; + assert_eq!(labels(&finish.to_record()), ["succeeded", "error"]); + let sign = SignArgs { + signer: party("node-b"), + participant_id: participant(2), + signed_by: "1220ff".into(), + signature_hex: "ab".into(), + format: "f".into(), + algorithm: "a".into(), + }; + assert_eq!( + labels(&sign.to_record()), + [ + "signer", + "participantId", + "signedBy", + "signatureHex", + "format", + "algorithm", + ] + ); + assert_eq!( + labels( + &CloseArgs { + result: "ok".into() + } + .to_record() + ), + ["result"] + ); + assert!(matches!( + unit_argument().sum, + Some(value::Sum::Record(r)) if r.fields.is_empty() + )); + } + + #[test] + fn a_missing_field_is_an_error_not_a_default() { + let mut rec = node_record("node-a", &[]).to_record(); + rec.fields.retain(|f| f.label != "coordinationVersion"); + let err = DecmanNodeRecord::from_record(&rec).expect_err("must fail"); + assert!(err.to_string().contains("coordinationVersion"), "{err}"); + } + + #[test] + fn a_wrong_shaped_optional_is_an_error() { + let mut rec = proposal_minimal().to_record(); + for f in &mut rec.fields { + if f.label == "threshold" { + f.value = Some(make_int64(3)); + } + } + let err = WorkflowProposalRecord::from_record(&rec).expect_err("must fail"); + assert!(err.to_string().contains("threshold"), "{err}"); + } + + #[test] + fn an_unknown_kind_constructor_is_an_error() { + let mut rec = proposal_minimal().to_record(); + for f in &mut rec.fields { + if f.label == "kind" { + f.value = Some(make_enum("Teleport")); + } + } + let err = WorkflowProposalRecord::from_record(&rec).expect_err("must fail"); + assert!(err.to_string().contains("Teleport"), "{err}"); + } +} diff --git a/crates/decman/src/onledger/daml/mod.rs b/crates/decman/src/onledger/daml/mod.rs new file mode 100644 index 000000000..990983f80 --- /dev/null +++ b/crates/decman/src/onledger/daml/mod.rs @@ -0,0 +1,16 @@ +//! Daml codec and Ledger API client for the `decman-coordination-v1` package. +//! +//! * [`templates`]: template ids, module paths, choice names, and the +//! node-level [`templates::CoordinationPackage`] resolver. +//! * [`codec`]: Rust structs mirroring every template and choice argument, +//! with `Record` encode and decode. +//! * [`client`]: [`client::CoordinationClient`], which submits as the node +//! party through `CommandService.submit_and_wait_for_transaction` and reads +//! the ACS as the node party. + +pub mod client; +pub mod codec; +pub mod templates; + +pub use client::{ActiveContract, CoordinationClient, ExerciseOutcome}; +pub use templates::{CoordinationPackage, CoordinationTemplate, choices}; diff --git a/crates/decman/src/onledger/daml/templates.rs b/crates/decman/src/onledger/daml/templates.rs new file mode 100644 index 000000000..68eb3f5fd --- /dev/null +++ b/crates/decman/src/onledger/daml/templates.rs @@ -0,0 +1,214 @@ +//! Template and choice names of the `decman-coordination-v1` package. +//! +//! The package is node-level: one ref for the whole node, resolved through +//! [`CoordinationPackage`] rather than a per-party `PackageConfig`. Module +//! paths are the full Ledger API `module_name`, so an event's `Identifier` +//! matches on module and entity (Canton echoes package hashes, never refs). + +use canton_proto_rs::com::daml::ledger::api::v2::Identifier; +use decman_lib::framework::{PackageResolver, TemplateId, TemplateInfo}; + +use crate::consts; + +/// The resolver key every coordination template asks for. +pub const PACKAGE_KEY: &str = "decman_coordination"; + +/// The one `#package-name` ref the coordination templates live under. +/// +/// A [`PackageResolver`] that answers only [`PACKAGE_KEY`], so a template +/// from another catalog cannot resolve against it by accident. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CoordinationPackage(String); + +impl CoordinationPackage { + /// The ref from `consts::coordination_package_ref()` (env-overridable). + pub fn from_env() -> Self { + Self(consts::coordination_package_ref()) + } + + /// A specific ref, e.g. in tests. + pub fn new(package_ref: impl Into) -> Self { + Self(package_ref.into()) + } + + /// The `#name` ref. + pub fn reference(&self) -> &str { + &self.0 + } + + /// The package name without the leading `#`, as `ListPackages` reports it. + pub fn package_name(&self) -> &str { + self.0.strip_prefix('#').unwrap_or(&self.0) + } +} + +impl PackageResolver for CoordinationPackage { + fn package_ref(&self, key: &str) -> Option<&str> { + (key == PACKAGE_KEY).then_some(self.0.as_str()) + } +} + +/// Every template in the coordination package. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum CoordinationTemplate { + DecmanNode, + WorkflowProposal, + WorkflowAcceptance, + WorkflowDecline, + WorkflowOutcome, + SubmissionRound, + SubmissionSignature, + AcsManifest, +} + +impl CoordinationTemplate { + /// The Daml module the template lives in. + pub fn module(self) -> &'static str { + match self { + Self::DecmanNode => modules::NODE, + Self::WorkflowProposal + | Self::WorkflowAcceptance + | Self::WorkflowDecline + | Self::WorkflowOutcome => modules::WORKFLOW, + Self::SubmissionRound | Self::SubmissionSignature => modules::SUBMISSION, + Self::AcsManifest => modules::ACS, + } + } + + /// The template name. + pub fn entity(self) -> &'static str { + match self { + Self::DecmanNode => "DecmanNode", + Self::WorkflowProposal => "WorkflowProposal", + Self::WorkflowAcceptance => "WorkflowAcceptance", + Self::WorkflowDecline => "WorkflowDecline", + Self::WorkflowOutcome => "WorkflowOutcome", + Self::SubmissionRound => "SubmissionRound", + Self::SubmissionSignature => "SubmissionSignature", + Self::AcsManifest => "AcsManifest", + } + } + + /// The `Identifier` a command carries, under the given package ref. + pub fn identifier(self, package: &CoordinationPackage) -> Identifier { + Identifier { + package_id: package.reference().to_string(), + module_name: self.module().to_string(), + entity_name: self.entity().to_string(), + } + } + + /// Whether an event's `Identifier` names this template. Compares module + /// and entity only, like `TemplateId::matches`. + pub fn matches(self, id: &Identifier) -> bool { + id.module_name == self.module() && id.entity_name == self.entity() + } +} + +impl TemplateInfo for CoordinationTemplate { + fn template_id(&self, pkgs: &dyn PackageResolver) -> Result { + let package_ref = pkgs + .package_ref(PACKAGE_KEY) + .ok_or(decman_lib::Error::PackageNotConfigured(PACKAGE_KEY))?; + Ok(TemplateId::new(package_ref, self.module(), self.entity())) + } +} + +/// Daml module paths (design section 3). +pub mod modules { + pub const TYPES: &str = "Decman.Coordination.Types"; + pub const NODE: &str = "Decman.Coordination.Node"; + pub const WORKFLOW: &str = "Decman.Coordination.Workflow"; + pub const SUBMISSION: &str = "Decman.Coordination.Submission"; + pub const ACS: &str = "Decman.Coordination.Acs"; +} + +/// Choice names (design section 3). Kept as constants so a typo fails at the +/// call site instead of as a runtime interpretation error. +pub mod choices { + pub const DECMAN_NODE_HEARTBEAT: &str = "DecmanNode_Heartbeat"; + pub const DECMAN_NODE_UPDATE: &str = "DecmanNode_Update"; + pub const DECMAN_NODE_RETIRE: &str = "DecmanNode_Retire"; + + pub const WORKFLOW_PROPOSAL_ACCEPT: &str = "WorkflowProposal_Accept"; + pub const WORKFLOW_PROPOSAL_DECLINE: &str = "WorkflowProposal_Decline"; + pub const WORKFLOW_PROPOSAL_CANCEL: &str = "WorkflowProposal_Cancel"; + pub const WORKFLOW_PROPOSAL_FINISH: &str = "WorkflowProposal_Finish"; + pub const WORKFLOW_ACCEPTANCE_ARCHIVE: &str = "WorkflowAcceptance_Archive"; + pub const WORKFLOW_DECLINE_ARCHIVE: &str = "WorkflowDecline_Archive"; + pub const WORKFLOW_OUTCOME_ARCHIVE: &str = "WorkflowOutcome_Archive"; + + pub const SUBMISSION_ROUND_SIGN: &str = "SubmissionRound_Sign"; + pub const SUBMISSION_ROUND_CLOSE: &str = "SubmissionRound_Close"; + pub const SUBMISSION_SIGNATURE_ARCHIVE: &str = "SubmissionSignature_Archive"; + + pub const ACS_MANIFEST_ARCHIVE: &str = "AcsManifest_Archive"; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn template_ids_resolve_through_the_node_level_ref() { + let package = CoordinationPackage::new("#decman-coordination-v1"); + let id = CoordinationTemplate::WorkflowProposal + .template_id(&package) + .expect("resolves"); + assert_eq!( + id.to_string(), + "#decman-coordination-v1:Decman.Coordination.Workflow:WorkflowProposal" + ); + } + + #[test] + fn the_package_resolver_answers_only_its_own_key() { + let package = CoordinationPackage::new("#x"); + assert_eq!( + PackageResolver::package_ref(&package, PACKAGE_KEY), + Some("#x") + ); + assert_eq!( + PackageResolver::package_ref(&package, "governance_core"), + None + ); + assert_eq!(package.reference(), "#x"); + assert_eq!(package.package_name(), "x"); + } + + #[test] + fn a_foreign_resolver_without_the_key_is_not_configured() { + let err = CoordinationTemplate::DecmanNode + .template_id(&common::api::PackageConfig::default()) + .expect_err("no coordination key in PackageConfig"); + assert!(matches!(err, decman_lib::Error::PackageNotConfigured(_))); + } + + #[test] + fn matches_compares_module_and_entity_only() { + let echoed = Identifier { + package_id: "abc123".into(), + module_name: modules::NODE.into(), + entity_name: "DecmanNode".into(), + }; + assert!(CoordinationTemplate::DecmanNode.matches(&echoed)); + assert!(!CoordinationTemplate::WorkflowProposal.matches(&echoed)); + } + + #[test] + fn every_template_has_a_module_in_the_package() { + for t in [ + CoordinationTemplate::DecmanNode, + CoordinationTemplate::WorkflowProposal, + CoordinationTemplate::WorkflowAcceptance, + CoordinationTemplate::WorkflowDecline, + CoordinationTemplate::WorkflowOutcome, + CoordinationTemplate::SubmissionRound, + CoordinationTemplate::SubmissionSignature, + CoordinationTemplate::AcsManifest, + ] { + assert!(t.module().starts_with("Decman.Coordination.")); + assert!(!t.entity().is_empty()); + } + } +} diff --git a/crates/decman/src/onledger/dars.rs b/crates/decman/src/onledger/dars.rs new file mode 100644 index 000000000..3276cd614 --- /dev/null +++ b/crates/decman/src/onledger/dars.rs @@ -0,0 +1,206 @@ +//! DARs: hash-pinned local upload (design D8). +//! +//! No DAR bytes travel over any decman channel. The proposer pins each file +//! (`filename`, `sha256Hex`, `mainPackageId`, `sizeBytes`) on the +//! `WorkflowProposal`; every operator uploads the same file locally through +//! `POST /dars/upload` with `pin_instance`, and the handler refuses a file +//! whose hash matches no pending pin. The proposer completes the run when +//! every pin is vetted on every participant. +//! +//! The coordination DAR itself is embedded and uploaded by a startup task. +//! +//! Pure helpers are implemented; every participant call is a stub the DARs +//! agent fills in. + +use std::collections::BTreeMap; + +use anyhow::{Context, Result, bail}; +use base64::Engine; +use common::{api::DarFile, canton_id::CantonId}; +use sha2::{Digest, Sha256}; + +use crate::config::NodeConfig; + +use super::daml::codec::DarPin; + +/// The coordination DAR this build ships (design D8). +pub const COORDINATION_DAR_FILENAME: &str = "decman-coordination-v1-0.1.0.dar"; + +/// The bytes of [`COORDINATION_DAR_FILENAME`]. +pub fn embedded_coordination_dar() -> &'static [u8] { + include_bytes!("../../../../releases/v1/decman-coordination-v1-0.1.0.dar") +} + +/// Lowercase hex SHA-256 of a DAR. +pub fn hash_dar(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +/// Decode the base64 `DarFile`s of a request into `(filename, bytes)`. +/// +/// # Errors +/// Returns an error naming the file whose content is not valid base64. +pub fn decode_dar_files(files: &[DarFile]) -> Result)>> { + files + .iter() + .map(|f| { + let bytes = base64::engine::general_purpose::STANDARD + .decode(&f.data) + .with_context(|| format!("DAR {} is not valid base64", f.filename))?; + Ok((f.filename.clone(), bytes)) + }) + .collect() +} + +/// Pin decoded DAR files. `main_package_ids` maps a filename to the main +/// package id the local participant reported after upload; a file without +/// one is an error, because the pin must carry it. +/// +/// # Errors +/// Returns an error for a duplicate filename or a missing main package id. +pub fn pin_dar_files( + files: &[(String, Vec)], + main_package_ids: &BTreeMap, +) -> Result> { + let mut pins = Vec::with_capacity(files.len()); + let mut seen = std::collections::BTreeSet::new(); + for (filename, bytes) in files { + if !seen.insert(filename.clone()) { + bail!("DAR {filename} is listed twice"); + } + let Some(main_package_id) = main_package_ids.get(filename) else { + bail!("no main package id for DAR {filename}; upload it locally first"); + }; + pins.push(DarPin { + filename: filename.clone(), + sha256_hex: hash_dar(bytes), + main_package_id: main_package_id.clone(), + size_bytes: i64::try_from(bytes.len()).unwrap_or(i64::MAX), + }); + } + Ok(pins) +} + +/// The pin an uploaded file matches by hash, if any. The handler refuses a +/// file with no match. +pub fn matching_pin<'a>(pins: &'a [DarPin], bytes: &[u8]) -> Option<&'a DarPin> { + let hash = hash_dar(bytes); + pins.iter().find(|p| p.sha256_hex == hash) +} + +/// Upload a DAR to the local participant, pinned to +/// `expected_main_package_id`, and vet it. Returns the main package id. +/// +/// # Errors +/// Returns an error when the participant rejects the upload or the main +/// package id differs. +pub async fn upload_and_vet_locally( + _config: &NodeConfig, + _filename: &str, + _bytes: &[u8], + _expected_main_package_id: Option<&str>, +) -> Result { + bail!("not implemented: local DAR upload with expected_main_package_id (design D8)") +} + +/// Which pins each participant has not vetted yet, from +/// `ListVettedPackages(filter_participant)` with the validity window +/// applied. An empty list for every participant means the run is complete. +/// +/// # Errors +/// Returns an error when a topology read fails. +pub async fn unvetted_pins_by_participant( + _config: &NodeConfig, + _participants: &[CantonId], + _pins: &[DarPin], +) -> Result>> { + bail!("not implemented: vetting observation per participant (design D8)") +} + +/// State of the startup coordination-DAR task, shown in `/node-health`. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CoordinationDarState { + pub uploaded: bool, + pub vetted: bool, + pub attempts: u32, + pub last_error: Option, +} + +/// Upload and vet the embedded coordination DAR on this participant with +/// retry, when `DECPM_AUTO_UPLOAD_COORDINATION_DAR` is on. +/// +/// # Errors +/// Returns an error when the retry budget runs out. +pub async fn ensure_coordination_dar(_config: &NodeConfig) -> Result { + bail!("not implemented: startup coordination-DAR upload (design D8)") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_dar_is_a_zip_archive() { + let bytes = embedded_coordination_dar(); + assert!(bytes.len() > 1_000); + assert_eq!(&bytes[..2], b"PK"); + } + + #[test] + fn hash_is_lowercase_hex_sha256() { + let h = hash_dar(b"abc"); + assert_eq!( + h, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn decode_rejects_bad_base64() { + let files = vec![DarFile { + filename: "a.dar".into(), + data: "not base64!".into(), + }]; + let err = decode_dar_files(&files).expect_err("bad base64"); + assert!(err.to_string().contains("a.dar")); + let ok = decode_dar_files(&[DarFile { + filename: "b.dar".into(), + data: base64::engine::general_purpose::STANDARD.encode(b"PK\x03\x04"), + }]) + .expect("decodes"); + assert_eq!(ok[0].1, b"PK\x03\x04"); + } + + #[test] + fn pins_carry_hash_size_and_main_package_id() { + let files = vec![("a.dar".to_string(), b"abc".to_vec())]; + let ids: BTreeMap = [("a.dar".to_string(), "pkg-a".to_string())] + .into_iter() + .collect(); + let pins = pin_dar_files(&files, &ids).expect("pins"); + assert_eq!(pins[0].filename, "a.dar"); + assert_eq!(pins[0].sha256_hex, hash_dar(b"abc")); + assert_eq!(pins[0].main_package_id, "pkg-a"); + assert_eq!(pins[0].size_bytes, 3); + assert_eq!( + matching_pin(&pins, b"abc").map(|p| p.filename.as_str()), + Some("a.dar") + ); + assert!(matching_pin(&pins, b"abd").is_none()); + } + + #[test] + fn pins_refuse_duplicates_and_missing_ids() { + let ids: BTreeMap = [("a.dar".to_string(), "pkg-a".to_string())] + .into_iter() + .collect(); + let dup = vec![ + ("a.dar".to_string(), b"abc".to_vec()), + ("a.dar".to_string(), b"abd".to_vec()), + ]; + assert!(pin_dar_files(&dup, &ids).is_err()); + let missing = vec![("b.dar".to_string(), b"abc".to_vec())]; + let err = pin_dar_files(&missing, &ids).expect_err("missing id"); + assert!(err.to_string().contains("upload it locally first")); + } +} diff --git a/crates/decman/src/onledger/engine/add_party.rs b/crates/decman/src/onledger/engine/add_party.rs new file mode 100644 index 000000000..aa2c668a9 --- /dev/null +++ b/crates/decman/src/onledger/engine/add_party.rs @@ -0,0 +1,75 @@ +//! Add-party driver (design section 6): add a member to a party. +//! +//! Stub. The step bodies land with the add-party agent; every tick logs and +//! returns so the observer loop keeps running. + +use anyhow::{Result, bail}; +use common::types::{WorkflowKind, WorkflowRun}; + +use super::{KindDriver, MemberVariant, OnLedger, ProposalExtras, RunMeta, StartRequest, TickCtx}; + +pub struct AddParty; + +pub const COORDINATOR_STEPS: &[&str] = &[ + "GenerateKeys", + "WaitingForAcceptances", + "ProposeChanges", + "AwaitChanges", + "AwaitReplication", + "Complete", +]; + +/// The participant being added. +pub const JOINER_STEPS: &[&str] = &[ + "GenerateKeys", + "CoSignChanges", + "SyncAcs", + "ClearOnboarding", + "Complete", +]; + +/// Every current host. `CoSignChanges` captures the export offset first. +pub const MEMBER_STEPS: &[&str] = &["CoSignChanges", "PublishManifest", "Complete"]; + +impl KindDriver for AddParty { + fn kind() -> WorkflowKind { + WorkflowKind::AddParty + } + + fn coordinator_steps() -> &'static [&'static str] { + COORDINATOR_STEPS + } + + fn member_steps(variant: Option) -> &'static [&'static str] { + match variant { + Some(MemberVariant::Joiner) => JOINER_STEPS, + Some(MemberVariant::Member) | None => MEMBER_STEPS, + } + } + + /// TODO(add-party agent): refuse when the new participant already hosts + /// the party or is quarantined for it (design section 6 preflight). + async fn preflight(_ol: &OnLedger, _req: &StartRequest) -> Result<()> { + Ok(()) + } + + /// TODO(add-party agent): the coordinator's own key material for the + /// party (`proposerNamespaceFingerprint`, `proposerDamlKeyFingerprint`). + async fn prepare(_ol: &OnLedger, _req: &StartRequest) -> Result { + bail!("not implemented: add-party proposer key material (design D6)") + } + + async fn tick_coordinator( + _ctx: &TickCtx<'_>, + run: &WorkflowRun, + _meta: &RunMeta, + ) -> Result<()> { + super::not_implemented(run); + Ok(()) + } + + async fn tick_member(_ctx: &TickCtx<'_>, run: &WorkflowRun, _meta: &RunMeta) -> Result<()> { + super::not_implemented(run); + Ok(()) + } +} diff --git a/crates/decman/src/onledger/engine/change_threshold.rs b/crates/decman/src/onledger/engine/change_threshold.rs new file mode 100644 index 000000000..555a598e1 --- /dev/null +++ b/crates/decman/src/onledger/engine/change_threshold.rs @@ -0,0 +1,61 @@ +//! Change-threshold driver (design section 6): re-issue the DND and P2P +//! with a new threshold. +//! +//! Stub. The step bodies land with the change-threshold agent; every tick +//! logs and returns so the observer loop keeps running. + +use anyhow::{Result, bail}; +use common::types::{WorkflowKind, WorkflowRun}; + +use super::{KindDriver, MemberVariant, OnLedger, ProposalExtras, RunMeta, StartRequest, TickCtx}; + +pub struct ChangeThreshold; + +pub const COORDINATOR_STEPS: &[&str] = &[ + "WaitingForAcceptances", + "ProposeChanges", + "AwaitChanges", + "Complete", +]; + +pub const MEMBER_STEPS: &[&str] = &["CoSignChanges", "Complete"]; + +impl KindDriver for ChangeThreshold { + fn kind() -> WorkflowKind { + WorkflowKind::ChangeThreshold + } + + fn coordinator_steps() -> &'static [&'static str] { + COORDINATOR_STEPS + } + + fn member_steps(_variant: Option) -> &'static [&'static str] { + MEMBER_STEPS + } + + /// TODO(change-threshold agent): refuse when the new threshold equals + /// the current one. + async fn preflight(_ol: &OnLedger, _req: &StartRequest) -> Result<()> { + Ok(()) + } + + /// TODO(change-threshold agent): the coordinator's own owner fingerprint + /// for the party (design D6). + async fn prepare(_ol: &OnLedger, _req: &StartRequest) -> Result { + bail!("not implemented: change-threshold proposer key material (design D6)") + } + + async fn tick_coordinator( + _ctx: &TickCtx<'_>, + run: &WorkflowRun, + _meta: &RunMeta, + ) -> Result<()> { + super::not_implemented(run); + Ok(()) + } + + async fn tick_member(_ctx: &TickCtx<'_>, run: &WorkflowRun, _meta: &RunMeta) -> Result<()> { + super::not_implemented(run); + Ok(()) + } +} diff --git a/crates/decman/src/onledger/engine/contracts.rs b/crates/decman/src/onledger/engine/contracts.rs new file mode 100644 index 000000000..68a80ba03 --- /dev/null +++ b/crates/decman/src/onledger/engine/contracts.rs @@ -0,0 +1,117 @@ +//! Contracts driver (design sections 6, D7): create contracts for a party +//! through `SubmissionRound`s. +//! +//! Stub. The step bodies land with the contracts agent; every tick logs and +//! returns so the observer loop keeps running. + +use std::collections::BTreeSet; + +use anyhow::Result; +use common::types::{WorkflowKind, WorkflowRun}; + +use super::{KindDriver, MemberVariant, OnLedger, ProposalExtras, RunMeta, StartRequest, TickCtx}; + +pub struct Contracts; + +pub const COORDINATOR_STEPS: &[&str] = &[ + "WaitingForAcceptances", + "AwaitDars", + "PrepareSubmissions", + "CollectSignatures", + "ExecuteSubmissions", + "Complete", +]; + +pub const MEMBER_STEPS: &[&str] = &["UploadDars", "SignSubmissions", "Complete"]; + +/// The package names a contracts request creates under. +/// +/// TODO(contracts agent): resolve a package hash to its name through the +/// package inventory; today a `#name` ref is stripped and a hash is kept +/// as-is. +pub fn package_names_of(req: &StartRequest) -> Vec { + let StartRequest::Contracts { contracts, .. } = req else { + return Vec::new(); + }; + let names: BTreeSet = contracts + .iter() + .map(|c| c.package_id.trim_start_matches('#').to_string()) + .filter(|n| !n.is_empty()) + .collect(); + names.into_iter().collect() +} + +impl KindDriver for Contracts { + fn kind() -> WorkflowKind { + WorkflowKind::Contracts + } + + fn coordinator_steps() -> &'static [&'static str] { + COORDINATOR_STEPS + } + + fn member_steps(_variant: Option) -> &'static [&'static str] { + MEMBER_STEPS + } + + /// The proposal names the packages members check root creates against + /// (design D7). No proposer key material: rounds are signed with the + /// party's `party_signing_keys`. + async fn prepare(_ol: &OnLedger, req: &StartRequest) -> Result { + Ok(ProposalExtras { + package_names: package_names_of(req), + ..ProposalExtras::default() + }) + } + + async fn tick_coordinator( + _ctx: &TickCtx<'_>, + run: &WorkflowRun, + _meta: &RunMeta, + ) -> Result<()> { + super::not_implemented(run); + Ok(()) + } + + async fn tick_member(_ctx: &TickCtx<'_>, run: &WorkflowRun, _meta: &RunMeta) -> Result<()> { + super::not_implemented(run); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use common::{api::ContractDefinition, canton_id::CantonId}; + + use super::*; + + #[test] + fn package_names_strip_the_ref_prefix_and_dedupe() { + let ns = "1220c4010d6883f367c7f45d55b2449501620130f9b21e96379f17dea455ac7a5892"; + let contract = |package_id: &str| ContractDefinition { + id: "c".into(), + name: "n".into(), + package_id: package_id.into(), + module_name: "M".into(), + entity_name: "E".into(), + fields: vec![], + }; + let req = StartRequest::Contracts { + dec_party_id: CantonId::parse(&format!("cbtc::{ns}")).expect("id"), + participant_ids: vec![], + participant_parties: vec![], + operator_party: CantonId::parse(&format!("op::{ns}")).expect("id"), + contracts: vec![ + contract("#governance-core-v1"), + contract("governance-core-v1"), + contract("#governance-action-v1"), + contract(""), + ], + instance_name: "i".into(), + }; + assert_eq!( + package_names_of(&req), + vec!["governance-action-v1", "governance-core-v1"] + ); + } +} diff --git a/crates/decman/src/onledger/engine/dars.rs b/crates/decman/src/onledger/engine/dars.rs new file mode 100644 index 000000000..3cc8cc5b2 --- /dev/null +++ b/crates/decman/src/onledger/engine/dars.rs @@ -0,0 +1,49 @@ +//! DARs driver (design sections 6, D8): hash-pinned local uploads. +//! +//! Stub. The step bodies land with the DARs agent; every tick logs and +//! returns so the observer loop keeps running. + +use anyhow::{Result, bail}; +use common::types::{WorkflowKind, WorkflowRun}; + +use super::{KindDriver, MemberVariant, OnLedger, ProposalExtras, RunMeta, StartRequest, TickCtx}; + +pub struct Dars; + +pub const COORDINATOR_STEPS: &[&str] = &["WaitingForAcceptances", "AwaitVetting", "Complete"]; + +pub const MEMBER_STEPS: &[&str] = &["UploadDars", "Complete"]; + +impl KindDriver for Dars { + fn kind() -> WorkflowKind { + WorkflowKind::Dars + } + + fn coordinator_steps() -> &'static [&'static str] { + COORDINATOR_STEPS + } + + fn member_steps(_variant: Option) -> &'static [&'static str] { + MEMBER_STEPS + } + + /// TODO(DARs agent): upload every file locally, read its main package id + /// from the participant, and pin it with `onledger::dars::pin_dar_files`. + async fn prepare(_ol: &OnLedger, _req: &StartRequest) -> Result { + bail!("not implemented: DAR pins need the main package ids (design D8)") + } + + async fn tick_coordinator( + _ctx: &TickCtx<'_>, + run: &WorkflowRun, + _meta: &RunMeta, + ) -> Result<()> { + super::not_implemented(run); + Ok(()) + } + + async fn tick_member(_ctx: &TickCtx<'_>, run: &WorkflowRun, _meta: &RunMeta) -> Result<()> { + super::not_implemented(run); + Ok(()) + } +} diff --git a/crates/decman/src/onledger/engine/kick.rs b/crates/decman/src/onledger/engine/kick.rs new file mode 100644 index 000000000..9fe8fcd7f --- /dev/null +++ b/crates/decman/src/onledger/engine/kick.rs @@ -0,0 +1,62 @@ +//! Kick driver (design section 6): remove a member from a party. +//! +//! Stub. The step bodies land with the kick agent; every tick logs and +//! returns so the observer loop keeps running. The kicked node is not an +//! invitee and sees the topology proposal as unsolicited. + +use anyhow::{Result, bail}; +use common::types::{WorkflowKind, WorkflowRun}; + +use super::{KindDriver, MemberVariant, OnLedger, ProposalExtras, RunMeta, StartRequest, TickCtx}; + +pub struct Kick; + +pub const COORDINATOR_STEPS: &[&str] = &[ + "WaitingForAcceptances", + "ProposeChanges", + "AwaitChanges", + "Complete", +]; + +pub const MEMBER_STEPS: &[&str] = &["CoSignChanges", "Complete"]; + +impl KindDriver for Kick { + fn kind() -> WorkflowKind { + WorkflowKind::Kick + } + + fn coordinator_steps() -> &'static [&'static str] { + COORDINATOR_STEPS + } + + fn member_steps(_variant: Option) -> &'static [&'static str] { + MEMBER_STEPS + } + + /// TODO(kick agent): refuse when this node cannot attribute the kicked + /// member's owner key (`dec_party_participant.owner_key`) or, on a legacy + /// party, its Daml key (design section 5, legacy branch). + async fn preflight(_ol: &OnLedger, _req: &StartRequest) -> Result<()> { + Ok(()) + } + + /// TODO(kick agent): the coordinator's own owner fingerprint for the + /// party, from the cache or the on-chain lookup (design D4). + async fn prepare(_ol: &OnLedger, _req: &StartRequest) -> Result { + bail!("not implemented: kick proposer key material (design D6)") + } + + async fn tick_coordinator( + _ctx: &TickCtx<'_>, + run: &WorkflowRun, + _meta: &RunMeta, + ) -> Result<()> { + super::not_implemented(run); + Ok(()) + } + + async fn tick_member(_ctx: &TickCtx<'_>, run: &WorkflowRun, _meta: &RunMeta) -> Result<()> { + super::not_implemented(run); + Ok(()) + } +} diff --git a/crates/decman/src/onledger/engine/mod.rs b/crates/decman/src/onledger/engine/mod.rs new file mode 100644 index 000000000..cadc2537f --- /dev/null +++ b/crates/decman/src/onledger/engine/mod.rs @@ -0,0 +1,1973 @@ +//! The run engine (design sections 6, 10, 11): how a run starts, how an +//! invitee joins or declines, how a run is cancelled or retried, and the +//! contract every per-kind driver implements for the observer loop. +//! +//! There is no long-lived task per run. `workflow_runs` is the only state +//! machine; the observer re-reads a row every tick and hands it to the +//! [`KindDriver`] for its kind and role. The driver does one bounded piece of +//! work and returns. Cancel, dismiss, and retry are row operations. +//! +//! Until migration `000021` adds the on-ledger columns, a run's on-ledger +//! fields live in `config_json` under the [`RUN_META_KEY`] object +//! ([`RunMeta`]). The observer ignores rows without one, which is how it +//! tells an on-ledger run from a Noise-era row. + +pub mod add_party; +pub mod change_threshold; +pub mod contracts; +pub mod dars; +pub mod kick; +pub mod onboarding; + +use std::collections::{BTreeMap, BTreeSet, HashSet}; + +use anyhow::{Context, Result, bail}; +use common::{ + api::{ContractDefinition, DarFile}, + canton_id::CantonId, + types::{WorkflowKind, WorkflowProgress, WorkflowRole, WorkflowRun}, +}; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; + +use crate::{ + consts, + db::{ + rows::{ProposalDecision, ProposalDecisionEntry}, + schema::{Commitable, SchemaRead, SchemaWrite}, + }, + utils, +}; + +use super::{ + CoordinationClient, NodeIdentity, OnLedger, + daml::codec::{ + AcceptArgs, DarPin, WorkflowAcceptanceRecord, WorkflowDeclineRecord, WorkflowOutcomeRecord, + WorkflowProposalRecord, + }, + now_micros, now_secs, + proposals::{self, Acceptance, ActiveProposal, Decline, Outcome}, + registry::{self, PeerHealthSnapshot}, + topology, + validation::HeadState, +}; + +/// The `config_json` key that carries [`RunMeta`]. +/// +/// TODO(migration 000021): move these fields to the `proposal_cid`, +/// `coordinator_party`, `coordinator_participant`, `member_variant`, and +/// `topology_hashes_json` columns and delete this key. +pub const RUN_META_KEY: &str = "onledger"; + +/// The last step of every step list. +pub const COMPLETE_STEP: &str = "Complete"; + +/// The coordinator step that waits for invitees. +pub const WAITING_FOR_ACCEPTANCES_STEP: &str = "WaitingForAcceptances"; + +// --------------------------------------------------------------------------- +// Start requests +// --------------------------------------------------------------------------- + +/// What a start handler asks the engine to run. Mirrors the request DTOs in +/// `common::api` plus the `instance_name` the handler allocated. +#[derive(Clone, Debug)] +pub enum StartRequest { + Onboarding { + party_id_prefix: String, + peer_ids: Vec, + threshold: Option, + instance_name: String, + }, + AddParty { + dec_party_id: CantonId, + new_participant_id: CantonId, + new_threshold: i32, + previous_threshold: i32, + instance_name: String, + }, + Kick { + dec_party_id: CantonId, + participant_id: CantonId, + new_threshold: i32, + previous_threshold: i32, + instance_name: String, + }, + ChangeThreshold { + dec_party_id: CantonId, + new_threshold: i32, + previous_threshold: i32, + instance_name: String, + }, + Contracts { + dec_party_id: CantonId, + participant_ids: Vec, + participant_parties: Vec, + operator_party: CantonId, + contracts: Vec, + instance_name: String, + }, + Dars { + dar_files: Vec, + peer_ids: Vec, + instance_name: String, + }, +} + +impl StartRequest { + pub fn kind(&self) -> WorkflowKind { + match self { + Self::Onboarding { .. } => WorkflowKind::Onboarding, + Self::AddParty { .. } => WorkflowKind::AddParty, + Self::Kick { .. } => WorkflowKind::Kick, + Self::ChangeThreshold { .. } => WorkflowKind::ChangeThreshold, + Self::Contracts { .. } => WorkflowKind::Contracts, + Self::Dars { .. } => WorkflowKind::Dars, + } + } + + pub fn instance_name(&self) -> &str { + match self { + Self::Onboarding { instance_name, .. } + | Self::AddParty { instance_name, .. } + | Self::Kick { instance_name, .. } + | Self::ChangeThreshold { instance_name, .. } + | Self::Contracts { instance_name, .. } + | Self::Dars { instance_name, .. } => instance_name, + } + } + + /// The existing decentralized party the request acts on, if any. + pub fn dec_party_id(&self) -> Option<&CantonId> { + match self { + Self::AddParty { dec_party_id, .. } + | Self::Kick { dec_party_id, .. } + | Self::ChangeThreshold { dec_party_id, .. } + | Self::Contracts { dec_party_id, .. } => Some(dec_party_id), + Self::Onboarding { .. } | Self::Dars { .. } => None, + } + } + + /// The threshold the request asks for (the new one for changes). + pub fn threshold(&self) -> Option { + match self { + Self::Onboarding { threshold, .. } => *threshold, + Self::AddParty { new_threshold, .. } + | Self::Kick { new_threshold, .. } + | Self::ChangeThreshold { new_threshold, .. } => Some(*new_threshold), + Self::Contracts { .. } | Self::Dars { .. } => None, + } + } + + /// The threshold the request says the party has today; `0` means the + /// client did not know. + pub fn previous_threshold(&self) -> Option { + match self { + Self::AddParty { + previous_threshold, .. + } + | Self::Kick { + previous_threshold, .. + } + | Self::ChangeThreshold { + previous_threshold, .. + } => Some(*previous_threshold).filter(|t| *t > 0), + _ => None, + } + } +} + +/// A start refused before anything was written. The HTTP layer maps it to +/// `409 Conflict` with `message`; `peers` names the participants that +/// are not ready (design D3). +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreflightRejected { + pub message: String, + pub peers: Vec<(CantonId, String)>, +} + +impl PreflightRejected { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + peers: Vec::new(), + } + } + + pub fn peers(peers: Vec<(CantonId, String)>) -> Self { + let list: Vec = peers.iter().map(|(p, why)| format!("{p}: {why}")).collect(); + Self { + message: format!( + "cannot start: {} participant(s) are not ready for on-ledger coordination: {}", + peers.len(), + list.join("; ") + ), + peers, + } + } +} + +impl std::fmt::Display for PreflightRejected { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for PreflightRejected {} + +/// The proposer's own key material for the party (design D6). +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ProposerKeyMaterial { + pub namespace_fingerprint: Option, + pub signing_public_key_hex: Option, + pub daml_key_fingerprint: Option, +} + +/// What a kind contributes to the `WorkflowProposal` before it is created. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ProposalExtras { + pub keys: ProposerKeyMaterial, + pub dar_pins: Vec, + pub package_names: Vec, +} + +/// What `start_run` produced. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StartedRun { + pub instance_name: String, + pub proposal_cid: String, +} + +// --------------------------------------------------------------------------- +// Run metadata +// --------------------------------------------------------------------------- + +/// Which member steps a peer row follows (design section 6). +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum MemberVariant { + /// The add-party participant being added. + Joiner, + /// Any other invitee. + Member, +} + +impl MemberVariant { + pub fn as_str(self) -> &'static str { + match self { + Self::Joiner => "Joiner", + Self::Member => "Member", + } + } +} + +/// The on-ledger fields of a run row. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RunMeta { + pub proposal_cid: String, + pub coordinator_party: CantonId, + pub coordinator_participant: CantonId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub member_variant: Option, + /// Topology transaction hashes this run pinned, by mapping (`dnd`, + /// `p2p`, `clear`). + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub topology_hashes: BTreeMap, +} + +/// The on-ledger fields of a run, or `None` for a row the engine does not +/// drive. +pub fn read_run_meta(run: &WorkflowRun) -> Option { + let value: serde_json::Value = serde_json::from_str(&run.config_json).ok()?; + serde_json::from_value(value.get(RUN_META_KEY)?.clone()).ok() +} + +fn with_meta(config_json: &str, meta: &RunMeta) -> Result { + let mut value: serde_json::Value = + serde_json::from_str(config_json).unwrap_or_else(|_| serde_json::json!({})); + if !value.is_object() { + value = serde_json::json!({}); + } + value[RUN_META_KEY] = serde_json::to_value(meta).context("encode run meta")?; + serde_json::to_string(&value).context("encode config_json") +} + +/// Replace a run's on-ledger fields. +/// +/// # Errors +/// Returns an error when the row is missing or the write fails. +pub async fn write_run_meta(db: &SqlitePool, instance_name: &str, meta: &RunMeta) -> Result<()> { + let Some(mut run) = db.get_workflow_run(instance_name).await? else { + bail!("workflow run {instance_name} not found"); + }; + run.config_json = with_meta(&run.config_json, meta)?; + run.updated_at = now_secs(); + let mut tx = db.begin_transaction().await?; + tx.upsert_workflow_run(&run).await?; + Commitable::commit(tx).await +} + +/// Pin a topology transaction hash on a run (`key` is `dnd`, `p2p`, or +/// `clear`). +/// +/// # Errors +/// Returns an error when the row is missing, has no meta, or the write fails. +pub async fn pin_topology_hash( + db: &SqlitePool, + instance_name: &str, + key: &str, + hash_hex: &str, +) -> Result<()> { + let Some(run) = db.get_workflow_run(instance_name).await? else { + bail!("workflow run {instance_name} not found"); + }; + let Some(mut meta) = read_run_meta(&run) else { + bail!("workflow run {instance_name} carries no on-ledger meta"); + }; + meta.topology_hashes + .insert(key.to_string(), hash_hex.to_string()); + write_run_meta(db, instance_name, &meta).await +} + +// --------------------------------------------------------------------------- +// Steps and quorum +// --------------------------------------------------------------------------- + +/// The step list of a run by kind, role, and member variant (section 6). +pub fn steps_for( + kind: WorkflowKind, + role: WorkflowRole, + variant: Option, +) -> &'static [&'static str] { + macro_rules! pick { + ($driver:ty) => { + match role { + WorkflowRole::Coordinator => <$driver>::coordinator_steps(), + WorkflowRole::Peer => <$driver>::member_steps(variant), + } + }; + } + match kind { + WorkflowKind::Onboarding => pick!(onboarding::Onboarding), + WorkflowKind::AddParty => pick!(add_party::AddParty), + WorkflowKind::Kick => pick!(kick::Kick), + WorkflowKind::ChangeThreshold => pick!(change_threshold::ChangeThreshold), + WorkflowKind::Contracts => pick!(contracts::Contracts), + WorkflowKind::Dars => pick!(dars::Dars), + } +} + +/// Owner signatures a kick or change-threshold mapping needs: the larger of +/// the threshold in force and the threshold proposed, because Canton +/// authorizes the change with the stored threshold and the result must be +/// reachable afterwards (section 6). +pub fn required_owner_signatures(previous_threshold: u32, new_threshold: u32) -> u32 { + previous_threshold.max(new_threshold).max(1) +} + +/// Counted acceptances the coordinator waits for before proposing: +/// [`required_owner_signatures`] minus its own signature. +pub fn acceptances_needed(previous_threshold: u32, new_threshold: u32) -> u32 { + required_owner_signatures(previous_threshold, new_threshold).saturating_sub(1) +} + +/// Whether a kind needs every invitee (onboarding, add-party, contracts, +/// dars) or an owner quorum (kick, change-threshold). +pub fn needs_every_invitee(kind: WorkflowKind) -> bool { + !matches!(kind, WorkflowKind::Kick | WorkflowKind::ChangeThreshold) +} + +// --------------------------------------------------------------------------- +// Row helpers for the drivers +// --------------------------------------------------------------------------- + +/// Move a run to `step`, keeping its completed peers. +/// +/// # Errors +/// Returns an error when `step` is not in the run's step list or the write +/// fails. +pub async fn advance_step(db: &SqlitePool, run: &WorkflowRun, step: &str) -> Result<()> { + let variant = read_run_meta(run).and_then(|m| m.member_variant); + let steps = steps_for(run.kind, run.role, variant); + let Some(index) = steps.iter().position(|s| *s == step) else { + bail!( + "{} is not a step of a {} {} run ({steps:?})", + step, + run.role, + run.kind + ); + }; + let mut tx = db.begin_transaction().await?; + tx.update_workflow_run_step( + &run.instance_name, + step, + i64::try_from(index).unwrap_or(0), + &run.completed_peers, + now_secs(), + ) + .await?; + Commitable::commit(tx).await?; + tracing::info!(instance = %run.instance_name, step, "run advanced"); + Ok(()) +} + +async fn set_status( + db: &SqlitePool, + instance_name: &str, + status: WorkflowProgress, + error: Option<&str>, +) -> Result<()> { + let mut tx = db.begin_transaction().await?; + tx.set_workflow_run_status(instance_name, status, error, now_secs()) + .await?; + Commitable::commit(tx).await +} + +/// Mark a run `Failed` with `error`. The UI shows the text unchanged. +/// +/// # Errors +/// Returns an error when the write fails. +pub async fn fail_run(db: &SqlitePool, run: &WorkflowRun, error: &str) -> Result<()> { + tracing::warn!(instance = %run.instance_name, error, "run failed"); + set_status( + db, + &run.instance_name, + WorkflowProgress::Failed, + Some(error), + ) + .await +} + +/// Mark a run `Cancelled` with `reason`. +/// +/// # Errors +/// Returns an error when the write fails. +pub async fn cancel_run_row(db: &SqlitePool, run: &WorkflowRun, reason: &str) -> Result<()> { + tracing::info!(instance = %run.instance_name, reason, "run cancelled"); + set_status( + db, + &run.instance_name, + WorkflowProgress::Cancelled, + Some(reason), + ) + .await +} + +/// Move a run to `Complete` and mark it `Completed`. +/// +/// # Errors +/// Returns an error when the write fails. +pub async fn complete_run(db: &SqlitePool, run: &WorkflowRun) -> Result<()> { + advance_step(db, run, COMPLETE_STEP).await?; + set_status(db, &run.instance_name, WorkflowProgress::Completed, None).await +} + +// --------------------------------------------------------------------------- +// The tick context and the driver contract +// --------------------------------------------------------------------------- + +/// Everything the observer read this tick about proposals. +#[derive(Clone, Debug, Default)] +pub struct ProposalSnapshot { + pub proposals: Vec, + pub acceptances: Vec, + pub declines: Vec, + pub outcomes: Vec, + pub decisions: Vec, +} + +impl ProposalSnapshot { + /// One ACS pass per template plus the local decisions table. + /// + /// # Errors + /// Returns an error when a read fails. + pub async fn read(client: &CoordinationClient, db: &SqlitePool) -> Result { + Ok(Self { + proposals: client.list_active::().await?, + acceptances: client.list_active::().await?, + declines: client.list_active::().await?, + outcomes: client.list_active::().await?, + decisions: db.get_all_proposal_decisions().await?, + }) + } + + pub fn proposal(&self, cid: &str) -> Option<&ActiveProposal> { + self.proposals.iter().find(|p| p.contract_id == cid) + } + + /// Raw acceptances of one proposal; run `proposals::counted_acceptances` + /// before trusting them. + pub fn acceptances_for(&self, cid: &str) -> Vec { + self.acceptances + .iter() + .filter(|a| a.record.proposal == cid) + .cloned() + .collect() + } + + pub fn declines_for(&self, cid: &str) -> Vec<&Decline> { + self.declines + .iter() + .filter(|d| d.record.proposal == cid) + .collect() + } + + /// The outcome a proposer published for one of its runs. + pub fn outcome_for(&self, proposer: &CantonId, run_id: &str) -> Option<&Outcome> { + self.outcomes + .iter() + .find(|o| o.record.proposer == *proposer && o.record.run_id == run_id) + } + + pub fn decision(&self, cid: &str) -> Option<&ProposalDecisionEntry> { + self.decisions.iter().find(|d| d.proposal_cid == cid) + } + + /// Proposals that invite `me` and that `me` did not create. + pub fn for_me(&self, me: &CantonId) -> Vec { + self.proposals + .iter() + .filter(|p| p.record.proposer != *me && p.record.invitees.contains(me)) + .cloned() + .collect() + } + + pub fn active_cids(&self) -> HashSet { + self.proposals + .iter() + .map(|p| p.contract_id.clone()) + .collect() + } +} + +/// What one observer tick hands to a driver. +pub struct TickCtx<'a> { + pub ol: &'a OnLedger, + pub client: &'a CoordinationClient, + pub identity: &'a NodeIdentity, + pub sync_id: String, + pub participant_id: CantonId, + pub proposals: &'a ProposalSnapshot, + pub registry: &'a PeerHealthSnapshot, + /// Micros since the epoch at the start of the tick. + pub now_micros: i64, +} + +impl TickCtx<'_> { + pub fn db(&self) -> &SqlitePool { + self.ol.db() + } + + /// Whether a proposal has passed its `expiresAt`. + pub fn is_expired(&self, proposal: &ActiveProposal) -> bool { + proposal.record.expires_at <= self.now_micros + } +} + +/// One workflow kind's step machine, both sides. Every method is static so +/// the observer dispatches without an instance. +/// +/// A tick does one bounded piece of work for the run's current step and +/// returns. It re-reads the row and the proposal before every ledger or +/// topology write (design D5 step 5). It reports validation failures with +/// [`fail_run`] and lets transient errors surface as `Err`, which the +/// observer logs and retries next tick. +#[allow(async_fn_in_trait)] +pub trait KindDriver { + fn kind() -> WorkflowKind; + + /// `current_step` values of the coordinator row, in order. + fn coordinator_steps() -> &'static [&'static str]; + + /// `current_step` values of a peer row, in order, by variant. + fn member_steps(variant: Option) -> &'static [&'static str]; + + /// Kind-specific start refusals (section 6 preflight). Return a + /// [`PreflightRejected`] through `anyhow` for a 409. + async fn preflight(ol: &OnLedger, req: &StartRequest) -> Result<()> { + let _ = (ol, req); + Ok(()) + } + + /// The proposer's key material, DAR pins, and package names for the + /// `WorkflowProposal`. Runs before the proposal is created. + async fn prepare(ol: &OnLedger, req: &StartRequest) -> Result { + let _ = (ol, req); + Ok(ProposalExtras::default()) + } + + async fn tick_coordinator(ctx: &TickCtx<'_>, run: &WorkflowRun, meta: &RunMeta) -> Result<()>; + + async fn tick_member(ctx: &TickCtx<'_>, run: &WorkflowRun, meta: &RunMeta) -> Result<()>; +} + +/// A stub tick: log at debug level and return, so a run that reaches an +/// unimplemented step stays `InProgress` without noise in the logs. +pub(crate) fn not_implemented(run: &WorkflowRun) { + tracing::debug!( + instance = %run.instance_name, + kind = %run.kind, + role = %run.role, + step = %run.current_step, + "step machine not implemented yet" + ); +} + +/// What [`drive`] did with a row. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Driven { + /// Not an on-ledger row. + Skipped, + /// The generic reconciliation ended the run this tick. + Stopped, + /// The kind driver ran. + Ticked, +} + +fn describe_declines(declines: &[&Decline]) -> String { + declines + .iter() + .map(|d| { + format!( + "{} declined the invitation: {}", + d.record.decliner, d.record.reason + ) + }) + .collect::>() + .join("; ") +} + +/// Generic transitions that hold for every kind (design D10) before the +/// kind driver runs: the proposal vanished, expired, or was declined. +async fn reconcile(ctx: &TickCtx<'_>, run: &WorkflowRun, meta: &RunMeta) -> Result { + let db = ctx.db(); + let proposal = ctx.proposals.proposal(&meta.proposal_cid); + match run.role { + WorkflowRole::Coordinator => { + let Some(proposal) = proposal else { + // A row is written only after `submit_and_wait` returned, so a + // missing proposal is a real loss, not a race, once the row + // is older than a couple of polls. + let grace = i64::try_from(consts::observer_poll_secs()).unwrap_or(3) * 2; + if now_secs() - run.created_at < grace { + return Ok(false); + } + fail_run( + db, + run, + &format!( + "WorkflowProposal {} is no longer active on the ledger", + meta.proposal_cid + ), + ) + .await?; + return Ok(false); + }; + if ctx.is_expired(proposal) { + let msg = "the WorkflowProposal expired before the run finished".to_string(); + fail_run(db, run, &msg).await?; + finish_best_effort(ctx.client, &meta.proposal_cid, Some(msg)).await; + return Ok(false); + } + let declines = ctx.proposals.declines_for(&meta.proposal_cid); + if declines.is_empty() { + return Ok(true); + } + let fail = if needs_every_invitee(run.kind) { + true + } else { + let decliners: BTreeSet<&CantonId> = + declines.iter().map(|d| &d.record.decliner).collect(); + let remaining = proposal + .record + .invitees + .iter() + .filter(|i| !decliners.contains(i)) + .count(); + let previous = + u32::try_from(proposal.record.previous_threshold.unwrap_or(0)).unwrap_or(0); + let new = u32::try_from(proposal.record.threshold.unwrap_or(0)).unwrap_or(0); + let needed = acceptances_needed(previous, new); + u32::try_from(remaining).unwrap_or(u32::MAX) < needed + }; + if fail { + let msg = describe_declines(&declines); + fail_run(db, run, &msg).await?; + finish_best_effort(ctx.client, &meta.proposal_cid, Some(msg)).await; + return Ok(false); + } + Ok(true) + } + WorkflowRole::Peer => { + let Some(proposal) = proposal else { + let run_id = run.coordinator_instance.as_deref().unwrap_or_default(); + match ctx.proposals.outcome_for(&meta.coordinator_party, run_id) { + Some(o) if o.record.succeeded => { + complete_run(db, run).await?; + } + Some(o) => { + let why = o + .record + .error + .clone() + .unwrap_or_else(|| "the proposer reported a failure".into()); + cancel_run_row(db, run, &format!("cancelled by the proposer: {why}")) + .await?; + } + None => { + cancel_run_row(db, run, "the proposer withdrew the WorkflowProposal") + .await?; + } + } + return Ok(false); + }; + if ctx.is_expired(proposal) { + fail_run( + db, + run, + "the WorkflowProposal expired before the run finished", + ) + .await?; + return Ok(false); + } + Ok(true) + } + } +} + +async fn finish_best_effort(client: &CoordinationClient, cid: &str, error: Option) { + if let Err(e) = proposals::finish(client, cid, false, error).await { + tracing::warn!(proposal = %cid, error = %e, "WorkflowProposal_Finish failed; retry next tick"); + } +} + +/// Drive one in-progress row for one tick: generic reconciliation, then the +/// kind driver for the row's role. +/// +/// # Errors +/// Returns an error when a read or write fails; the observer logs it and +/// tries again next tick. +pub async fn drive(ctx: &TickCtx<'_>, run: &WorkflowRun) -> Result { + let Some(meta) = read_run_meta(run) else { + return Ok(Driven::Skipped); + }; + if !reconcile(ctx, run, &meta).await? { + return Ok(Driven::Stopped); + } + macro_rules! tick { + ($driver:ty) => { + match run.role { + WorkflowRole::Coordinator => <$driver>::tick_coordinator(ctx, run, &meta).await?, + WorkflowRole::Peer => <$driver>::tick_member(ctx, run, &meta).await?, + } + }; + } + match run.kind { + WorkflowKind::Onboarding => tick!(onboarding::Onboarding), + WorkflowKind::AddParty => tick!(add_party::AddParty), + WorkflowKind::Kick => tick!(kick::Kick), + WorkflowKind::ChangeThreshold => tick!(change_threshold::ChangeThreshold), + WorkflowKind::Contracts => tick!(contracts::Contracts), + WorkflowKind::Dars => tick!(dars::Dars), + } + Ok(Driven::Ticked) +} + +// --------------------------------------------------------------------------- +// start_run +// --------------------------------------------------------------------------- + +/// The participants and head state a request resolves to. +#[derive(Clone, Debug, Default, PartialEq)] +struct ResolvedTargets { + /// Every participant of the resulting workflow, this node included. + participants: Vec, + /// The participants to invite: `participants` minus this node. + invitee_participants: Vec, + dec_party_id: Option, + prefix: Option, + threshold: Option, + previous_threshold: Option, + dnd_base_serial: Option, + p2p_base_serial: Option, + head: HeadState, +} + +fn sorted_unique(ids: impl IntoIterator) -> Vec { + let set: BTreeSet = ids.into_iter().collect(); + set.into_iter().collect() +} + +async fn resolve_targets( + ol: &OnLedger, + identity: &NodeIdentity, + sync_id: &str, + req: &StartRequest, +) -> Result { + let me = identity.participant_id.clone(); + let mut out = ResolvedTargets::default(); + + if let Some(party) = req.dec_party_id() { + let namespace = party.namespace.to_hex(); + let dnd = topology::read_accepted_dnd(ol.config(), sync_id, &namespace).await?; + let p2p = topology::read_accepted_p2p(ol.config(), sync_id, party).await?; + let Some(p2p) = p2p else { + bail!("{party} has no PartyToParticipant mapping in the synchronizer store"); + }; + let hosts: Vec = p2p + .mapping + .participants + .iter() + .filter_map(|h| CantonId::parse(&h.participant_uid).ok()) + .collect(); + if !hosts.contains(&me) { + bail!("this participant ({me}) does not host {party}"); + } + out.dec_party_id = Some(party.to_string()); + out.prefix = Some(party.prefix.clone()); + out.p2p_base_serial = Some(i64::from(p2p.serial)); + out.dnd_base_serial = dnd.as_ref().map(|d| i64::from(d.serial)); + out.previous_threshold = dnd + .as_ref() + .map(|d| i64::from(d.mapping.threshold)) + .or_else(|| req.previous_threshold().map(i64::from)); + out.head = HeadState { + dnd: dnd.map(|d| d.mapping), + p2p: Some(p2p.mapping.clone()), + }; + out.participants = match req { + StartRequest::AddParty { + new_participant_id, .. + } => sorted_unique(hosts.into_iter().chain([new_participant_id.clone()])), + StartRequest::Kick { participant_id, .. } => { + if !hosts.contains(participant_id) { + bail!("{participant_id} does not host {party}"); + } + sorted_unique(hosts.into_iter().filter(|h| h != participant_id)) + } + StartRequest::Contracts { + participant_ids, .. + } => sorted_unique(participant_ids.iter().cloned().chain([me.clone()])), + _ => sorted_unique(hosts), + }; + } else { + match req { + StartRequest::Onboarding { + party_id_prefix, + peer_ids, + .. + } => { + out.prefix = Some(party_id_prefix.clone()); + out.participants = sorted_unique(peer_ids.iter().cloned().chain([me.clone()])); + } + StartRequest::Dars { peer_ids, .. } => { + out.participants = sorted_unique(peer_ids.iter().cloned().chain([me.clone()])); + } + _ => unreachable!("kinds without a party are Onboarding and Dars"), + } + } + + out.threshold = match req { + StartRequest::Onboarding { threshold, .. } => { + Some(i64::from(threshold.unwrap_or_else(|| { + i32::try_from(out.participants.len().div_ceil(2).max(1)).unwrap_or(1) + }))) + } + _ => req.threshold().map(i64::from), + }; + + out.invitee_participants = out + .participants + .iter() + .filter(|p| **p != me) + .cloned() + .collect(); + if out.invitee_participants.is_empty() { + bail!("a workflow needs at least one other participant"); + } + Ok(out) +} + +/// Section 6 threshold gates shared by the topology kinds. +fn check_thresholds(req: &StartRequest, targets: &ResolvedTargets) -> Result<()> { + let owners = match req.kind() { + WorkflowKind::Onboarding => targets.participants.len(), + WorkflowKind::AddParty | WorkflowKind::Kick | WorkflowKind::ChangeThreshold => { + let head = targets + .head + .dnd + .as_ref() + .map(|d| d.owners.len()) + .unwrap_or(targets.participants.len()); + match req { + StartRequest::AddParty { .. } => head + 1, + StartRequest::Kick { .. } => head.saturating_sub(1), + _ => head, + } + } + _ => return Ok(()), + }; + let owners = i64::try_from(owners).unwrap_or(i64::MAX); + let Some(threshold) = targets.threshold else { + bail!("{} needs a threshold", req.kind()); + }; + if threshold < 1 || threshold > owners { + return Err(PreflightRejected::new(format!( + "threshold {threshold} is outside 1..={owners} for the resulting owner set" + )) + .into()); + } + if let StartRequest::Kick { .. } = req + && let Some(previous) = targets.previous_threshold + && previous > owners + { + return Err(PreflightRejected::new(format!( + "the current threshold {previous} cannot be met by the {owners} remaining owner(s); \ + lower the threshold before the kick" + )) + .into()); + } + Ok(()) +} + +/// Map invitee participants to their node parties through the peers table. +async fn invitee_parties(db: &SqlitePool, participants: &[CantonId]) -> Result> { + let peers = db.get_all_peers().await?; + let mut parties = Vec::with_capacity(participants.len()); + let mut missing = Vec::new(); + for p in participants { + let party = peers + .iter() + .find(|peer| peer.participant_id == *p) + .and_then(|peer| peer.party.as_deref()) + .and_then(|s| CantonId::parse(s).ok()); + match party { + Some(party) => parties.push(party), + None => missing.push((p.clone(), "no node party in the peers table".to_string())), + } + } + if !missing.is_empty() { + return Err(PreflightRejected::peers(missing).into()); + } + Ok(parties) +} + +fn describe(req: &StartRequest, targets: &ResolvedTargets) -> String { + match req { + StartRequest::Onboarding { + party_id_prefix, .. + } => format!( + "Create decentralized party {party_id_prefix} with {} members (threshold {})", + targets.participants.len(), + targets.threshold.unwrap_or(0) + ), + StartRequest::AddParty { + dec_party_id, + new_participant_id, + new_threshold, + .. + } => format!("Add {new_participant_id} to {dec_party_id} (threshold {new_threshold})"), + StartRequest::Kick { + dec_party_id, + participant_id, + new_threshold, + .. + } => format!("Remove {participant_id} from {dec_party_id} (threshold {new_threshold})"), + StartRequest::ChangeThreshold { + dec_party_id, + new_threshold, + .. + } => format!("Change the threshold of {dec_party_id} to {new_threshold}"), + StartRequest::Contracts { + dec_party_id, + contracts, + .. + } => format!("Create {} contract(s) for {dec_party_id}", contracts.len()), + StartRequest::Dars { dar_files, .. } => { + format!("Distribute {} DAR file(s)", dar_files.len()) + } + } +} + +/// The `config_json` of a coordinator row: the fields the API layer lifts +/// for the run card, plus [`RunMeta`]. +fn coordinator_config_json( + req: &StartRequest, + targets: &ResolvedTargets, + meta: &RunMeta, +) -> Result { + let participants = &targets.participants; + let base = match req { + StartRequest::Onboarding { + party_id_prefix, + instance_name, + .. + } => serde_json::json!({ + "party_id_prefix": party_id_prefix, + "instance_name": instance_name, + "threshold": targets.threshold, + "participants": participants, + }), + StartRequest::AddParty { + dec_party_id, + new_participant_id, + new_threshold, + instance_name, + .. + } => serde_json::json!({ + "decentralized_party_id": dec_party_id, + "new_participant_id": new_participant_id, + "new_threshold": new_threshold, + "previous_threshold": targets.previous_threshold, + "instance_name": instance_name, + "participants": participants, + }), + StartRequest::Kick { + dec_party_id, + participant_id, + new_threshold, + instance_name, + .. + } => serde_json::json!({ + "decentralized_party_id": dec_party_id, + "participant_id": participant_id, + "new_threshold": new_threshold, + "previous_threshold": targets.previous_threshold, + "instance_name": instance_name, + "participants": participants, + }), + StartRequest::ChangeThreshold { + dec_party_id, + new_threshold, + instance_name, + .. + } => serde_json::json!({ + "decentralized_party_id": dec_party_id, + "new_threshold": new_threshold, + "previous_threshold": targets.previous_threshold, + "instance_name": instance_name, + "participants": participants, + }), + StartRequest::Contracts { + dec_party_id, + participant_ids, + participant_parties, + operator_party, + contracts, + instance_name, + } => serde_json::json!({ + "decentralized_party_id": dec_party_id, + "participant_ids": participant_ids, + "participant_parties": participant_parties, + "operator_party": operator_party, + "contracts": contracts, + "instance_name": instance_name, + "participants": participants, + }), + // DAR bytes stay out of the row: the handler uploads them locally + // and the pins on the proposal identify them. + StartRequest::Dars { + dar_files, + peer_ids, + instance_name, + } => serde_json::json!({ + "dar_filenames": dar_files.iter().map(|d| d.filename.clone()).collect::>(), + "peer_ids": peer_ids, + "instance_name": instance_name, + "participants": participants, + }), + }; + with_meta(&base.to_string(), meta) +} + +async fn dispatch_preflight(ol: &OnLedger, req: &StartRequest) -> Result<()> { + match req.kind() { + WorkflowKind::Onboarding => onboarding::Onboarding::preflight(ol, req).await, + WorkflowKind::AddParty => add_party::AddParty::preflight(ol, req).await, + WorkflowKind::Kick => kick::Kick::preflight(ol, req).await, + WorkflowKind::ChangeThreshold => { + change_threshold::ChangeThreshold::preflight(ol, req).await + } + WorkflowKind::Contracts => contracts::Contracts::preflight(ol, req).await, + WorkflowKind::Dars => dars::Dars::preflight(ol, req).await, + } +} + +async fn dispatch_prepare(ol: &OnLedger, req: &StartRequest) -> Result { + match req.kind() { + WorkflowKind::Onboarding => onboarding::Onboarding::prepare(ol, req).await, + WorkflowKind::AddParty => add_party::AddParty::prepare(ol, req).await, + WorkflowKind::Kick => kick::Kick::prepare(ol, req).await, + WorkflowKind::ChangeThreshold => change_threshold::ChangeThreshold::prepare(ol, req).await, + WorkflowKind::Contracts => contracts::Contracts::prepare(ol, req).await, + WorkflowKind::Dars => dars::Dars::prepare(ol, req).await, + } +} + +/// Start a run as the coordinator (design D6): +/// +/// 1. resolve the participants from the request and the head state; +/// 2. preflight: every invitee has a node party, has vetted the package, +/// has a registry entry at this coordination version +/// (`registry::preflight_unready_peers`), thresholds are in range, and +/// the kind's own gates pass; +/// 3. the kind prepares the proposer's key material and pins; +/// 4. create the `WorkflowProposal` with the base serials read in step 1; +/// 5. persist the coordinator row at its first step, with `RunMeta`, only +/// after `submit_and_wait` returned. +/// +/// # Errors +/// Returns [`PreflightRejected`] (through `anyhow`, downcast for a 409) when +/// a gate fails, and any other error when a read or write fails. +pub async fn start_run(ol: &OnLedger, req: StartRequest) -> Result { + let identity = ol.require_identity().await?; + let client = ol.client().await?; + let sync_id = utils::get_synchronizer_id(ol.config()).await?; + let instance_name = req.instance_name().to_string(); + + if ol.db().get_workflow_run(&instance_name).await?.is_some() { + return Err(PreflightRejected::new(format!( + "a workflow run named {instance_name} already exists" + )) + .into()); + } + + let targets = resolve_targets(ol, &identity, &sync_id, &req).await?; + check_thresholds(&req, &targets)?; + + let unready = registry::preflight_unready_peers( + &ol.registry_snapshot().await, + &targets.invitee_participants, + ); + if !unready.is_empty() { + return Err(PreflightRejected::peers(unready).into()); + } + let invitees = invitee_parties(ol.db(), &targets.invitee_participants).await?; + dispatch_preflight(ol, &req).await?; + + let extras = dispatch_prepare(ol, &req).await?; + + let (created_at, expires_at) = proposals::proposal_lifetime(now_micros()); + let record = WorkflowProposalRecord { + proposer: identity.node_party.clone(), + proposer_participant: identity.participant_id.to_string(), + proposer_namespace_fingerprint: extras.keys.namespace_fingerprint, + proposer_signing_public_key_hex: extras.keys.signing_public_key_hex, + proposer_daml_key_fingerprint: extras.keys.daml_key_fingerprint, + run_id: instance_name.clone(), + kind: req.kind(), + invitees, + participants: targets + .participants + .iter() + .map(CantonId::to_string) + .collect(), + dec_party_id: targets.dec_party_id.clone(), + prefix: targets.prefix.clone(), + threshold: targets.threshold, + previous_threshold: targets.previous_threshold, + dnd_base_serial: targets.dnd_base_serial, + p2p_base_serial: targets.p2p_base_serial, + new_participant: match &req { + StartRequest::AddParty { + new_participant_id, .. + } => Some(new_participant_id.to_string()), + _ => None, + }, + kicked_participant: match &req { + StartRequest::Kick { participant_id, .. } => Some(participant_id.to_string()), + _ => None, + }, + dar_pins: extras.dar_pins, + package_names: extras.package_names, + description: describe(&req, &targets), + created_at, + expires_at, + }; + let proposal_cid = proposals::create_proposal(&client, &record).await?; + + let meta = RunMeta { + proposal_cid: proposal_cid.clone(), + coordinator_party: identity.node_party.clone(), + coordinator_participant: identity.participant_id.clone(), + member_variant: None, + topology_hashes: BTreeMap::new(), + }; + let steps = steps_for(req.kind(), WorkflowRole::Coordinator, None); + let now = now_secs(); + let run = WorkflowRun { + instance_name: instance_name.clone(), + kind: req.kind(), + role: WorkflowRole::Coordinator, + status: WorkflowProgress::InProgress, + current_step: steps[0].to_string(), + step_index: 0, + step_total: i64::try_from(steps.len()).unwrap_or(0), + config_json: coordinator_config_json(&req, &targets, &meta)?, + // TODO(migration 000021): `coordinator_participant`. Until then the + // old column carries this node's participant id. + coordinator_pubkey: Some(identity.participant_id.to_string()), + coordinator_instance: None, + coordinator_name: None, + expected_peers: targets.invitee_participants.clone(), + completed_peers: Vec::new(), + connected_peers: Vec::new(), + acs_progress: None, + dec_party_id: targets + .dec_party_id + .as_deref() + .and_then(|s| CantonId::parse(s).ok()), + prefix: None, + participants: Vec::new(), + previous_threshold: None, + new_threshold: None, + kicked_participant: None, + added_participant: None, + package_names: Vec::new(), + dar_filenames: Vec::new(), + error: None, + dismissed: false, + created_at: now, + updated_at: now, + }; + let mut tx = ol.db().begin_transaction().await?; + tx.upsert_workflow_run(&run).await?; + Commitable::commit(tx).await?; + tracing::info!(instance = %instance_name, proposal = %proposal_cid, kind = %req.kind(), "run started"); + Ok(StartedRun { + instance_name, + proposal_cid, + }) +} + +// --------------------------------------------------------------------------- +// Invitee actions +// --------------------------------------------------------------------------- + +/// The peer `instance_name`: `peer-{kind}-{coordinator_participant_short}-{run_id}`. +pub fn peer_instance_name( + kind: WorkflowKind, + coordinator_participant: &str, + run_id: &str, +) -> String { + let short: String = coordinator_participant.chars().take(16).collect(); + format!("peer-{}-{short}-{run_id}", kind.as_str().to_lowercase()) +} + +/// The variant of this node in a proposal: the add-party joiner, or a +/// member. +pub fn member_variant_for( + proposal: &WorkflowProposalRecord, + me: &CantonId, +) -> Option { + match proposal.kind { + WorkflowKind::AddParty => Some( + if proposal.new_participant.as_deref() == Some(me.to_string().as_str()) { + MemberVariant::Joiner + } else { + MemberVariant::Member + }, + ), + _ => None, + } +} + +/// The `config_json` of a peer row: the invitation fields the API layer +/// lifts for the run card, plus [`RunMeta`]. +fn peer_config_json(proposal: &WorkflowProposalRecord, meta: &RunMeta) -> Result { + let base = serde_json::json!({ + "prefix": proposal.prefix, + "participants": proposal.participants, + "dar_filenames": proposal.dar_pins.iter().map(|p| p.filename.clone()).collect::>(), + "dar_hashes": proposal.dar_pins.iter().map(|p| p.sha256_hex.clone()).collect::>(), + "participant_id": proposal.kicked_participant, + "new_participant_id": proposal.new_participant, + "new_threshold": proposal.threshold, + "previous_threshold": proposal.previous_threshold, + "package_names": proposal.package_names, + }); + with_meta(&base.to_string(), meta) +} + +/// What `accept_invitation` produced. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AcceptedInvitation { + pub instance_name: String, + pub member_variant: Option, +} + +async fn find_invitation(ol: &OnLedger, proposal_cid: &str) -> Result { + let client = ol.client().await?; + let Some(proposal) = proposals::read_proposal(&client, proposal_cid).await? else { + bail!("WorkflowProposal {proposal_cid} is not active or not visible to this node"); + }; + let me = client.node_party(); + if proposal.record.proposer == *me { + bail!("WorkflowProposal {proposal_cid} was created by this node"); + } + if !proposal.record.invitees.contains(me) { + bail!("WorkflowProposal {proposal_cid} does not invite this node"); + } + Ok(proposal) +} + +/// Accept an invitation (design D6): record the decision, create the peer +/// run row at its first step, and drop the invitation card. The observer +/// generates keys, exercises `WorkflowProposal_Accept`, and drives the run. +/// +/// Idempotent: a second accept of the same proposal returns the same row. +/// +/// # Errors +/// Returns an error when the proposal is not an active invitation for this +/// node, when it has expired, when it was declined or dismissed before, or +/// when a write fails. +pub async fn accept_invitation(ol: &OnLedger, proposal_cid: &str) -> Result { + let identity = ol.require_identity().await?; + let proposal = find_invitation(ol, proposal_cid).await?; + if proposal.record.expires_at <= now_micros() { + bail!("WorkflowProposal {proposal_cid} has expired"); + } + let record = &proposal.record; + let coordinator_participant = CantonId::parse(&record.proposer_participant) + .with_context(|| format!("proposerParticipant `{}`", record.proposer_participant))?; + let member_variant = member_variant_for(record, &identity.participant_id); + let instance_name = + peer_instance_name(record.kind, &record.proposer_participant, &record.run_id); + + let db = ol.db(); + let mut tx = db.begin_transaction().await?; + let fresh = tx + .insert_proposal_decision(&ProposalDecisionEntry { + proposal_cid: proposal_cid.to_string(), + decision: ProposalDecision::Accepted, + decided_at: now_secs(), + pinned_hashes: Vec::new(), + }) + .await?; + if !fresh { + Commitable::commit(tx).await?; + let existing = db.get_proposal_decision(proposal_cid).await?; + match existing.map(|d| d.decision) { + Some(ProposalDecision::Accepted) => { + return Ok(AcceptedInvitation { + instance_name, + member_variant, + }); + } + Some(other) => bail!("WorkflowProposal {proposal_cid} was already {other}"), + None => bail!("proposal_decisions row for {proposal_cid} vanished"), + } + } + + let meta = RunMeta { + proposal_cid: proposal_cid.to_string(), + coordinator_party: record.proposer.clone(), + coordinator_participant: coordinator_participant.clone(), + member_variant, + topology_hashes: BTreeMap::new(), + }; + let steps = steps_for(record.kind, WorkflowRole::Peer, member_variant); + let now = now_secs(); + let run = WorkflowRun { + instance_name: instance_name.clone(), + kind: record.kind, + role: WorkflowRole::Peer, + status: WorkflowProgress::InProgress, + current_step: steps[0].to_string(), + step_index: 0, + step_total: i64::try_from(steps.len()).unwrap_or(0), + config_json: peer_config_json(record, &meta)?, + // TODO(migration 000021): `coordinator_participant`. + coordinator_pubkey: Some(record.proposer_participant.clone()), + coordinator_instance: Some(record.run_id.clone()), + coordinator_name: None, + expected_peers: record + .participants + .iter() + .filter_map(|p| CantonId::parse(p).ok()) + .collect(), + completed_peers: Vec::new(), + connected_peers: Vec::new(), + acs_progress: None, + dec_party_id: record + .dec_party_id + .as_deref() + .and_then(|s| CantonId::parse(s).ok()), + prefix: None, + participants: Vec::new(), + previous_threshold: None, + new_threshold: None, + kicked_participant: None, + added_participant: None, + package_names: Vec::new(), + dar_filenames: Vec::new(), + error: None, + dismissed: false, + created_at: now, + updated_at: now, + }; + tx.upsert_workflow_run(&run).await?; + tx.delete_pending_invitation(proposal_cid).await?; + Commitable::commit(tx).await?; + ol.remove_pending_invitation(proposal_cid).await; + tracing::info!(instance = %instance_name, proposal = %proposal_cid, "invitation accepted"); + Ok(AcceptedInvitation { + instance_name, + member_variant, + }) +} + +/// Decline an invitation (design D10): record the decision, exercise +/// `WorkflowProposal_Decline`, and drop the invitation card. +/// +/// # Errors +/// Returns an error when the proposal is not an active invitation for this +/// node, when it was accepted before, or when a write fails. +pub async fn decline_invitation(ol: &OnLedger, proposal_cid: &str, reason: &str) -> Result { + let client = ol.client().await?; + find_invitation(ol, proposal_cid).await?; + let db = ol.db(); + let mut tx = db.begin_transaction().await?; + let fresh = tx + .insert_proposal_decision(&ProposalDecisionEntry { + proposal_cid: proposal_cid.to_string(), + decision: ProposalDecision::Declined, + decided_at: now_secs(), + pinned_hashes: Vec::new(), + }) + .await?; + if !fresh { + let existing = db.get_proposal_decision(proposal_cid).await?; + match existing.map(|d| d.decision) { + Some(ProposalDecision::Accepted) => { + bail!( + "WorkflowProposal {proposal_cid} was already accepted; cancel the run instead" + ) + } + // A dismissed card can still be declined on the ledger. + Some(ProposalDecision::Dismissed) => { + tx.update_proposal_decision(proposal_cid, ProposalDecision::Declined, now_secs()) + .await?; + } + Some(ProposalDecision::Declined) | None => {} + } + } + tx.delete_pending_invitation(proposal_cid).await?; + Commitable::commit(tx).await?; + ol.remove_pending_invitation(proposal_cid).await; + let cid = proposals::decline(&client, proposal_cid, reason).await?; + tracing::info!(proposal = %proposal_cid, decline = %cid, "invitation declined"); + Ok(cid) +} + +// --------------------------------------------------------------------------- +// Row operations +// --------------------------------------------------------------------------- + +/// Cancel a run (design D10). The row is marked `Cancelled` first so a +/// restart cannot resume it; a coordinator then exercises +/// `WorkflowProposal_Cancel` so invitees stop co-signing. A topology +/// proposal that already reached its threshold still becomes effective. +/// +/// # Errors +/// Returns an error when the run is missing or not in progress, or when the +/// row write fails. A failed `Cancel` exercise is logged, not returned: the +/// proposal expires on its own. +pub async fn cancel_run(ol: &OnLedger, instance_name: &str) -> Result<()> { + let db = ol.db(); + let Some(run) = db.get_workflow_run(instance_name).await? else { + bail!("workflow run {instance_name} not found"); + }; + if run.status != WorkflowProgress::InProgress { + bail!( + "workflow run {instance_name} is {}, not in progress", + run.status + ); + } + cancel_run_row(db, &run, "cancelled by the operator").await?; + if run.role == WorkflowRole::Coordinator + && let Some(meta) = read_run_meta(&run) + { + match ol.client().await { + Ok(client) => { + if let Err(e) = proposals::cancel(&client, &meta.proposal_cid).await { + tracing::warn!( + proposal = %meta.proposal_cid, + error = %e, + "WorkflowProposal_Cancel failed; the proposal expires on its own" + ); + } + } + Err(e) => tracing::warn!(error = %e, "no client to cancel the WorkflowProposal"), + } + } + Ok(()) +} + +/// Retry a failed run (design D10): flip `Failed` to `InProgress` and let +/// the observer's ensure semantics take over. Nothing is broadcast. +/// +/// # Errors +/// Returns an error when the run is missing, is not `Failed`, or carries no +/// on-ledger meta. +pub async fn retry_run(ol: &OnLedger, instance_name: &str) -> Result<()> { + let db = ol.db(); + let Some(run) = db.get_workflow_run(instance_name).await? else { + bail!("workflow run {instance_name} not found"); + }; + if run.status != WorkflowProgress::Failed { + bail!("workflow run {instance_name} is {}, not failed", run.status); + } + if read_run_meta(&run).is_none() { + bail!("workflow run {instance_name} predates on-ledger coordination and cannot be retried"); + } + set_status(db, instance_name, WorkflowProgress::InProgress, None).await?; + tracing::info!(instance = %instance_name, "run retried"); + Ok(()) +} + +/// Build the `WorkflowProposal_Accept` arguments for this node. +pub fn accept_args( + identity: &NodeIdentity, + keys: &ProposerKeyMaterial, + member_party: Option, +) -> AcceptArgs { + AcceptArgs { + acceptor: identity.node_party.clone(), + participant_id: identity.participant_id.to_string(), + namespace_fingerprint: keys.namespace_fingerprint.clone(), + signing_public_key_hex: keys.signing_public_key_hex.clone(), + daml_key_fingerprint: keys.daml_key_fingerprint.clone(), + member_party, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::onledger::daml::codec::tests::{party, proposal_full}; + + const NS: &str = "1220c4010d6883f367c7f45d55b2449501620130f9b21e96379f17dea455ac7a5892"; + + fn participant(n: u8) -> CantonId { + CantonId::parse(&format!("participant{n}::{NS}")).expect("id") + } + + fn meta() -> RunMeta { + RunMeta { + proposal_cid: "00proposal".into(), + coordinator_party: party("node-a"), + coordinator_participant: participant(1), + member_variant: Some(MemberVariant::Joiner), + topology_hashes: [("dnd".to_string(), "1220ab".to_string())] + .into_iter() + .collect(), + } + } + + fn run_with(config_json: &str) -> WorkflowRun { + WorkflowRun { + instance_name: "cbtc-creation".into(), + kind: WorkflowKind::Onboarding, + role: WorkflowRole::Coordinator, + status: WorkflowProgress::InProgress, + current_step: "GenerateKeys".into(), + step_index: 0, + step_total: 7, + config_json: config_json.into(), + coordinator_pubkey: None, + coordinator_instance: None, + coordinator_name: None, + expected_peers: vec![], + completed_peers: vec![], + connected_peers: vec![], + acs_progress: None, + dec_party_id: None, + prefix: None, + participants: vec![], + previous_threshold: None, + new_threshold: None, + kicked_participant: None, + added_participant: None, + package_names: vec![], + dar_filenames: vec![], + error: None, + dismissed: false, + created_at: 0, + updated_at: 0, + } + } + + #[test] + fn quorum_is_the_larger_threshold() { + // 4 owners, previous 3, new 2, kick one: wait for 3 signatures. + assert_eq!(required_owner_signatures(3, 2), 3); + assert_eq!(acceptances_needed(3, 2), 2); + assert_eq!(required_owner_signatures(2, 3), 3); + assert_eq!(required_owner_signatures(0, 0), 1); + assert_eq!(acceptances_needed(0, 0), 0); + } + + #[test] + fn every_invitee_kinds_are_the_non_quorum_ones() { + assert!(needs_every_invitee(WorkflowKind::Onboarding)); + assert!(needs_every_invitee(WorkflowKind::AddParty)); + assert!(needs_every_invitee(WorkflowKind::Contracts)); + assert!(needs_every_invitee(WorkflowKind::Dars)); + assert!(!needs_every_invitee(WorkflowKind::Kick)); + assert!(!needs_every_invitee(WorkflowKind::ChangeThreshold)); + } + + #[test] + fn run_meta_round_trips_through_config_json_and_keeps_other_fields() { + let json = with_meta(r#"{"party_id_prefix":"cbtc","threshold":2}"#, &meta()).expect("json"); + let value: serde_json::Value = serde_json::from_str(&json).expect("value"); + assert_eq!(value["party_id_prefix"], "cbtc"); + assert_eq!(value["threshold"], 2); + let run = run_with(&json); + assert_eq!(read_run_meta(&run), Some(meta())); + assert!(read_run_meta(&run_with(r#"{"party_id_prefix":"cbtc"}"#)).is_none()); + assert!(read_run_meta(&run_with("not json")).is_none()); + // A non-object config is replaced, not appended to. + let repaired = with_meta("[1,2]", &meta()).expect("json"); + assert!(read_run_meta(&run_with(&repaired)).is_some()); + } + + #[test] + fn step_lists_match_design_section_6() { + assert_eq!( + steps_for(WorkflowKind::Onboarding, WorkflowRole::Coordinator, None), + [ + "GenerateKeys", + "WaitingForAcceptances", + "ProposeNamespace", + "AwaitNamespace", + "ProposeParty", + "AwaitParty", + "Complete" + ] + ); + assert_eq!( + steps_for(WorkflowKind::Onboarding, WorkflowRole::Peer, None), + ["GenerateKeys", "CoSignNamespace", "CoSignParty", "Complete"] + ); + assert_eq!( + steps_for(WorkflowKind::AddParty, WorkflowRole::Coordinator, None), + [ + "GenerateKeys", + "WaitingForAcceptances", + "ProposeChanges", + "AwaitChanges", + "AwaitReplication", + "Complete" + ] + ); + assert_eq!( + steps_for( + WorkflowKind::AddParty, + WorkflowRole::Peer, + Some(MemberVariant::Joiner) + ), + [ + "GenerateKeys", + "CoSignChanges", + "SyncAcs", + "ClearOnboarding", + "Complete" + ] + ); + assert_eq!( + steps_for( + WorkflowKind::AddParty, + WorkflowRole::Peer, + Some(MemberVariant::Member) + ), + ["CoSignChanges", "PublishManifest", "Complete"] + ); + for kind in [WorkflowKind::Kick, WorkflowKind::ChangeThreshold] { + assert_eq!( + steps_for(kind, WorkflowRole::Coordinator, None), + [ + "WaitingForAcceptances", + "ProposeChanges", + "AwaitChanges", + "Complete" + ] + ); + assert_eq!( + steps_for(kind, WorkflowRole::Peer, None), + ["CoSignChanges", "Complete"] + ); + } + assert_eq!( + steps_for(WorkflowKind::Contracts, WorkflowRole::Coordinator, None), + [ + "WaitingForAcceptances", + "AwaitDars", + "PrepareSubmissions", + "CollectSignatures", + "ExecuteSubmissions", + "Complete" + ] + ); + assert_eq!( + steps_for(WorkflowKind::Contracts, WorkflowRole::Peer, None), + ["UploadDars", "SignSubmissions", "Complete"] + ); + assert_eq!( + steps_for(WorkflowKind::Dars, WorkflowRole::Coordinator, None), + ["WaitingForAcceptances", "AwaitVetting", "Complete"] + ); + assert_eq!( + steps_for(WorkflowKind::Dars, WorkflowRole::Peer, None), + ["UploadDars", "Complete"] + ); + } + + #[test] + fn every_step_list_ends_with_complete() { + for kind in [ + WorkflowKind::Onboarding, + WorkflowKind::AddParty, + WorkflowKind::Kick, + WorkflowKind::ChangeThreshold, + WorkflowKind::Contracts, + WorkflowKind::Dars, + ] { + for (role, variant) in [ + (WorkflowRole::Coordinator, None), + (WorkflowRole::Peer, None), + (WorkflowRole::Peer, Some(MemberVariant::Joiner)), + (WorkflowRole::Peer, Some(MemberVariant::Member)), + ] { + let steps = steps_for(kind, role, variant); + assert_eq!( + steps.last(), + Some(&COMPLETE_STEP), + "{kind} {role} {variant:?}" + ); + } + } + } + + #[test] + fn peer_instance_name_follows_the_design_shape() { + let name = peer_instance_name( + WorkflowKind::ChangeThreshold, + &participant(1).to_string(), + "cbtc-change-threshold-1", + ); + assert_eq!( + name, + "peer-changethreshold-participant1::12-cbtc-change-threshold-1" + ); + } + + #[test] + fn member_variant_is_joiner_only_for_the_add_party_new_participant() { + let mut p = proposal_full(); + p.kind = WorkflowKind::AddParty; + p.new_participant = Some(participant(4).to_string()); + assert_eq!( + member_variant_for(&p, &participant(4)), + Some(MemberVariant::Joiner) + ); + assert_eq!( + member_variant_for(&p, &participant(2)), + Some(MemberVariant::Member) + ); + p.kind = WorkflowKind::Kick; + assert_eq!(member_variant_for(&p, &participant(4)), None); + } + + #[test] + fn start_request_accessors() { + let req = StartRequest::Kick { + dec_party_id: CantonId::parse(&format!("cbtc::{NS}")).expect("id"), + participant_id: participant(3), + new_threshold: 2, + previous_threshold: 0, + instance_name: "cbtc-kick-1".into(), + }; + assert_eq!(req.kind(), WorkflowKind::Kick); + assert_eq!(req.instance_name(), "cbtc-kick-1"); + assert!(req.dec_party_id().is_some()); + assert_eq!(req.threshold(), Some(2)); + assert_eq!(req.previous_threshold(), None, "0 means unknown"); + } + + #[test] + fn preflight_rejection_names_every_peer() { + let err = PreflightRejected::peers(vec![ + ( + participant(2), + "has not vetted the coordination package".into(), + ), + (participant(3), "no registry entry visible".into()), + ]); + assert!(err.message.contains("participant2")); + assert!(err.message.contains("participant3")); + let any: anyhow::Error = err.clone().into(); + assert_eq!(any.downcast_ref::(), Some(&err)); + } + + #[test] + fn threshold_gates_refuse_out_of_range_and_unreachable_kicks() { + let party_id = CantonId::parse(&format!("cbtc::{NS}")).expect("id"); + let kick = StartRequest::Kick { + dec_party_id: party_id.clone(), + participant_id: participant(3), + new_threshold: 2, + previous_threshold: 3, + instance_name: "k".into(), + }; + let head_dnd = topology::dnd_of(&topology::build_dnd( + &["a".into(), "b".into(), "c".into()], + 3, + )) + .expect("dnd") + .clone(); + let targets = ResolvedTargets { + participants: vec![participant(1), participant(2)], + threshold: Some(2), + previous_threshold: Some(3), + head: HeadState { + dnd: Some(head_dnd.clone()), + p2p: None, + }, + ..Default::default() + }; + let err = check_thresholds(&kick, &targets).expect_err("previous 3 > 2 owners"); + assert!(err.downcast_ref::().is_some(), "{err}"); + + let ok_targets = ResolvedTargets { + previous_threshold: Some(2), + ..targets.clone() + }; + check_thresholds(&kick, &ok_targets).expect("previous 2 fits 2 owners"); + + let too_high = ResolvedTargets { + threshold: Some(3), + previous_threshold: Some(2), + ..targets + }; + let err = check_thresholds(&kick, &too_high).expect_err("threshold 3 > 2 owners"); + assert!(err.to_string().contains("outside 1..=2"), "{err}"); + + let onboarding = StartRequest::Onboarding { + party_id_prefix: "cbtc".into(), + peer_ids: vec![participant(2), participant(3)], + threshold: Some(4), + instance_name: "o".into(), + }; + let targets = ResolvedTargets { + participants: vec![participant(1), participant(2), participant(3)], + threshold: Some(4), + ..Default::default() + }; + assert!(check_thresholds(&onboarding, &targets).is_err()); + } + + #[test] + fn coordinator_config_json_carries_the_card_fields_and_the_meta() { + let req = StartRequest::AddParty { + dec_party_id: CantonId::parse(&format!("cbtc::{NS}")).expect("id"), + new_participant_id: participant(4), + new_threshold: 3, + previous_threshold: 2, + instance_name: "cbtc-add-party-1".into(), + }; + let targets = ResolvedTargets { + participants: vec![participant(1), participant(2), participant(4)], + previous_threshold: Some(2), + threshold: Some(3), + ..Default::default() + }; + let json = coordinator_config_json(&req, &targets, &meta()).expect("json"); + let value: serde_json::Value = serde_json::from_str(&json).expect("value"); + assert_eq!(value["new_threshold"], 3); + assert_eq!(value["previous_threshold"], 2); + assert_eq!(value["participants"].as_array().map(Vec::len), Some(3)); + assert_eq!(value[RUN_META_KEY]["proposal_cid"], "00proposal"); + assert_eq!(value[RUN_META_KEY]["member_variant"], "Joiner"); + } + + #[test] + fn peer_config_json_mirrors_the_invitation_fields() { + let json = peer_config_json(&proposal_full(), &meta()).expect("json"); + let value: serde_json::Value = serde_json::from_str(&json).expect("value"); + assert_eq!(value["prefix"], "cbtc"); + assert_eq!(value["dar_filenames"][0], "governance-core-v1-0.1.0.dar"); + assert_eq!(value["dar_hashes"][0], "deadbeef"); + assert_eq!(value["new_threshold"], 2); + assert_eq!(value["previous_threshold"], 3); + assert_eq!(value["package_names"][0], "governance-core-v1"); + assert_eq!( + value[RUN_META_KEY]["coordinator_party"], + party("node-a").to_string() + ); + } + + #[test] + fn describe_declines_names_every_decliner() { + let d = |who: &str, why: &str| Decline { + contract_id: "00d".into(), + offset: 1, + record: WorkflowDeclineRecord { + proposal: "00proposal".into(), + proposer: party("node-a"), + decliner: party(who), + observers: vec![], + run_id: "r".into(), + reason: why.into(), + declined_at: 1, + }, + }; + let text = describe_declines(&[&d("node-b", "busy"), &d("node-c", "no")]); + assert!(text.contains("node-b")); + assert!(text.contains("busy")); + assert!(text.contains("node-c")); + } + + #[test] + fn snapshot_lookups_filter_by_contract_and_run() { + let mut p = ActiveProposal { + contract_id: "00proposal".into(), + offset: 1, + record: proposal_full(), + }; + p.record.invitees = vec![party("node-b")]; + let snapshot = ProposalSnapshot { + proposals: vec![p], + outcomes: vec![Outcome { + contract_id: "00o".into(), + offset: 2, + record: WorkflowOutcomeRecord { + proposer: party("node-a"), + run_id: "cbtc-creation".into(), + kind: WorkflowKind::Onboarding, + observers: vec![], + succeeded: true, + error: None, + finished_at: 3, + }, + }], + ..Default::default() + }; + assert!(snapshot.proposal("00proposal").is_some()); + assert!(snapshot.proposal("00other").is_none()); + assert_eq!(snapshot.for_me(&party("node-b")).len(), 1); + assert!(snapshot.for_me(&party("node-a")).is_empty()); + assert!(snapshot.for_me(&party("node-z")).is_empty()); + assert!( + snapshot + .outcome_for(&party("node-a"), "cbtc-creation") + .is_some() + ); + assert!(snapshot.outcome_for(&party("node-a"), "other").is_none()); + assert_eq!( + snapshot.active_cids(), + ["00proposal".to_string()].into_iter().collect() + ); + } +} diff --git a/crates/decman/src/onledger/engine/onboarding.rs b/crates/decman/src/onledger/engine/onboarding.rs new file mode 100644 index 000000000..e54449e47 --- /dev/null +++ b/crates/decman/src/onledger/engine/onboarding.rs @@ -0,0 +1,58 @@ +//! Onboarding driver (design section 6): create a new decentralized party. +//! +//! Stub. The step bodies land with the onboarding agent; every tick logs +//! and returns so the observer loop keeps running. + +use anyhow::{Result, bail}; +use common::types::{WorkflowKind, WorkflowRun}; + +use super::{KindDriver, MemberVariant, OnLedger, ProposalExtras, RunMeta, StartRequest, TickCtx}; + +pub struct Onboarding; + +pub const COORDINATOR_STEPS: &[&str] = &[ + "GenerateKeys", + "WaitingForAcceptances", + "ProposeNamespace", + "AwaitNamespace", + "ProposeParty", + "AwaitParty", + "Complete", +]; + +pub const MEMBER_STEPS: &[&str] = &["GenerateKeys", "CoSignNamespace", "CoSignParty", "Complete"]; + +impl KindDriver for Onboarding { + fn kind() -> WorkflowKind { + WorkflowKind::Onboarding + } + + fn coordinator_steps() -> &'static [&'static str] { + COORDINATOR_STEPS + } + + fn member_steps(_variant: Option) -> &'static [&'static str] { + MEMBER_STEPS + } + + /// TODO(onboarding agent): generate the dual-usage `{prefix}-key`, publish + /// its root `NamespaceDelegation`, and return the fingerprint, the + /// serialized public key as hex, and the Daml key fingerprint (design D4). + async fn prepare(_ol: &OnLedger, _req: &StartRequest) -> Result { + bail!("not implemented: onboarding proposer key material (design D4)") + } + + async fn tick_coordinator( + _ctx: &TickCtx<'_>, + run: &WorkflowRun, + _meta: &RunMeta, + ) -> Result<()> { + super::not_implemented(run); + Ok(()) + } + + async fn tick_member(_ctx: &TickCtx<'_>, run: &WorkflowRun, _meta: &RunMeta) -> Result<()> { + super::not_implemented(run); + Ok(()) + } +} diff --git a/crates/decman/src/onledger/identity.rs b/crates/decman/src/onledger/identity.rs new file mode 100644 index 000000000..c42724a95 --- /dev/null +++ b/crates/decman/src/onledger/identity.rs @@ -0,0 +1,351 @@ +//! The node identity (design D1) and the hosting check (design D2). +//! +//! A node identity is the `party_credentials` row with `kind = 'node'`. Its +//! node party signs every coordination contract this node publishes. The +//! token comes from the same `AuthRegistry` the decparty rows use, keyed by +//! the node party, so no second auth path exists. + +use std::{fmt, sync::Arc}; + +use anyhow::{Context, Result, anyhow, bail}; +use canton_proto_rs::com::digitalasset::canton::protocol::v30::PartyToParticipant; +use common::{canton_id::CantonId, types::Permission}; + +use crate::{ + auth::{MockTokenManager, TokenManager, WorkflowAuth}, + config::{CredentialKind, NodeConfig, PartyCredentials}, + server::permission_from_proto, + workflow::external_party::add_hosts::read_party_to_participant, +}; + +/// Where the node party's Ledger API token comes from. +#[derive(Clone)] +pub enum NodeTokenSource { + /// A real IdP (Keycloak or Auth0) through the shared `AuthRegistry`. + Keycloak(Arc), + /// The unsafe HS256 token of insecure/test mode. + Mock(Arc), +} + +impl NodeTokenSource { + /// A fresh bearer token for the node party. + /// + /// # Errors + /// Returns an error when the IdP refuses to mint or refresh. + pub async fn token(&self) -> Result { + match self { + Self::Keycloak(tm) => tm.get_token().await.context("node identity token"), + Self::Mock(mm) => Ok(mm.get_token()), + } + } +} + +/// This node's on-ledger identity: the node party, the participant that +/// hosts it, and a token source that can act as it. +#[derive(Clone)] +pub struct NodeIdentity { + pub node_party: CantonId, + pub participant_id: CantonId, + /// The Ledger API user behind the token; informational. + pub user_id: String, + tokens: NodeTokenSource, +} + +impl NodeIdentity { + /// Assemble an identity from its parts. Callers outside this module + /// normally go through [`load_node_identity`]. + pub fn new( + node_party: CantonId, + participant_id: CantonId, + user_id: String, + tokens: NodeTokenSource, + ) -> Self { + Self { + node_party, + participant_id, + user_id, + tokens, + } + } + + /// A fresh bearer token for the node party. + /// + /// # Errors + /// Returns an error when the IdP refuses to mint or refresh. + pub async fn token(&self) -> Result { + self.tokens.token().await + } + + pub fn tokens(&self) -> &NodeTokenSource { + &self.tokens + } +} + +impl fmt::Debug for NodeIdentity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("NodeIdentity") + .field("node_party", &self.node_party) + .field("participant_id", &self.participant_id) + .field("user_id", &self.user_id) + .finish_non_exhaustive() + } +} + +/// The `kind = 'node'` row, if any. At most one is expected; the first wins. +pub fn node_credentials(rows: &[PartyCredentials]) -> Option<&PartyCredentials> { + rows.iter().find(|r| r.kind == CredentialKind::Node) +} + +/// Build the node identity from the credentials rows and the live auth +/// registry. +/// +/// Returns `Ok(None)` when no `kind = 'node'` row exists. Returns an error +/// when a row exists but no token manager backs it, because a node that +/// believes it has an identity but cannot act as it must fail loudly. +/// +/// # Errors +/// See above; also when `config` has no resolved participant id. +pub async fn load_node_identity( + config: &NodeConfig, + rows: &[PartyCredentials], + auth: Option<&WorkflowAuth>, +) -> Result> { + let Some(row) = node_credentials(rows) else { + return Ok(None); + }; + if row.member_party_id != row.dec_party_id { + tracing::warn!( + dec_party_id = %row.dec_party_id, + member_party_id = %row.member_party_id, + "node identity row has differing dec_party_id and member_party_id; \ + using member_party_id as the node party" + ); + } + let Some(participant_id) = config.node.participant_id.clone() else { + bail!("participant id not resolved; cannot build the node identity"); + }; + let tokens = match auth { + Some(WorkflowAuth::Keycloak(registry)) => { + let tm = registry.get(&row.dec_party_id).ok_or_else(|| { + anyhow!( + "node identity {} has no token manager; check its IdP credentials", + row.dec_party_id + ) + })?; + NodeTokenSource::Keycloak(tm) + } + Some(WorkflowAuth::Mock(registry)) => { + NodeTokenSource::Mock(registry.get(&row.dec_party_id).await) + } + None => bail!( + "node identity {} is configured but the auth registry is not initialised", + row.dec_party_id + ), + }; + Ok(Some(NodeIdentity::new( + row.member_party_id.clone(), + participant_id, + row.user_id.clone(), + tokens, + ))) +} + +/// The identity, or the one error message every caller shows when it is +/// missing. +/// +/// # Errors +/// Returns an error when `identity` is `None`. +pub fn require_node_identity(identity: Option<&NodeIdentity>) -> Result<&NodeIdentity> { + identity.ok_or_else(|| { + anyhow!("node identity not configured; set one with PUT /node-identity first") + }) +} + +/// How one participant hosts one party, read from the synchronizer head state. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HostingCheck { + /// Whether an authorized `PartyToParticipant` exists at all. + pub mapping_exists: bool, + /// Whether the participant appears among the party's hosts. + pub hosted: bool, + /// The participant's permission, when hosted. + pub permission: Option, + /// Whether the participant still carries the `onboarding` marker. + pub onboarding: bool, + /// The party's hosting threshold (0 when no mapping exists). + pub threshold: u32, +} + +impl HostingCheck { + /// The one predicate the design uses before naming a party as observer + /// or accepting its acceptance: hosted with Submission permission. + pub fn has_submission(&self) -> bool { + self.hosted && self.permission == Some(Permission::Submission) + } + + /// A short human-readable reason for a rejection. + pub fn describe(&self) -> String { + if !self.mapping_exists { + return "no PartyToParticipant mapping in the synchronizer head state".to_string(); + } + if !self.hosted { + return "the participant does not host the party".to_string(); + } + match &self.permission { + Some(p) => format!("hosted with {} permission", p.as_str()), + None => "hosted with unknown permission".to_string(), + } + } +} + +/// Pure half of [`verify_hosting`]: evaluate a mapping against a participant. +pub fn hosting_check_from_mapping( + mapping: Option<&PartyToParticipant>, + participant: &CantonId, +) -> HostingCheck { + let Some(mapping) = mapping else { + return HostingCheck { + mapping_exists: false, + hosted: false, + permission: None, + onboarding: false, + threshold: 0, + }; + }; + let participant_uid = participant.to_string(); + let host = mapping + .participants + .iter() + .find(|h| h.participant_uid == participant_uid); + HostingCheck { + mapping_exists: true, + hosted: host.is_some(), + permission: host.map(|h| permission_from_proto(h.permission)), + onboarding: host.is_some_and(|h| h.onboarding.is_some()), + threshold: mapping.threshold, + } +} + +/// Read the head-state `PartyToParticipant` of `party` over the tokenless +/// Admin API and report how `participant` hosts it. +/// +/// `read_party_to_participant` already requires `mapping.party == party`, so +/// a prefix match on another party can never pass. +/// +/// # Errors +/// Returns an error when the topology read fails. +pub async fn verify_hosting( + config: &NodeConfig, + party: &CantonId, + participant: &CantonId, +) -> Result { + let current = read_party_to_participant(config, &party.to_string()) + .await + .with_context(|| format!("read PartyToParticipant for {party}"))?; + Ok(hosting_check_from_mapping( + current.as_ref().map(|c| &c.mapping), + participant, + )) +} + +#[cfg(test)] +pub(crate) mod tests { + use canton_proto_rs::com::digitalasset::canton::protocol::v30::{ + enums::ParticipantPermission, party_to_participant::HostingParticipant, + }; + + use super::*; + + const NS: &str = "1220c4010d6883f367c7f45d55b2449501620130f9b21e96379f17dea455ac7a5892"; + + fn id(prefix: &str) -> CantonId { + CantonId::parse(&format!("{prefix}::{NS}")).expect("valid id") + } + + /// An identity backed by the mock token, for tests in sibling modules. + pub(crate) async fn mock_identity(node_prefix: &str, participant: CantonId) -> NodeIdentity { + let registry = crate::auth::MockAuthRegistry::new(Default::default()); + NodeIdentity::new( + id(node_prefix), + participant, + "ledger-api-user".into(), + NodeTokenSource::Mock(registry.get_by_str("").await), + ) + } + + fn mapping(hosts: &[(&str, ParticipantPermission)]) -> PartyToParticipant { + PartyToParticipant { + party: id("node-a").to_string(), + threshold: 1, + participants: hosts + .iter() + .map(|(p, perm)| HostingParticipant { + participant_uid: id(p).to_string(), + permission: *perm as i32, + onboarding: None, + }) + .collect(), + party_signing_keys: None, + } + } + + #[test] + fn submission_host_passes_the_check() { + let m = mapping(&[("participant1", ParticipantPermission::Submission)]); + let check = hosting_check_from_mapping(Some(&m), &id("participant1")); + assert!(check.has_submission()); + assert_eq!(check.permission, Some(Permission::Submission)); + assert_eq!(check.threshold, 1); + } + + #[test] + fn confirmation_only_host_fails_the_check() { + let m = mapping(&[("participant1", ParticipantPermission::Confirmation)]); + let check = hosting_check_from_mapping(Some(&m), &id("participant1")); + assert!(check.hosted); + assert!(!check.has_submission()); + assert!(check.describe().contains("confirmation")); + } + + #[test] + fn a_different_participant_is_not_hosted() { + let m = mapping(&[("participant1", ParticipantPermission::Submission)]); + let check = hosting_check_from_mapping(Some(&m), &id("participant2")); + assert!(check.mapping_exists); + assert!(!check.hosted); + assert!(!check.has_submission()); + } + + #[test] + fn no_mapping_is_not_hosted() { + let check = hosting_check_from_mapping(None, &id("participant1")); + assert!(!check.mapping_exists); + assert!(!check.has_submission()); + assert!(check.describe().contains("no PartyToParticipant")); + } + + #[test] + fn node_credentials_picks_the_node_row_only() { + let mut dec = PartyCredentials { + kind: CredentialKind::Decparty, + dec_party_id: id("cbtc"), + member_party_id: id("member"), + user_id: "u".into(), + keycloak: Default::default(), + auth0: None, + packages: Default::default(), + }; + assert!(node_credentials(std::slice::from_ref(&dec)).is_none()); + dec.kind = CredentialKind::Node; + assert_eq!( + node_credentials(std::slice::from_ref(&dec)).map(|r| &r.dec_party_id), + Some(&id("cbtc")) + ); + } + + #[test] + fn require_node_identity_names_the_fix() { + let err = require_node_identity(None).expect_err("missing"); + assert!(err.to_string().contains("PUT /node-identity")); + } +} diff --git a/crates/decman/src/onledger/mod.rs b/crates/decman/src/onledger/mod.rs new file mode 100644 index 000000000..47f040e30 --- /dev/null +++ b/crates/decman/src/onledger/mod.rs @@ -0,0 +1,393 @@ +//! Canton-native coordination: nodes coordinate only through the +//! synchronizer topology store, Daml contracts, and the Ledger API. +//! +//! This module is the on-ledger foundation (design section 4): +//! +//! * [`identity`]: the node party from `party_credentials` (`kind = 'node'`) +//! and the topology hosting check every trust decision uses. +//! * [`daml`]: template ids, the record codec, and the client that submits +//! and reads as the node party. +//! * [`registry`]: the `DecmanNode` entry, peer entries keyed by signatory, +//! the health snapshot, and the version gate. +//! * [`proposals`]: `WorkflowProposal` create/accept/decline/cancel/finish, +//! the D6 counting predicates, and the `pending_invitations` projection. +//! * [`topology`]: proposal discovery, propose, co-sign by hash, waits, root +//! delegations, and the canonical mapping builders. +//! * [`validation`]: what a member checks before it co-signs. +//! * [`engine`]: start, accept, decline, cancel, retry, and the per-kind +//! [`engine::KindDriver`] contract the observer dispatches to. +//! * [`observer`]: the one polling loop that drives every run. +//! * [`submission`], [`dars`], [`acs`]: the contracts, DAR, and ACS +//! contracts (signatures final, bodies pending). +//! +//! [`OnLedger`] is the facade `AppState` holds. See `README.md` in this +//! directory for every public signature. + +pub mod acs; +pub mod daml; +pub mod dars; +pub mod engine; +pub mod identity; +pub mod observer; +pub mod proposals; +pub mod registry; +pub mod submission; +pub mod topology; +pub mod validation; + +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +use anyhow::Result; +use common::{coordination::RegistryResponse, types::PendingInvitation}; +use sqlx::SqlitePool; +use tokio::sync::{Mutex, RwLock}; + +use crate::{ + auth::WorkflowAuth, + config::{NodeConfig, PartyCredentials}, + consts, + db::schema::SchemaRead, +}; + +pub use daml::{CoordinationClient, CoordinationPackage, CoordinationTemplate}; +pub use engine::{ + AcceptedInvitation, KindDriver, MemberVariant, PreflightRejected, RunMeta, StartRequest, + StartedRun, TickCtx, accept_invitation, cancel_run, decline_invitation, retry_run, start_run, +}; +pub use identity::{HostingCheck, NodeIdentity, require_node_identity, verify_hosting}; +pub use observer::spawn_observer; +pub use registry::{PeerHealth, PeerHealthSnapshot, PublishOutcome}; +pub use topology::UnsolicitedProposal; + +/// Micros since the epoch, the Ledger API `Timestamp` unit. +pub fn now_micros() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|d| i64::try_from(d.as_micros()).ok()) + .unwrap_or(0) +} + +/// Unix seconds. +pub fn now_secs() -> i64 { + now_micros().div_euclid(1_000_000) +} + +/// The facade `AppState` holds: config, database, live auth, the node +/// identity, and the caches the observer loop refreshes (registry snapshot, +/// pending invitations, unsolicited proposals). +/// +/// `auth` and `party_credentials` are the same `Arc`s `AppState` holds, so a +/// `PUT /party-config` or `PUT /node-identity` followed by +/// [`OnLedger::reload_identity`] picks up the new token manager. +pub struct OnLedger { + config: NodeConfig, + db: SqlitePool, + auth: Arc>>, + party_credentials: Arc>>, + test_mode: bool, + package: CoordinationPackage, + identity: RwLock>, + registry: Arc>, + /// The projected invitation cards, replaced every observer tick. + pending_invitations: RwLock>, + /// Pending topology proposals nobody asked this node about; UI only. + unsolicited: RwLock>, + /// One lock per in-progress run, so one driver at a time touches it. + run_locks: Mutex>>>, +} + +impl OnLedger { + /// Build the facade and load the node identity once. A missing identity + /// is normal on a fresh node; a broken one is logged and left unset so + /// the server still starts. + pub async fn new( + config: NodeConfig, + db: SqlitePool, + auth: Arc>>, + party_credentials: Arc>>, + test_mode: bool, + ) -> Arc { + let this = Self::detached(config, db, auth, party_credentials, test_mode); + match this.reload_identity().await { + Ok(Some(identity)) => tracing::info!( + node_party = %identity.node_party, + participant = %identity.participant_id, + "node identity loaded" + ), + Ok(None) => tracing::info!( + "no node identity configured; on-ledger coordination waits for PUT /node-identity" + ), + Err(e) => tracing::error!(error = %e, "node identity failed to load"), + } + this + } + + /// Build the facade without touching the identity. For tests and for + /// `AppState` literals that never coordinate on-ledger. + pub fn detached( + config: NodeConfig, + db: SqlitePool, + auth: Arc>>, + party_credentials: Arc>>, + test_mode: bool, + ) -> Arc { + Arc::new(Self { + config, + db, + auth, + party_credentials, + test_mode, + package: CoordinationPackage::from_env(), + identity: RwLock::new(None), + registry: Arc::new(RwLock::new(PeerHealthSnapshot::default())), + pending_invitations: RwLock::new(Vec::new()), + unsolicited: RwLock::new(Vec::new()), + run_locks: Mutex::new(HashMap::new()), + }) + } + + /// A facade on an in-memory database with no identity, for `AppState` + /// test literals. + #[cfg(test)] + pub(crate) fn placeholder() -> Arc { + Self::detached( + NodeConfig::default(), + SqlitePool::connect_lazy("sqlite::memory:").expect("lazy in-memory pool"), + Arc::new(RwLock::new(None)), + Arc::new(RwLock::new(Vec::new())), + true, + ) + } + + pub fn config(&self) -> &NodeConfig { + &self.config + } + + pub fn db(&self) -> &SqlitePool { + &self.db + } + + pub fn package(&self) -> &CoordinationPackage { + &self.package + } + + pub fn test_mode(&self) -> bool { + self.test_mode + } + + /// Re-read the `kind = 'node'` row and the live auth registry, and + /// replace the cached identity. Call after `reload_auth`. + /// + /// # Errors + /// Returns an error when a node row exists but no token manager backs it; + /// the cached identity is cleared in that case. + pub async fn reload_identity(&self) -> Result> { + let rows = self.party_credentials.read().await.clone(); + let auth = self.auth.read().await.clone(); + let loaded = identity::load_node_identity(&self.config, &rows, auth.as_ref()).await; + let mut slot = self.identity.write().await; + match loaded { + Ok(identity) => { + *slot = identity.clone(); + Ok(identity) + } + Err(e) => { + *slot = None; + Err(e) + } + } + } + + /// The cached identity, if configured and loaded. + pub async fn identity(&self) -> Option { + self.identity.read().await.clone() + } + + /// The cached identity, or the standard "configure it first" error. + /// + /// # Errors + /// Returns an error when no identity is loaded. + pub async fn require_identity(&self) -> Result { + let slot = self.identity.read().await; + require_node_identity(slot.as_ref()).cloned() + } + + /// A client bound to the current identity. + /// + /// # Errors + /// As [`OnLedger::require_identity`]. + pub async fn client(&self) -> Result { + let identity = self.require_identity().await?; + Ok(CoordinationClient::new( + self.config.clone(), + identity, + self.package.clone(), + self.test_mode, + )) + } + + /// The shared snapshot handle, for the observer loop and the status + /// handlers. + pub fn registry(&self) -> Arc> { + self.registry.clone() + } + + /// A copy of the last snapshot. + pub async fn registry_snapshot(&self) -> PeerHealthSnapshot { + self.registry.read().await.clone() + } + + /// The invitation cards the observer projected last (`GET /invitations`). + pub async fn pending_invitations(&self) -> Vec { + self.pending_invitations.read().await.clone() + } + + /// Replace the projected cards. The observer calls this every tick. + pub async fn set_pending_invitations(&self, list: Vec) { + *self.pending_invitations.write().await = list; + } + + /// Drop one card at once, so an accept or decline is visible before the + /// next tick re-projects. + pub async fn remove_pending_invitation(&self, proposal_cid: &str) { + self.pending_invitations + .write() + .await + .retain(|i| i.id != proposal_cid); + } + + /// The unsolicited proposals the observer scanned last + /// (`GET /proposals/unsolicited`). + pub async fn unsolicited_proposals(&self) -> Vec { + self.unsolicited.read().await.clone() + } + + /// Replace the unsolicited list. The observer calls this every minute. + pub async fn set_unsolicited(&self, list: Vec) { + *self.unsolicited.write().await = list; + } + + /// The lock of one run. Callers `try_lock` it and skip the run when it + /// is held (design D11). + pub async fn run_lock(&self, instance_name: &str) -> Arc> { + self.run_locks + .lock() + .await + .entry(instance_name.to_string()) + .or_default() + .clone() + } + + /// Forget the locks of runs that are no longer in progress. + pub async fn prune_run_locks(&self, live: &HashSet) { + self.run_locks + .lock() + .await + .retain(|name, _| live.contains(name)); + } + + /// The configured peers whose participant has vetted the coordination + /// package. + /// + /// # Errors + /// Returns an error when the package inventory read fails. + pub async fn vetted_peers(&self) -> Result> { + let peers = self.db.get_all_peers().await?; + registry::vetted_peers( + &self.config, + self.package.package_name(), + self.config.participant_id(), + &peers, + ) + .await + } + + /// Read every peer entry, cross-check hosting, rebuild the snapshot, and + /// store it. The observer loop calls this every tick. + /// + /// # Errors + /// Returns an error when no identity is loaded or a read fails. + pub async fn refresh_registry(&self) -> Result { + let client = self.client().await?; + let peers = self.db.get_all_peers().await?; + let vetted = registry::vetted_peers( + &self.config, + self.package.package_name(), + client.participant_id(), + &peers, + ) + .await?; + let entries = registry::read_peer_entries(&client, &self.config).await?; + let snapshot = registry::build_snapshot( + &peers, + client.participant_id(), + &entries, + &vetted, + now_micros(), + consts::peer_stale_factor(), + now_secs(), + ); + *self.registry.write().await = snapshot.clone(); + Ok(snapshot) + } + + /// Publish this node's `DecmanNode`, or update it when the desired + /// fields differ (design D3). + /// + /// # Errors + /// Returns an error when no identity is loaded or a ledger call fails. + pub async fn publish_registry_entry(&self) -> Result { + let client = self.client().await?; + let peers = self.db.get_all_peers().await?; + let vetted = registry::vetted_peers( + &self.config, + self.package.package_name(), + client.participant_id(), + &peers, + ) + .await?; + let desired = registry::desired_node_record( + &self.config, + client.identity(), + &peers, + &vetted, + now_micros(), + ); + registry::publish_or_update(&client, &desired).await + } + + /// Send a heartbeat when the entry's interval has elapsed. + /// + /// # Errors + /// Returns an error when no identity is loaded or a ledger call fails. + pub async fn heartbeat_if_due(&self) -> Result> { + let client = self.client().await?; + let Some(current) = registry::read_own_entry(&client).await? else { + return Ok(None); + }; + registry::heartbeat_if_due(&client, ¤t, now_micros()).await + } + + /// The `GET /registry` body: own entry, peers bucket, inbound bucket. + /// + /// # Errors + /// Returns an error when no identity is loaded or a read fails. + pub async fn read_registry(&self) -> Result { + let client = self.client().await?; + let peers = self.db.get_all_peers().await?; + let own = registry::read_own_entry(&client).await?; + let entries = registry::read_peer_entries(&client, &self.config).await?; + Ok(registry::registry_response( + own.as_ref(), + &entries, + &peers, + now_micros(), + consts::peer_stale_factor(), + )) + } +} diff --git a/crates/decman/src/onledger/observer.rs b/crates/decman/src/onledger/observer.rs new file mode 100644 index 000000000..bf1753292 --- /dev/null +++ b/crates/decman/src/onledger/observer.rs @@ -0,0 +1,372 @@ +//! The observer loop (design D5, D11): the one background task of on-ledger +//! coordination. +//! +//! Every `DECPM_OBSERVER_POLL_SECS` the loop refreshes the registry, keeps +//! this node's `DecmanNode` current, reads every coordination contract once, +//! projects invitations, and drives every in-progress run through its +//! [`KindDriver`](super::engine::KindDriver). Every minute it also refreshes +//! the unsolicited-proposal list and archives this node's stale acceptances +//! and declines. +//! +//! The loop never panics. A stage that fails is logged and counted, and the +//! tick moves on; the next tick retries. Ticks do not overlap +//! (`MissedTickBehavior::Skip`), and a per-run `try_lock` keeps any other +//! caller from driving the same run at the same time. + +use std::{ + collections::HashSet, + sync::{Arc, LazyLock}, + time::{Duration, Instant}, +}; + +use anyhow::Result; +use futures::future::join_all; +use prometheus::{Gauge, Histogram, IntCounter, IntCounterVec}; +use tokio::{sync::Mutex, task::JoinHandle}; + +use crate::{consts, db::schema::SchemaRead, utils}; + +use super::{ + OnLedger, + engine::{self, Driven, ProposalSnapshot, TickCtx}, + now_micros, proposals, topology, +}; + +// --------------------------------------------------------------------------- +// Metrics +// --------------------------------------------------------------------------- + +static TICKS: LazyLock = LazyLock::new(|| { + prometheus::register_int_counter!( + "decman_observer_tick_total", + "Observer ticks started, one per poll interval while the node lives." + ) + .expect("metric name is a unique literal") +}); + +static TICK_SECONDS: LazyLock = LazyLock::new(|| { + prometheus::register_histogram!( + "decman_observer_tick_seconds", + "Wall-clock duration of one observer tick." + ) + .expect("metric name is a unique literal") +}); + +static LAST_TICK_SECONDS: LazyLock = LazyLock::new(|| { + prometheus::register_gauge!( + "decman_observer_last_tick_seconds", + "Wall-clock duration of the most recent observer tick." + ) + .expect("metric name is a unique literal") +}); + +static RUNS_DRIVEN: LazyLock = LazyLock::new(|| { + prometheus::register_int_counter!( + "decman_observer_runs_driven_total", + "In-progress workflow runs handed to a kind driver." + ) + .expect("metric name is a unique literal") +}); + +static ERRORS: LazyLock = LazyLock::new(|| { + prometheus::register_int_counter_vec!( + "decman_observer_errors_total", + "Observer stages that ended in an error, by stage.", + &["stage"] + ) + .expect("metric name is a unique literal") +}); + +static NO_IDENTITY: LazyLock = LazyLock::new(|| { + prometheus::register_int_counter!( + "decman_observer_no_identity_total", + "Observer ticks skipped because no node identity is configured." + ) + .expect("metric name is a unique literal") +}); + +/// Register every family at startup, so a family exists before its first +/// event. +pub(crate) fn register_metrics() { + LazyLock::force(&TICKS); + LazyLock::force(&TICK_SECONDS); + LazyLock::force(&LAST_TICK_SECONDS); + LazyLock::force(&RUNS_DRIVEN); + LazyLock::force(&ERRORS); + LazyLock::force(&NO_IDENTITY); + for stage in [ + "identity", + "registry", + "runs", + "proposals", + "projection", + "synchronizer", + "drive", + "unsolicited", + "archive", + ] { + ERRORS.with_label_values(&[stage]); + } +} + +/// Log and count a failed stage, returning its value when it succeeded. +fn note(stage: &'static str, result: Result) -> Option { + match result { + Ok(v) => Some(v), + Err(e) => { + ERRORS.with_label_values(&[stage]).inc(); + tracing::warn!(stage, error = %format!("{e:#}"), "observer stage failed"); + None + } + } +} + +// --------------------------------------------------------------------------- +// Loop +// --------------------------------------------------------------------------- + +/// Start the observer loop on the runtime and return its handle. The loop +/// runs until the process exits. +pub fn spawn_observer(ol: Arc) -> JoinHandle<()> { + register_metrics(); + tokio::spawn(run_observer_loop(ol)) +} + +/// Cross-tick state. +#[derive(Debug, Default)] +pub struct ObserverState { + last_unsolicited_scan: Option, +} + +/// The loop body of [`spawn_observer`]; public so a test harness can run it +/// inline. +pub async fn run_observer_loop(ol: Arc) { + let poll = Duration::from_secs(consts::observer_poll_secs()); + let mut interval = tokio::time::interval(poll); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut state = ObserverState::default(); + tracing::info!(poll_secs = poll.as_secs(), "observer loop started"); + loop { + interval.tick().await; + TICKS.inc(); + let started = Instant::now(); + tick(&ol, &mut state).await; + let elapsed = started.elapsed().as_secs_f64(); + TICK_SECONDS.observe(elapsed); + LAST_TICK_SECONDS.set(elapsed); + } +} + +/// What one tick did, for logs and tests. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct TickReport { + pub had_identity: bool, + pub runs_seen: usize, + pub runs_driven: usize, + pub runs_busy: usize, + pub runs_stopped: usize, + pub invitations: usize, +} + +/// One observer tick. Never panics; every stage is logged and counted. +pub async fn tick(ol: &OnLedger, state: &mut ObserverState) -> TickReport { + let mut report = TickReport::default(); + + // 1. Identity. Without one there is nothing to sign with; re-load it + // each tick so `PUT /node-identity` takes effect without a restart. + let identity = match ol.identity().await { + Some(i) => i, + None => match note("identity", ol.reload_identity().await) { + Some(Some(i)) => i, + _ => { + NO_IDENTITY.inc(); + tracing::debug!("observer idle: no node identity configured"); + return report; + } + }, + }; + report.had_identity = true; + let Some(client) = note("identity", ol.client().await) else { + return report; + }; + let db = ol.db(); + + // 2. Registry: snapshot for the status handlers and the preflight gate, + // own entry current, heartbeat when due. + note("registry", ol.refresh_registry().await); + if let Some(outcome) = note("registry", ol.publish_registry_entry().await) { + match outcome { + super::PublishOutcome::Created(cid) => { + tracing::info!(contract_id = %cid, "published DecmanNode") + } + super::PublishOutcome::Updated(cid) => { + tracing::info!(contract_id = %cid, "updated DecmanNode") + } + super::PublishOutcome::Unchanged(_) => {} + } + } + if let Some(Some(cid)) = note("registry", ol.heartbeat_if_due().await) { + tracing::debug!(contract_id = %cid, "heartbeat sent"); + } + let registry = ol.registry_snapshot().await; + + // 3. Runs first, then the ledger: a row exists only after its proposal + // was committed, so a proposal missing from a later snapshot is a + // real loss and not a race. + let Some(runs) = note("runs", db.get_in_progress_workflow_runs().await) else { + return report; + }; + report.runs_seen = runs.len(); + let Some(snapshot) = note("proposals", ProposalSnapshot::read(&client, db).await) else { + return report; + }; + + // 4. Invitations: undecided proposals that name this node. + let now = now_micros(); + if let Some(peers) = note("projection", db.get_all_peers().await) { + let for_me = snapshot.for_me(client.node_party()); + if let Some(list) = note( + "projection", + proposals::project_pending_invitations(db, &for_me, &snapshot.decisions, &peers, now) + .await, + ) { + report.invitations = list.len(); + ol.set_pending_invitations(list).await; + } + } + + // 5. Drive every on-ledger run, at most one driver per run at a time. + let Some(sync_id) = note( + "synchronizer", + utils::get_synchronizer_id(ol.config()).await, + ) else { + return report; + }; + let ctx = TickCtx { + ol, + client: &client, + identity: &identity, + sync_id, + participant_id: identity.participant_id.clone(), + proposals: &snapshot, + registry: ®istry, + now_micros: now, + }; + let mut locked = Vec::with_capacity(runs.len()); + for run in &runs { + if engine::read_run_meta(run).is_none() { + continue; + } + locked.push((run, ol.run_lock(&run.instance_name).await)); + } + let outcomes = join_all( + locked + .iter() + .map(|(run, lock)| drive_one(&ctx, run, lock.clone())), + ) + .await; + for outcome in outcomes { + match outcome { + None => report.runs_busy += 1, + Some(Ok(Driven::Ticked)) => { + report.runs_driven += 1; + RUNS_DRIVEN.inc(); + } + Some(Ok(Driven::Stopped)) => report.runs_stopped += 1, + Some(Ok(Driven::Skipped)) => {} + Some(Err(e)) => { + ERRORS.with_label_values(&["drive"]).inc(); + tracing::warn!(error = %format!("{e:#}"), "driving a run failed; retry next tick"); + } + } + } + let live: HashSet = runs.iter().map(|r| r.instance_name.clone()).collect(); + ol.prune_run_locks(&live).await; + + // 6. Slow work: the UI's unsolicited list and housekeeping. + let due = state + .last_unsolicited_scan + .is_none_or(|t| t.elapsed() >= Duration::from_secs(consts::UNSOLICITED_SCAN_INTERVAL_SECS)); + if due { + state.last_unsolicited_scan = Some(Instant::now()); + if let Some(list) = note( + "unsolicited", + topology::scan_unsolicited(ol.config(), &ctx.sync_id).await, + ) { + ol.set_unsolicited(list).await; + } + if let Some(swept) = note( + "archive", + proposals::archive_sweep(&client, &snapshot.active_cids()).await, + ) && (swept.acceptances > 0 || swept.declines > 0) + { + tracing::info!( + acceptances = swept.acceptances, + declines = swept.declines, + "archived stale coordination contracts" + ); + } + } + + tracing::debug!(?report, "observer tick done"); + report +} + +/// Drive one run under its lock; `None` when another caller holds it. +async fn drive_one( + ctx: &TickCtx<'_>, + run: &common::types::WorkflowRun, + lock: Arc>, +) -> Option> { + let Ok(_guard) = lock.try_lock() else { + tracing::debug!(instance = %run.instance_name, "run busy; skipped this tick"); + return None; + }; + Some(engine::drive(ctx, run).await) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn metrics_register_once_and_expose_every_stage() { + register_metrics(); + register_metrics(); + let families = prometheus::gather(); + let names: Vec<&str> = families.iter().map(|f| f.name()).collect(); + for expected in [ + "decman_observer_tick_total", + "decman_observer_tick_seconds", + "decman_observer_last_tick_seconds", + "decman_observer_runs_driven_total", + "decman_observer_errors_total", + "decman_observer_no_identity_total", + ] { + assert!(names.contains(&expected), "missing {expected} in {names:?}"); + } + let errors = families + .iter() + .find(|f| f.name() == "decman_observer_errors_total") + .expect("errors family"); + assert!(errors.get_metric().len() >= 9, "one series per stage"); + } + + #[test] + fn note_counts_and_swallows_errors() { + register_metrics(); + let before = ERRORS.with_label_values(&["drive"]).get(); + assert_eq!(note("drive", Ok::(7)), Some(7)); + assert_eq!(note("drive", Err::(anyhow::anyhow!("boom"))), None); + assert_eq!(ERRORS.with_label_values(&["drive"]).get(), before + 1); + } + + #[tokio::test] + async fn a_tick_without_identity_reports_idle_and_does_not_panic() { + let ol = OnLedger::placeholder(); + let mut state = ObserverState::default(); + let report = tick(&ol, &mut state).await; + assert!(!report.had_identity); + assert_eq!(report.runs_seen, 0); + } +} diff --git a/crates/decman/src/onledger/proposals.rs b/crates/decman/src/onledger/proposals.rs new file mode 100644 index 000000000..8f0d7681e --- /dev/null +++ b/crates/decman/src/onledger/proposals.rs @@ -0,0 +1,834 @@ +//! `WorkflowProposal` lifecycle (design D6, D10) and its projection into +//! `pending_invitations` (design D11). +//! +//! The proposer creates, cancels, and finishes. An invitee accepts or +//! declines. Everyone reads. The counting predicates that decide which +//! acceptances a proposer trusts live in [`counted_acceptances`], a pure +//! function, so they are unit-tested without a ledger. + +use std::collections::{BTreeSet, HashMap, HashSet}; + +use anyhow::{Context, Result, bail}; +use common::{ + canton_id::CantonId, + types::{InvitationType, PendingInvitation, WorkflowKind}, +}; +use sqlx::SqlitePool; + +use crate::{ + config::{NodeConfig, Peer}, + consts, + db::{ + rows::ProposalDecisionEntry, + schema::{Commitable, SchemaRead, SchemaWrite}, + }, +}; + +use super::{ + daml::{ + ActiveContract, CoordinationClient, CoordinationTemplate, choices, + codec::{ + AcceptArgs, ChoiceArgument, DeclineArgs, FinishArgs, WorkflowAcceptanceRecord, + WorkflowDeclineRecord, WorkflowOutcomeRecord, WorkflowProposalRecord, unit_argument, + }, + }, + identity::verify_hosting, +}; + +pub type ActiveProposal = ActiveContract; +pub type Acceptance = ActiveContract; +pub type Decline = ActiveContract; +pub type Outcome = ActiveContract; + +const MICROS_PER_SEC: i64 = 1_000_000; + +/// `(createdAt, expiresAt)` for a proposal created at `now` (micros), using +/// the configured TTL. The template requires `expiresAt > createdAt`, which +/// the TTL floor of one second guarantees. +pub fn proposal_lifetime(now: i64) -> (i64, i64) { + let ttl = i64::try_from(consts::proposal_ttl_secs()) + .unwrap_or(i64::MAX) + .saturating_mul(MICROS_PER_SEC); + (now, now.saturating_add(ttl)) +} + +// --------------------------------------------------------------------------- +// Proposer side +// --------------------------------------------------------------------------- + +/// Create a `WorkflowProposal` and return its contract id. +/// +/// # Errors +/// Returns an error when the record's proposer is not the node party, or the +/// submission fails. +pub async fn create_proposal( + client: &CoordinationClient, + record: &WorkflowProposalRecord, +) -> Result { + if record.proposer != *client.node_party() { + bail!( + "proposal names proposer {} but the client acts as {}", + record.proposer, + client.node_party() + ); + } + if record.expires_at <= record.created_at { + bail!("proposal expiresAt must be after createdAt"); + } + let cid = client + .create(record) + .await + .context("create WorkflowProposal")?; + tracing::info!( + contract_id = %cid, + run_id = %record.run_id, + kind = %record.kind, + invitees = record.invitees.len(), + "created WorkflowProposal" + ); + Ok(cid) +} + +/// Exercise `WorkflowProposal_Cancel`. The proposal vanishes for every +/// invitee, so they stop co-signing (design D10). +/// +/// # Errors +/// Returns an error when the submission fails. +pub async fn cancel(client: &CoordinationClient, proposal_cid: &str) -> Result<()> { + client + .exercise( + CoordinationTemplate::WorkflowProposal, + proposal_cid, + choices::WORKFLOW_PROPOSAL_CANCEL, + unit_argument(), + ) + .await + .context("WorkflowProposal_Cancel")?; + Ok(()) +} + +/// Exercise `WorkflowProposal_Finish` and return the `WorkflowOutcome` cid. +/// +/// # Errors +/// Returns an error when the submission fails or created no outcome. +pub async fn finish( + client: &CoordinationClient, + proposal_cid: &str, + succeeded: bool, + error: Option, +) -> Result { + let args = FinishArgs { succeeded, error }; + let outcome = client + .exercise( + CoordinationTemplate::WorkflowProposal, + proposal_cid, + choices::WORKFLOW_PROPOSAL_FINISH, + args.to_value(), + ) + .await + .context("WorkflowProposal_Finish")?; + outcome + .created_contract_id + .context("WorkflowProposal_Finish created no WorkflowOutcome") +} + +// --------------------------------------------------------------------------- +// Invitee side +// --------------------------------------------------------------------------- + +/// Exercise `WorkflowProposal_Accept` and return the `WorkflowAcceptance` cid. +/// +/// # Errors +/// Returns an error when `args.acceptor` is not the node party or the +/// submission fails. +pub async fn accept( + client: &CoordinationClient, + proposal_cid: &str, + args: &AcceptArgs, +) -> Result { + if args.acceptor != *client.node_party() { + bail!( + "acceptance names acceptor {} but the client acts as {}", + args.acceptor, + client.node_party() + ); + } + let outcome = client + .exercise( + CoordinationTemplate::WorkflowProposal, + proposal_cid, + choices::WORKFLOW_PROPOSAL_ACCEPT, + args.to_value(), + ) + .await + .context("WorkflowProposal_Accept")?; + outcome + .created_contract_id + .context("WorkflowProposal_Accept created no WorkflowAcceptance") +} + +/// Exercise `WorkflowProposal_Decline` as the node party and return the +/// `WorkflowDecline` cid. +/// +/// # Errors +/// Returns an error when the submission fails. +pub async fn decline( + client: &CoordinationClient, + proposal_cid: &str, + reason: &str, +) -> Result { + let args = DeclineArgs { + decliner: client.node_party().clone(), + reason: reason.to_string(), + }; + let outcome = client + .exercise( + CoordinationTemplate::WorkflowProposal, + proposal_cid, + choices::WORKFLOW_PROPOSAL_DECLINE, + args.to_value(), + ) + .await + .context("WorkflowProposal_Decline")?; + outcome + .created_contract_id + .context("WorkflowProposal_Decline created no WorkflowDecline") +} + +// --------------------------------------------------------------------------- +// Reads +// --------------------------------------------------------------------------- + +/// Active proposals that invite this node: the node party is an invitee and +/// not the proposer. +/// +/// # Errors +/// Returns an error when the read fails. +pub async fn read_active_proposals_for_me( + client: &CoordinationClient, +) -> Result> { + let me = client.node_party(); + Ok(client + .list_active::() + .await? + .into_iter() + .filter(|p| p.record.proposer != *me && p.record.invitees.contains(me)) + .collect()) +} + +/// Active proposals this node created. +/// +/// # Errors +/// Returns an error when the read fails. +pub async fn read_my_proposals(client: &CoordinationClient) -> Result> { + let me = client.node_party(); + Ok(client + .list_active::() + .await? + .into_iter() + .filter(|p| p.record.proposer == *me) + .collect()) +} + +/// One proposal by contract id, if it is still active and visible. This is +/// the re-read design D5 step 5 requires immediately before a co-sign. +/// +/// # Errors +/// Returns an error when the read fails. +pub async fn read_proposal( + client: &CoordinationClient, + proposal_cid: &str, +) -> Result> { + Ok(client + .list_active::() + .await? + .into_iter() + .find(|p| p.contract_id == proposal_cid)) +} + +/// Every visible `WorkflowAcceptance` that names `proposal_cid`. Raw: run +/// [`counted_acceptances`] before trusting any of them. +/// +/// # Errors +/// Returns an error when the read fails. +pub async fn read_acceptances( + client: &CoordinationClient, + proposal_cid: &str, +) -> Result> { + Ok(client + .list_active::() + .await? + .into_iter() + .filter(|a| a.record.proposal == proposal_cid) + .collect()) +} + +/// Every visible `WorkflowDecline` that names `proposal_cid`. +/// +/// # Errors +/// Returns an error when the read fails. +pub async fn read_declines( + client: &CoordinationClient, + proposal_cid: &str, +) -> Result> { + Ok(client + .list_active::() + .await? + .into_iter() + .filter(|d| d.record.proposal == proposal_cid) + .collect()) +} + +/// Every visible `WorkflowOutcome`. +/// +/// # Errors +/// Returns an error when the read fails. +pub async fn read_outcomes(client: &CoordinationClient) -> Result> { + client.list_active::().await +} + +// --------------------------------------------------------------------------- +// Counting (design D6) +// --------------------------------------------------------------------------- + +/// Apply the D6 counting predicates. +/// +/// An acceptance is counted only if all of these hold: +/// * `acceptance.proposal == proposal.contract_id`; +/// * `acceptance.proposer == proposal.proposer`; +/// * the acceptor is an invitee and not the proposer; +/// * `acceptance.participantId` is one of `proposal.participants`; +/// * `hosted(acceptor, participantId)` says the acceptor's node party is +/// hosted on that participant with Submission permission. +/// +/// A second counted acceptance from the same acceptor is an error: the run +/// fails closed rather than pick one set of key material. The result is +/// sorted by acceptor. +/// +/// `hosted` is injected so this stays pure; [`counted_acceptances_verified`] +/// supplies the topology-backed check. +/// +/// # Errors +/// Returns an error on a duplicate acceptor. +pub fn counted_acceptances( + proposal: &ActiveProposal, + acceptances: &[Acceptance], + hosted: &dyn Fn(&CantonId, &CantonId) -> bool, +) -> Result> { + let invitees: HashSet<&CantonId> = proposal.record.invitees.iter().collect(); + let participants: HashSet<&str> = proposal + .record + .participants + .iter() + .map(String::as_str) + .collect(); + let mut seen: BTreeSet = BTreeSet::new(); + let mut counted = Vec::new(); + + for a in acceptances { + let r = &a.record; + let skip = |why: &str| { + tracing::debug!( + acceptance = %a.contract_id, + acceptor = %r.acceptor, + proposal = %proposal.contract_id, + "acceptance not counted: {why}" + ); + }; + if r.proposal != proposal.contract_id { + skip("names another proposal"); + continue; + } + if r.proposer != proposal.record.proposer { + skip("names another proposer"); + continue; + } + if r.acceptor == proposal.record.proposer { + skip("the proposer accepted its own proposal"); + continue; + } + if !invitees.contains(&r.acceptor) { + skip("acceptor is not an invitee"); + continue; + } + if !participants.contains(r.participant_id.as_str()) { + skip("participantId is not in the proposal's participants"); + continue; + } + let Ok(participant) = CantonId::parse(&r.participant_id) else { + skip("participantId is not a Canton id"); + continue; + }; + if !hosted(&r.acceptor, &participant) { + skip("acceptor is not hosted on participantId with Submission"); + continue; + } + if !seen.insert(r.acceptor.clone()) { + bail!( + "acceptor {} accepted proposal {} more than once; refusing to pick between \ + conflicting acceptances", + r.acceptor, + proposal.contract_id + ); + } + counted.push(a.clone()); + } + + counted.sort_by(|x, y| x.record.acceptor.cmp(&y.record.acceptor)); + Ok(counted) +} + +/// [`counted_acceptances`] with the hosting predicate backed by the +/// synchronizer topology (one `ListPartyToParticipant` per distinct +/// `(acceptor, participant)` pair). A failed topology read counts as not +/// hosted for this call. +/// +/// # Errors +/// As [`counted_acceptances`]. +pub async fn counted_acceptances_verified( + config: &NodeConfig, + proposal: &ActiveProposal, + acceptances: &[Acceptance], +) -> Result> { + let mut checks: HashMap<(CantonId, CantonId), bool> = HashMap::new(); + for a in acceptances { + let Ok(participant) = CantonId::parse(&a.record.participant_id) else { + continue; + }; + let key = (a.record.acceptor.clone(), participant); + if checks.contains_key(&key) { + continue; + } + let ok = match verify_hosting(config, &key.0, &key.1).await { + Ok(check) => check.has_submission(), + Err(e) => { + tracing::warn!( + acceptor = %key.0, + participant = %key.1, + error = %e, + "hosting check failed; acceptance not counted this tick" + ); + false + } + }; + checks.insert(key, ok); + } + counted_acceptances(proposal, acceptances, &|acceptor, participant| { + checks + .get(&(acceptor.clone(), participant.clone())) + .copied() + .unwrap_or(false) + }) +} + +// --------------------------------------------------------------------------- +// Projection into pending_invitations (design D11) +// --------------------------------------------------------------------------- + +fn invitation_type_of(kind: WorkflowKind) -> InvitationType { + match kind { + WorkflowKind::Onboarding => InvitationType::Onboarding, + WorkflowKind::Kick => InvitationType::Kick, + WorkflowKind::Contracts => InvitationType::Contracts, + WorkflowKind::Dars => InvitationType::Dars, + WorkflowKind::AddParty => InvitationType::AddParty, + WorkflowKind::ChangeThreshold => InvitationType::ChangeThreshold, + } +} + +/// The `PendingInvitation` card for one proposal. +/// +/// `id` is the proposal contract id. Until migration 000021 renames the +/// column, `coordinator_pubkey` carries the proposer's participant id; a +/// value that parses as a Canton id therefore marks an on-ledger row. +pub fn invitation_from_proposal( + proposal: &ActiveProposal, + peers: &[Peer], + received_at: i64, +) -> PendingInvitation { + let r = &proposal.record; + let coordinator_name = peers + .iter() + .find(|p| p.participant_id.to_string() == r.proposer_participant) + .map(|p| p.name.clone()); + let parse_all = |items: &[String]| -> Vec { + items + .iter() + .filter_map(|s| CantonId::parse(s).ok()) + .collect() + }; + let parse_opt = |item: &Option| item.as_deref().and_then(|s| CantonId::parse(s).ok()); + let to_i32 = |v: Option| v.and_then(|n| i32::try_from(n).ok()); + PendingInvitation { + id: proposal.contract_id.clone(), + invitation_type: invitation_type_of(r.kind), + coordinator_pubkey: r.proposer_participant.clone(), + coordinator_name, + received_at, + prefix: r.prefix.clone(), + participants: parse_all(&r.participants), + dar_filenames: r.dar_pins.iter().map(|p| p.filename.clone()).collect(), + dar_hashes: r.dar_pins.iter().map(|p| p.sha256_hex.clone()).collect(), + kicked_participant: parse_opt(&r.kicked_participant), + new_threshold: to_i32(r.threshold), + previous_threshold: to_i32(r.previous_threshold), + dec_party_id: parse_opt(&r.dec_party_id), + new_participant: parse_opt(&r.new_participant), + package_names: r.package_names.clone(), + workflow_instance: Some(r.run_id.clone()), + } +} + +/// Whether a stored invitation row came from the on-ledger projection. +fn is_onledger_row(inv: &PendingInvitation) -> bool { + CantonId::parse(&inv.coordinator_pubkey).is_ok() +} + +/// Which rows [`project_pending_invitations`] writes and removes (pure). +/// +/// A proposal is projected when it invites this node, has no decision, and +/// has not expired. An on-ledger row whose proposal is no longer projected is +/// removed. Rows from the Noise path are left alone. +pub fn plan_projection( + proposals: &[ActiveProposal], + decisions: &[ProposalDecisionEntry], + existing: &[PendingInvitation], + peers: &[Peer], + now_micros: i64, +) -> (Vec, Vec) { + let decided: HashSet<&str> = decisions.iter().map(|d| d.proposal_cid.as_str()).collect(); + let received_at = now_micros.div_euclid(MICROS_PER_SEC); + let keep: Vec = proposals + .iter() + .filter(|p| !decided.contains(p.contract_id.as_str())) + .filter(|p| p.record.expires_at > now_micros) + .map(|p| { + // Keep the first-seen time when the row already exists. + let received_at = existing + .iter() + .find(|e| e.id == p.contract_id) + .map(|e| e.received_at) + .unwrap_or(received_at); + invitation_from_proposal(p, peers, received_at) + }) + .collect(); + let live: HashSet<&str> = keep.iter().map(|i| i.id.as_str()).collect(); + let remove: Vec = existing + .iter() + .filter(|e| is_onledger_row(e) && !live.contains(e.id.as_str())) + .map(|e| e.id.clone()) + .collect(); + (keep, remove) +} + +/// Write the `pending_invitations` rows for the undecided proposals that +/// invite this node and remove the on-ledger rows that no longer apply. +/// Returns the full table afterwards, so the caller can replace the in-memory +/// `AppState.pending_invitations` list with it. +/// +/// # Errors +/// Returns an error when a database read or write fails. +pub async fn project_pending_invitations( + db: &SqlitePool, + proposals: &[ActiveProposal], + decisions: &[ProposalDecisionEntry], + peers: &[Peer], + now_micros: i64, +) -> Result> { + let existing = db.get_all_pending_invitations().await?; + let (keep, remove) = plan_projection(proposals, decisions, &existing, peers, now_micros); + + let mut tx = db.begin_transaction().await?; + for inv in &keep { + tx.upsert_pending_invitation(inv).await?; + } + for id in &remove { + tx.delete_pending_invitation(id).await?; + } + Commitable::commit(tx).await?; + + if !keep.is_empty() || !remove.is_empty() { + tracing::debug!( + projected = keep.len(), + removed = remove.len(), + "projected WorkflowProposals into pending_invitations" + ); + } + db.get_all_pending_invitations().await +} + +// --------------------------------------------------------------------------- +// Housekeeping (design D10) +// --------------------------------------------------------------------------- + +/// What one sweep archived. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ArchiveSweepReport { + pub acceptances: usize, + pub declines: usize, +} + +/// Archive this node's own `WorkflowAcceptance` and `WorkflowDecline` +/// contracts whose proposal is no longer active. A finished or cancelled +/// proposal is consumed, so "not in `active_proposal_cids`" is the test. +/// +/// TODO(submission.rs): archive this node's `SubmissionSignature`s of closed +/// rounds here too once the contracts workflow lands (part 2). +/// +/// # Errors +/// Returns an error when a read fails. A failed archive is logged and the +/// sweep continues; the next tick retries it. +pub async fn archive_sweep( + client: &CoordinationClient, + active_proposal_cids: &HashSet, +) -> Result { + let me = client.node_party(); + let mut report = ArchiveSweepReport::default(); + + let acceptances = client.list_active::().await?; + for a in acceptances + .iter() + .filter(|a| a.record.acceptor == *me && !active_proposal_cids.contains(&a.record.proposal)) + { + match client + .exercise( + CoordinationTemplate::WorkflowAcceptance, + &a.contract_id, + choices::WORKFLOW_ACCEPTANCE_ARCHIVE, + unit_argument(), + ) + .await + { + Ok(_) => report.acceptances += 1, + Err(e) => tracing::warn!( + contract_id = %a.contract_id, + error = %e, + "archiving a stale WorkflowAcceptance failed; retrying next tick" + ), + } + } + + let declines = client.list_active::().await?; + for d in declines + .iter() + .filter(|d| d.record.decliner == *me && !active_proposal_cids.contains(&d.record.proposal)) + { + match client + .exercise( + CoordinationTemplate::WorkflowDecline, + &d.contract_id, + choices::WORKFLOW_DECLINE_ARCHIVE, + unit_argument(), + ) + .await + { + Ok(_) => report.declines += 1, + Err(e) => tracing::warn!( + contract_id = %d.contract_id, + error = %e, + "archiving a stale WorkflowDecline failed; retrying next tick" + ), + } + } + + Ok(report) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + db::rows::ProposalDecision, + onledger::daml::codec::tests::{acceptance_full, party, proposal_full}, + }; + + fn proposal() -> ActiveProposal { + ActiveContract { + contract_id: "00proposal".into(), + offset: 10, + record: proposal_full(), + } + } + + fn acceptance(acceptor: &str, participant_n: u8) -> Acceptance { + let ns = "1220c4010d6883f367c7f45d55b2449501620130f9b21e96379f17dea455ac7a5892"; + ActiveContract { + contract_id: format!("00acc-{acceptor}-{participant_n}"), + offset: 20, + record: WorkflowAcceptanceRecord { + participant_id: format!("participant{participant_n}::{ns}"), + ..acceptance_full("00proposal", acceptor) + }, + } + } + + fn all_hosted(_: &CantonId, _: &CantonId) -> bool { + true + } + + #[test] + fn counts_a_valid_acceptance_from_each_invitee() { + let counted = counted_acceptances( + &proposal(), + &[acceptance("node-c", 3), acceptance("node-b", 2)], + &all_hosted, + ) + .expect("counts"); + let acceptors: Vec<_> = counted.iter().map(|a| a.record.acceptor.clone()).collect(); + assert_eq!(acceptors, vec![party("node-b"), party("node-c")]); + } + + #[test] + fn a_foreign_proposal_cid_is_not_counted() { + let mut foreign = acceptance("node-b", 2); + foreign.record.proposal = "00other".into(); + let counted = counted_acceptances(&proposal(), &[foreign], &all_hosted).expect("ok"); + assert!(counted.is_empty()); + } + + #[test] + fn a_foreign_proposer_is_not_counted() { + let mut wrong = acceptance("node-b", 2); + wrong.record.proposer = party("node-z"); + let counted = counted_acceptances(&proposal(), &[wrong], &all_hosted).expect("ok"); + assert!(counted.is_empty()); + } + + #[test] + fn an_acceptor_outside_the_invitees_is_not_counted() { + let counted = + counted_acceptances(&proposal(), &[acceptance("node-z", 2)], &all_hosted).expect("ok"); + assert!(counted.is_empty()); + } + + #[test] + fn a_participant_outside_the_proposal_is_not_counted() { + let counted = + counted_acceptances(&proposal(), &[acceptance("node-b", 9)], &all_hosted).expect("ok"); + assert!(counted.is_empty()); + } + + #[test] + fn an_unhosted_acceptor_is_not_counted() { + let counted = counted_acceptances(&proposal(), &[acceptance("node-b", 2)], &|_, _| false) + .expect("ok"); + assert!(counted.is_empty()); + } + + #[test] + fn the_proposers_own_acceptance_is_not_counted() { + // Even if the proposer somehow lists itself as invitee. + let mut p = proposal(); + p.record.invitees.push(party("node-a")); + let counted = counted_acceptances(&p, &[acceptance("node-a", 1)], &all_hosted).expect("ok"); + assert!(counted.is_empty()); + } + + #[test] + fn a_duplicate_acceptor_fails_closed() { + let err = counted_acceptances( + &proposal(), + &[acceptance("node-b", 2), acceptance("node-b", 3)], + &all_hosted, + ) + .expect_err("duplicate"); + assert!(err.to_string().contains("more than once"), "{err}"); + } + + #[test] + fn a_duplicate_that_fails_an_earlier_predicate_is_not_a_conflict() { + let mut foreign = acceptance("node-b", 2); + foreign.record.proposal = "00other".into(); + let counted = counted_acceptances( + &proposal(), + &[foreign, acceptance("node-b", 2)], + &all_hosted, + ) + .expect("ok"); + assert_eq!(counted.len(), 1); + } + + #[test] + fn proposal_lifetime_expires_after_created() { + let (created, expires) = proposal_lifetime(1_000); + assert_eq!(created, 1_000); + assert!(expires > created); + } + + fn peer_row(n: u8, name: &str) -> Peer { + let ns = "1220c4010d6883f367c7f45d55b2449501620130f9b21e96379f17dea455ac7a5892"; + Peer { + participant_id: CantonId::parse(&format!("participant{n}::{ns}")).expect("id"), + name: name.into(), + address: String::new(), + port: 0, + public_key: String::new(), + party: None, + } + } + + #[test] + fn invitation_mirrors_the_proposal_fields() { + let inv = invitation_from_proposal(&proposal(), &[peer_row(1, "Alpha")], 77); + let r = proposal_full(); + assert_eq!(inv.id, "00proposal"); + assert_eq!(inv.invitation_type, InvitationType::Onboarding); + assert_eq!(inv.coordinator_pubkey, r.proposer_participant); + assert_eq!(inv.coordinator_name.as_deref(), Some("Alpha")); + assert_eq!(inv.received_at, 77); + assert_eq!(inv.prefix, r.prefix); + assert_eq!(inv.participants.len(), 3); + assert_eq!(inv.dar_filenames, vec!["governance-core-v1-0.1.0.dar"]); + assert_eq!(inv.dar_hashes, vec!["deadbeef"]); + assert_eq!(inv.new_threshold, Some(2)); + assert_eq!(inv.previous_threshold, Some(3)); + assert!(inv.dec_party_id.is_some()); + assert!(inv.kicked_participant.is_some()); + assert!(inv.new_participant.is_some()); + assert_eq!(inv.package_names, vec!["governance-core-v1"]); + assert_eq!(inv.workflow_instance.as_deref(), Some("cbtc-creation")); + } + + #[test] + fn projection_skips_decided_and_expired_and_removes_stale_onledger_rows() { + let live = proposal(); + let mut decided = proposal(); + decided.contract_id = "00decided".into(); + let mut expired = proposal(); + expired.contract_id = "00expired".into(); + expired.record.expires_at = 5; + let decisions = vec![ProposalDecisionEntry { + proposal_cid: "00decided".into(), + decision: ProposalDecision::Accepted, + decided_at: 1, + pinned_hashes: vec![], + }]; + let stale_onledger = invitation_from_proposal( + &ActiveContract { + contract_id: "00gone".into(), + ..proposal() + }, + &[], + 3, + ); + let noise_row = PendingInvitation { + id: "uuid-1".into(), + coordinator_pubkey: "03abcdef".into(), + ..invitation_from_proposal(&proposal(), &[], 3) + }; + let earlier_live = invitation_from_proposal(&live, &[], 3); + + let (keep, remove) = plan_projection( + &[live, decided, expired], + &decisions, + &[stale_onledger, noise_row, earlier_live], + &[], + 1_000 * MICROS_PER_SEC, + ); + + assert_eq!(keep.len(), 1); + assert_eq!(keep[0].id, "00proposal"); + assert_eq!(keep[0].received_at, 3, "first-seen time is kept"); + assert_eq!(remove, vec!["00gone".to_string()]); + } +} diff --git a/crates/decman/src/onledger/registry.rs b/crates/decman/src/onledger/registry.rs new file mode 100644 index 000000000..9fa5ef632 --- /dev/null +++ b/crates/decman/src/onledger/registry.rs @@ -0,0 +1,1002 @@ +//! The `DecmanNode` registry (design D3): publish and refresh this node's +//! entry, read the peers' entries, and turn them into a health snapshot. +//! +//! Every read is keyed by the contract signatory (`node`), never by the +//! `participantId` text, which is a claim. The claim is cross-checked with +//! [`verify_hosting`] before an entry counts for a peer. + +use std::collections::{HashMap, HashSet}; + +use anyhow::{Context, Result, bail}; +use canton_proto_rs::com::digitalasset::canton::{ + protocol::v30::{enums::TopologyChangeOp, vetted_packages::VettedPackage}, + topology::admin::v30::{ + BaseQuery, ListVettedPackagesRequest, + topology_manager_read_service_client::TopologyManagerReadServiceClient, + }, +}; +use common::{ + canton_id::CantonId, + coordination::{DecmanNodeView, PeerHealthStatus, RegistryResponse}, +}; +use prost_types::Timestamp; + +use crate::{ + build_info, + config::{NodeConfig, Peer}, + consts, + server::package_inventory::fetch_package_id_to_name, + utils, + workflow::topology, +}; + +use super::{ + daml::{ + ActiveContract, CoordinationClient, CoordinationTemplate, choices, + codec::{ChoiceArgument, DecmanNodeRecord, DecmanNodeUpdateArgs, unit_argument}, + }, + identity::{HostingCheck, NodeIdentity, verify_hosting}, +}; + +/// Microseconds per second, for `Time` arithmetic. +const MICROS_PER_SEC: i64 = 1_000_000; + +// --------------------------------------------------------------------------- +// Desired state +// --------------------------------------------------------------------------- + +/// The `DecmanNode` this node wants on the ledger right now. +/// +/// Observers are the peers whose participant has vetted the coordination +/// package (`vetted_peers`) and whose peers-table row names a node party. +/// Canton rejects a create whose informee has not vetted the package, so an +/// unvetted peer is left out and picked up on a later observer tick. The +/// list is sorted and deduplicated so [`needs_update`] compares bytes, not +/// order. +pub fn desired_node_record( + config: &NodeConfig, + identity: &NodeIdentity, + peers: &[Peer], + vetted_peers: &HashSet, + last_active_at: i64, +) -> DecmanNodeRecord { + let mut observers: Vec = peers + .iter() + .filter(|p| p.participant_id != identity.participant_id) + .filter(|p| vetted_peers.contains(&p.participant_id)) + .filter_map(|p| p.party.as_deref()) + .filter_map(|party| match CantonId::parse(party) { + Ok(id) => Some(id), + Err(e) => { + tracing::warn!(party, error = %e, "peer party is not a Canton id; skipping"); + None + } + }) + .filter(|id| *id != identity.node_party) + .collect(); + observers.sort(); + observers.dedup(); + + let heartbeat = i64::try_from(consts::heartbeat_interval_secs()).unwrap_or(i64::MAX); + let floor = i64::try_from(consts::heartbeat_min_interval_secs()).unwrap_or(1); + DecmanNodeRecord { + node: identity.node_party.clone(), + participant_id: identity.participant_id.to_string(), + display_name: display_name(config), + version: build_info::SEMVER.to_string(), + build_version: build_info::build_version().to_string(), + coordination_version: consts::COORDINATION_VERSION, + peers: observers, + last_active_at, + heartbeat_interval_secs: heartbeat, + min_heartbeat_interval_secs: floor.min(heartbeat), + } +} + +/// The name this node advertises. The peers table has no row for self, so +/// the participant prefix stands in. +fn display_name(config: &NodeConfig) -> String { + config + .node + .participant_id + .as_ref() + .map(|id| id.prefix.clone()) + .unwrap_or_else(|| "decman".to_string()) +} + +/// Whether the on-ledger entry differs from the desired one in any field a +/// `DecmanNode_Update` can change. `lastActiveAt` is excluded: the heartbeat +/// owns it. +pub fn needs_update(current: &DecmanNodeRecord, desired: &DecmanNodeRecord) -> bool { + let mut current_peers = current.peers.clone(); + current_peers.sort(); + current_peers.dedup(); + current_peers != desired.peers + || current.display_name != desired.display_name + || current.version != desired.version + || current.build_version != desired.build_version + || current.coordination_version != desired.coordination_version + || current.heartbeat_interval_secs != desired.heartbeat_interval_secs + || current.min_heartbeat_interval_secs != desired.min_heartbeat_interval_secs + || current.participant_id != desired.participant_id +} + +/// Whether a heartbeat is due: the entry's own interval has elapsed since +/// `last_active_at`. The template floor is always at or below the interval, +/// so an exercise this predicate allows never trips `heartbeat too soon`. +pub fn heartbeat_due(last_active_at: i64, heartbeat_interval_secs: i64, now: i64) -> bool { + let interval = heartbeat_interval_secs + .max(1) + .saturating_mul(MICROS_PER_SEC); + now.saturating_sub(last_active_at) >= interval +} + +/// Whether a peer entry is stale: `now - lastActiveAt` exceeds the stale +/// factor times the peer's own heartbeat interval. +pub fn is_stale(last_active_at: i64, heartbeat_interval_secs: i64, now: i64, factor: u64) -> bool { + let factor = i64::try_from(factor.max(1)).unwrap_or(i64::MAX); + let window = heartbeat_interval_secs + .max(1) + .saturating_mul(MICROS_PER_SEC) + .saturating_mul(factor); + now.saturating_sub(last_active_at) > window +} + +// --------------------------------------------------------------------------- +// Vetting +// --------------------------------------------------------------------------- + +/// Package ids on this participant whose name is the coordination package. +/// +/// The topology store vets package *ids*; the config names the package. This +/// participant's `ListPackages` is the only place that maps one to the other +/// without a token. An empty set means this participant has not uploaded +/// the package yet, in which case nothing can be published either. +/// +/// # Errors +/// Returns an error when the Admin API call fails. +pub async fn coordination_package_ids( + config: &NodeConfig, + package_name: &str, +) -> Result> { + let id_to_name = fetch_package_id_to_name(config).await?; + Ok(id_to_name + .into_iter() + .filter(|(_, name)| name == package_name) + .map(|(id, _)| id) + .collect()) +} + +/// Package ids `participant_id` has vetted on the synchronizer and that are +/// in their validity window right now. +/// +/// TODO(server/package_inventory): this generalizes `fetch_vetted_packages` +/// with a `filter_participant` argument; move it there once that file is +/// open for edits and have the self-read call this. +/// +/// # Errors +/// Returns an error when the synchronizer id cannot be resolved or the +/// topology read fails. +pub async fn fetch_vetted_packages_for( + config: &NodeConfig, + participant_id: &CantonId, +) -> Result> { + let synchronizer_id = utils::get_synchronizer_id(config).await?; + let channel = config + .admin_channel() + .await + .context("connect to participant Admin API")?; + let mut client = TopologyManagerReadServiceClient::new(channel) + .max_decoding_message_size(utils::MAX_GRPC_MESSAGE_SIZE); + + let response = client + .list_vetted_packages(tonic::Request::new(ListVettedPackagesRequest { + base_query: Some(BaseQuery { + operation: TopologyChangeOp::AddReplace as i32, + ..topology::head_state_query(&synchronizer_id) + }), + filter_participant: participant_id.to_string(), + })) + .await + .with_context(|| format!("list vetted packages of {participant_id}"))? + .into_inner(); + + let now = now_timestamp(); + let wanted = participant_id.to_string(); + let mut vetted = HashSet::new(); + for result in response.results { + let Some(item) = result.item else { continue }; + // `filter_participant` is a substring post-filter on some Canton + // versions, so confirm the exact uid. + if item.participant_uid != wanted { + continue; + } + for package in item.packages { + if package_valid_at(&package, &now) { + vetted.insert(package.package_id); + } + } + } + Ok(vetted) +} + +/// Whether a vetting entry is in effect at `now`. +fn package_valid_at(package: &VettedPackage, now: &Timestamp) -> bool { + let le = |a: &Timestamp, b: &Timestamp| (a.seconds, a.nanos) <= (b.seconds, b.nanos); + package + .valid_from_inclusive + .as_ref() + .is_none_or(|from| le(from, now)) + && package + .valid_until_exclusive + .as_ref() + .is_none_or(|until| !le(until, now)) +} + +fn now_timestamp() -> Timestamp { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + Timestamp { + seconds: i64::try_from(now.as_secs()).unwrap_or(i64::MAX), + nanos: i32::try_from(now.subsec_nanos()).unwrap_or(0), + } +} + +/// Whether `participant_id` has vetted any of the coordination package ids. +/// +/// # Errors +/// As [`fetch_vetted_packages_for`]. +pub async fn participant_has_vetted( + config: &NodeConfig, + participant_id: &CantonId, + coordination_ids: &HashSet, +) -> Result { + if coordination_ids.is_empty() { + return Ok(false); + } + let vetted = fetch_vetted_packages_for(config, participant_id).await?; + Ok(!vetted.is_disjoint(coordination_ids)) +} + +/// The configured peers (self excluded) whose participant has vetted the +/// coordination package. A peer whose topology read fails is logged and +/// treated as not vetted for this tick. +/// +/// # Errors +/// Returns an error when the package id lookup fails. +pub async fn vetted_peers( + config: &NodeConfig, + package_name: &str, + self_participant: &CantonId, + peers: &[Peer], +) -> Result> { + let ids = coordination_package_ids(config, package_name).await?; + let mut out = HashSet::new(); + for peer in peers + .iter() + .filter(|p| p.participant_id != *self_participant) + { + match participant_has_vetted(config, &peer.participant_id, &ids).await { + Ok(true) => { + out.insert(peer.participant_id.clone()); + } + Ok(false) => {} + Err(e) => tracing::warn!( + participant = %peer.participant_id, + error = %e, + "vetting read failed; treating the peer as unvetted this tick" + ), + } + } + Ok(out) +} + +// --------------------------------------------------------------------------- +// Own entry +// --------------------------------------------------------------------------- + +/// What [`publish_or_update`] did. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PublishOutcome { + Created(String), + Updated(String), + Unchanged(String), +} + +impl PublishOutcome { + pub fn contract_id(&self) -> &str { + match self { + Self::Created(c) | Self::Updated(c) | Self::Unchanged(c) => c, + } + } +} + +/// This node's current `DecmanNode`, the newest by offset when several exist. +/// +/// # Errors +/// Returns an error when the read fails. +pub async fn read_own_entry( + client: &CoordinationClient, +) -> Result>> { + let mine: Vec<_> = client + .list_active::() + .await? + .into_iter() + .filter(|c| c.record.node == *client.node_party()) + .collect(); + if mine.len() > 1 { + tracing::warn!( + count = mine.len(), + "several DecmanNode entries signed by this node are active; using the newest" + ); + } + Ok(mine.into_iter().max_by_key(|c| c.offset)) +} + +/// Create this node's entry when absent, or `DecmanNode_Update` it when any +/// field differs from `desired`. Never touches an entry that matches. +/// +/// # Errors +/// Returns an error when the read or the submission fails. +pub async fn publish_or_update( + client: &CoordinationClient, + desired: &DecmanNodeRecord, +) -> Result { + if desired.node != *client.node_party() { + bail!( + "desired registry entry is signed by {} but the client acts as {}", + desired.node, + client.node_party() + ); + } + match read_own_entry(client).await? { + None => { + let cid = client.create(desired).await.context("create DecmanNode")?; + tracing::info!(contract_id = %cid, peers = desired.peers.len(), "published DecmanNode"); + Ok(PublishOutcome::Created(cid)) + } + Some(current) if needs_update(¤t.record, desired) => { + let args = DecmanNodeUpdateArgs::from_desired(desired); + let outcome = client + .exercise( + CoordinationTemplate::DecmanNode, + ¤t.contract_id, + choices::DECMAN_NODE_UPDATE, + args.to_value(), + ) + .await + .context("DecmanNode_Update")?; + let cid = outcome + .created_contract_id + .unwrap_or_else(|| current.contract_id.clone()); + tracing::info!(contract_id = %cid, peers = desired.peers.len(), "updated DecmanNode"); + Ok(PublishOutcome::Updated(cid)) + } + Some(current) => Ok(PublishOutcome::Unchanged(current.contract_id)), + } +} + +/// Exercise `DecmanNode_Heartbeat` when the entry's interval has elapsed. +/// Returns the new contract id when a heartbeat was sent. +/// +/// # Errors +/// Returns an error when the submission fails. +pub async fn heartbeat_if_due( + client: &CoordinationClient, + current: &ActiveContract, + now: i64, +) -> Result> { + if !heartbeat_due( + current.record.last_active_at, + current.record.heartbeat_interval_secs, + now, + ) { + return Ok(None); + } + let outcome = client + .exercise( + CoordinationTemplate::DecmanNode, + ¤t.contract_id, + choices::DECMAN_NODE_HEARTBEAT, + unit_argument(), + ) + .await + .context("DecmanNode_Heartbeat")?; + tracing::debug!(contract_id = ?outcome.created_contract_id, "DecmanNode heartbeat sent"); + Ok(outcome.created_contract_id) +} + +/// Exercise `DecmanNode_Retire` on the current entry, if any. +/// +/// # Errors +/// Returns an error when the read or the submission fails. +pub async fn retire(client: &CoordinationClient) -> Result> { + let Some(current) = read_own_entry(client).await? else { + return Ok(None); + }; + client + .exercise( + CoordinationTemplate::DecmanNode, + ¤t.contract_id, + choices::DECMAN_NODE_RETIRE, + unit_argument(), + ) + .await + .context("DecmanNode_Retire")?; + Ok(Some(current.contract_id)) +} + +// --------------------------------------------------------------------------- +// Peer entries +// --------------------------------------------------------------------------- + +/// A peer's `DecmanNode` as read, plus the topology cross-check of its +/// `participantId` claim. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PeerEntry { + pub contract_id: String, + pub offset: i64, + pub record: DecmanNodeRecord, + /// How the synchronizer says `record.node` is hosted on the claimed + /// participant. `None` when the claim does not parse as a Canton id. + pub hosting: Option, +} + +impl PeerEntry { + /// The claim passed: the signatory is hosted on the claimed participant + /// with Submission permission. + pub fn hosting_verified(&self) -> bool { + self.hosting + .as_ref() + .is_some_and(HostingCheck::has_submission) + } + + /// The claimed participant, when it parses. + pub fn participant(&self) -> Option { + CantonId::parse(&self.record.participant_id).ok() + } +} + +/// Every `DecmanNode` visible to this node except its own, one per +/// signatory (the newest by offset), with the hosting cross-check applied. +/// +/// # Errors +/// Returns an error when the ACS read fails. A failed topology read for one +/// entry is logged and leaves `hosting = None` for that entry. +pub async fn read_peer_entries( + client: &CoordinationClient, + config: &NodeConfig, +) -> Result> { + let all = client.list_active::().await?; + let mut newest: HashMap> = HashMap::new(); + for contract in all { + if contract.record.node == *client.node_party() { + continue; + } + let replace = newest + .get(&contract.record.node) + .is_none_or(|existing| contract.offset > existing.offset); + if replace { + newest.insert(contract.record.node.clone(), contract); + } + } + + let mut entries = Vec::with_capacity(newest.len()); + for (_, contract) in newest { + let hosting = match CantonId::parse(&contract.record.participant_id) { + Ok(participant) => { + match verify_hosting(config, &contract.record.node, &participant).await { + Ok(check) => Some(check), + Err(e) => { + tracing::warn!( + node = %contract.record.node, + participant = %participant, + error = %e, + "hosting check failed for a registry entry" + ); + None + } + } + } + Err(e) => { + tracing::warn!( + node = %contract.record.node, + claimed = %contract.record.participant_id, + error = %e, + "registry entry claims a participant id that does not parse" + ); + None + } + }; + entries.push(PeerEntry { + contract_id: contract.contract_id, + offset: contract.offset, + record: contract.record, + hosting, + }); + } + entries.sort_by(|a, b| a.record.node.cmp(&b.record.node)); + Ok(entries) +} + +// --------------------------------------------------------------------------- +// Snapshot +// --------------------------------------------------------------------------- + +/// What the observer loop last learned about one configured peer. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PeerHealth { + /// The node party from the peers table, when the operator entered one. + pub node_party: Option, + pub version: Option, + pub build_version: Option, + pub coordination_version: Option, + /// Micros since the epoch. + pub last_active_at: Option, + pub heartbeat_interval_secs: Option, + /// Whether the peer's participant has vetted the coordination package. + pub vetted: bool, + pub status: PeerHealthStatus, +} + +/// The in-memory view `/participants-status` and the preflight gate read. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PeerHealthSnapshot { + /// Keyed by the peer's participant id. + pub entries: HashMap, + /// Unix seconds of the last refresh; `None` before the first one. + pub refreshed_at: Option, +} + +impl PeerHealthSnapshot { + pub fn get(&self, participant: &CantonId) -> Option<&PeerHealth> { + self.entries.get(participant) + } +} + +/// Build the snapshot from the configured peers and the entries read. +/// +/// An entry counts for a peer only when its signatory is the peer's node +/// party, its `participantId` claim is the peer's participant, and the +/// hosting check passed. `now` is micros since the epoch. +pub fn build_snapshot( + peers: &[Peer], + self_participant: &CantonId, + entries: &[PeerEntry], + vetted: &HashSet, + now: i64, + stale_factor: u64, + refreshed_at: i64, +) -> PeerHealthSnapshot { + let by_signatory: HashMap<&CantonId, &PeerEntry> = + entries.iter().map(|e| (&e.record.node, e)).collect(); + let mut out = HashMap::new(); + for peer in peers + .iter() + .filter(|p| p.participant_id != *self_participant) + { + let node_party = peer.party.as_deref().and_then(|p| CantonId::parse(p).ok()); + let is_vetted = vetted.contains(&peer.participant_id); + let matched = node_party.as_ref().and_then(|party| { + by_signatory.get(party).copied().filter(|e| { + e.hosting_verified() && e.participant().as_ref() == Some(&peer.participant_id) + }) + }); + let health = match matched { + Some(entry) => { + let status = if is_stale( + entry.record.last_active_at, + entry.record.heartbeat_interval_secs, + now, + stale_factor, + ) { + PeerHealthStatus::Stale + } else { + PeerHealthStatus::Active + }; + PeerHealth { + node_party, + version: Some(entry.record.version.clone()), + build_version: Some(entry.record.build_version.clone()), + coordination_version: Some(entry.record.coordination_version), + last_active_at: Some(entry.record.last_active_at), + heartbeat_interval_secs: Some(entry.record.heartbeat_interval_secs), + vetted: is_vetted, + status, + } + } + None => PeerHealth { + node_party, + version: None, + build_version: None, + coordination_version: None, + last_active_at: None, + heartbeat_interval_secs: None, + vetted: is_vetted, + status: if is_vetted { + PeerHealthStatus::Unknown + } else { + PeerHealthStatus::Unvetted + }, + }, + }; + out.insert(peer.participant_id.clone(), health); + } + PeerHealthSnapshot { + entries: out, + refreshed_at: Some(refreshed_at), + } +} + +/// The version gate a proposer runs before creating a `WorkflowProposal` +/// (design D3). Returns one `(participant, reason)` per invitee that is not +/// ready; an empty vector means every invitee passed. +/// +/// An invitee absent from the snapshot is reported as unknown to this node. +pub fn preflight_unready_peers( + snapshot: &PeerHealthSnapshot, + invitees: &[CantonId], +) -> Vec<(CantonId, String)> { + let mut out = Vec::new(); + for participant in invitees { + let reason = match snapshot.get(participant) { + None => Some("not in the peers table".to_string()), + Some(h) if h.status == PeerHealthStatus::Unvetted => { + Some("has not vetted the coordination package".to_string()) + } + Some(h) if h.status == PeerHealthStatus::Unknown => Some( + "no registry entry visible (peer has not added you, or has not vetted)".to_string(), + ), + Some(h) => match h.coordination_version { + Some(v) if v >= consts::COORDINATION_VERSION => None, + Some(v) => Some(format!( + "registry entry too old: coordinationVersion {v} < {}", + consts::COORDINATION_VERSION + )), + None => Some("registry entry carries no coordinationVersion".to_string()), + }, + }; + if let Some(reason) = reason { + out.push((participant.clone(), reason)); + } + } + out +} + +// --------------------------------------------------------------------------- +// Views +// --------------------------------------------------------------------------- + +/// The wire view of one entry. `status` is derived from the entry alone +/// (Active or Stale); the caller overrides it for peers it knows better. +pub fn view_of(entry: &PeerEntry, now: i64, stale_factor: u64) -> DecmanNodeView { + let record = &entry.record; + let status = if is_stale( + record.last_active_at, + record.heartbeat_interval_secs, + now, + stale_factor, + ) { + PeerHealthStatus::Stale + } else { + PeerHealthStatus::Active + }; + DecmanNodeView { + contract_id: entry.contract_id.clone(), + node_party: record.node.clone(), + participant_id: record.participant_id.clone(), + hosting_verified: entry.hosting_verified(), + display_name: record.display_name.clone(), + version: record.version.clone(), + build_version: record.build_version.clone(), + coordination_version: record.coordination_version, + peers: record.peers.clone(), + last_active_at: record.last_active_at.div_euclid(MICROS_PER_SEC), + heartbeat_interval_secs: record.heartbeat_interval_secs, + min_heartbeat_interval_secs: record.min_heartbeat_interval_secs, + heartbeat_age_secs: now + .saturating_sub(record.last_active_at) + .div_euclid(MICROS_PER_SEC) + .max(0), + status, + } +} + +/// Split the visible entries into the `peers` bucket (signatory is a +/// configured peer's node party) and the `inbound` bucket (everything else). +pub fn registry_response( + own: Option<&ActiveContract>, + entries: &[PeerEntry], + peers: &[Peer], + now: i64, + stale_factor: u64, +) -> RegistryResponse { + let known: HashSet = peers + .iter() + .filter_map(|p| p.party.as_deref()) + .filter_map(|p| CantonId::parse(p).ok()) + .collect(); + let mut peer_views = Vec::new(); + let mut inbound = Vec::new(); + for entry in entries { + let view = view_of(entry, now, stale_factor); + if known.contains(&entry.record.node) { + peer_views.push(view); + } else { + inbound.push(view); + } + } + let self_entry = own.map(|c| { + view_of( + &PeerEntry { + contract_id: c.contract_id.clone(), + offset: c.offset, + record: c.record.clone(), + hosting: None, + }, + now, + stale_factor, + ) + }); + RegistryResponse { + self_entry, + peers: peer_views, + inbound, + } +} + +#[cfg(test)] +mod tests { + use common::types::Permission; + + use super::*; + use crate::onledger::daml::codec::tests::{node_record, party}; + + const NS: &str = "1220c4010d6883f367c7f45d55b2449501620130f9b21e96379f17dea455ac7a5892"; + const SEC: i64 = MICROS_PER_SEC; + + fn participant(n: u8) -> CantonId { + CantonId::parse(&format!("participant{n}::{NS}")).expect("valid id") + } + + fn peer(n: u8, party_prefix: Option<&str>) -> Peer { + Peer { + participant_id: participant(n), + name: format!("Node {n}"), + address: String::new(), + port: 0, + public_key: String::new(), + party: party_prefix.map(|p| party(p).to_string()), + } + } + + fn hosted(permission: Permission) -> HostingCheck { + HostingCheck { + mapping_exists: true, + hosted: true, + permission: Some(permission), + onboarding: false, + threshold: 1, + } + } + + fn entry( + node: &str, + participant_n: u8, + last_active_at: i64, + hosting: Option, + ) -> PeerEntry { + PeerEntry { + contract_id: format!("00{node}"), + offset: 1, + record: DecmanNodeRecord { + participant_id: participant(participant_n).to_string(), + last_active_at, + heartbeat_interval_secs: 10, + ..node_record(node, &[]) + }, + hosting, + } + } + + #[test] + fn heartbeat_is_due_once_the_interval_has_elapsed() { + assert!(!heartbeat_due(0, 10, 9 * SEC)); + assert!(heartbeat_due(0, 10, 10 * SEC)); + assert!( + heartbeat_due(0, 0, SEC), + "a zero interval clamps to one second" + ); + } + + #[test] + fn staleness_uses_the_factor_times_the_peer_interval() { + assert!(!is_stale(0, 10, 30 * SEC, 3)); + assert!(is_stale(0, 10, 30 * SEC + 1, 3)); + assert!(is_stale(0, 10, 11 * SEC, 0), "a zero factor clamps to one"); + } + + #[test] + fn needs_update_ignores_last_active_at_and_peer_order() { + let current = node_record("node-a", &["node-c", "node-b"]); + let mut desired = node_record("node-a", &["node-b", "node-c"]); + desired.last_active_at += 5 * SEC; + assert!(!needs_update(¤t, &desired)); + desired.version = "2.1.0".into(); + assert!(needs_update(¤t, &desired)); + let mut fewer = node_record("node-a", &["node-b"]); + fewer.last_active_at = current.last_active_at; + assert!(needs_update(¤t, &fewer)); + } + + #[test] + fn snapshot_is_keyed_by_signatory_and_verified_hosting() { + let peers = vec![ + peer(2, Some("node-b")), + peer(3, Some("node-c")), + peer(4, Some("node-d")), + peer(5, None), + peer(6, Some("node-f")), + ]; + let now = 100 * SEC; + let entries = vec![ + // b: fresh, hosted with Submission on the claimed participant. + entry("node-b", 2, 95 * SEC, Some(hosted(Permission::Submission))), + // c: stale (interval 10, factor 3, age 40). + entry("node-c", 3, 60 * SEC, Some(hosted(Permission::Submission))), + // d: claims participant 4 but hosting shows Confirmation only. + entry( + "node-d", + 4, + 99 * SEC, + Some(hosted(Permission::Confirmation)), + ), + // f: signed by node-f but claims participant 2 (not its own). + entry("node-f", 2, 99 * SEC, Some(hosted(Permission::Submission))), + // x: nobody's peer; must not leak into any bucket by participant. + entry("node-x", 5, 99 * SEC, Some(hosted(Permission::Submission))), + ]; + let vetted: HashSet = [ + participant(2), + participant(3), + participant(4), + participant(6), + ] + .into_iter() + .collect(); + let snap = build_snapshot(&peers, &participant(1), &entries, &vetted, now, 3, 100); + + assert_eq!( + snap.get(&participant(2)).map(|h| h.status), + Some(PeerHealthStatus::Active) + ); + assert_eq!( + snap.get(&participant(3)).map(|h| h.status), + Some(PeerHealthStatus::Stale) + ); + assert_eq!( + snap.get(&participant(4)).map(|h| h.status), + Some(PeerHealthStatus::Unknown) + ); + assert_eq!( + snap.get(&participant(5)).map(|h| h.status), + Some(PeerHealthStatus::Unvetted) + ); + assert_eq!( + snap.get(&participant(6)).map(|h| h.status), + Some(PeerHealthStatus::Unknown) + ); + assert!(snap.get(&participant(1)).is_none(), "self is never a peer"); + assert_eq!( + snap.get(&participant(2)).and_then(|h| h.version.clone()), + Some("2.0.0".to_string()) + ); + } + + #[test] + fn preflight_distinguishes_unvetted_unknown_and_too_old() { + let peers = vec![ + peer(2, Some("node-b")), + peer(3, Some("node-c")), + peer(4, Some("node-d")), + ]; + let mut old = entry("node-b", 2, 99 * SEC, Some(hosted(Permission::Submission))); + old.record.coordination_version = 0; + let good = entry("node-c", 3, 99 * SEC, Some(hosted(Permission::Submission))); + let vetted: HashSet = [participant(2), participant(3)].into_iter().collect(); + let snap = build_snapshot( + &peers, + &participant(1), + &[old, good], + &vetted, + 100 * SEC, + 3, + 100, + ); + + let unready = preflight_unready_peers( + &snap, + &[ + participant(2), + participant(3), + participant(4), + participant(9), + ], + ); + let reasons: HashMap = unready.into_iter().collect(); + assert!(reasons[&participant(2)].contains("too old")); + assert!(!reasons.contains_key(&participant(3))); + assert!(reasons[&participant(4)].contains("has not vetted")); + assert!(reasons[&participant(9)].contains("not in the peers table")); + + // A vetted peer with no entry names the two possible causes. + let snap = build_snapshot(&peers, &participant(1), &[], &vetted, 100 * SEC, 3, 100); + let unready = preflight_unready_peers(&snap, &[participant(2)]); + assert!(unready[0].1.contains("no registry entry visible")); + } + + #[test] + fn registry_response_buckets_by_known_signatory() { + let peers = vec![peer(2, Some("node-b"))]; + let entries = vec![ + entry("node-b", 2, 99 * SEC, Some(hosted(Permission::Submission))), + entry("node-z", 9, 99 * SEC, None), + ]; + let own = ActiveContract { + contract_id: "00self".into(), + offset: 0, + record: node_record("node-a", &["node-b"]), + }; + let resp = registry_response(Some(&own), &entries, &peers, 100 * SEC, 3); + assert_eq!( + resp.self_entry.as_ref().map(|v| v.contract_id.as_str()), + Some("00self") + ); + assert_eq!(resp.peers.len(), 1); + assert!(resp.peers[0].hosting_verified); + assert_eq!(resp.inbound.len(), 1); + assert!(!resp.inbound[0].hosting_verified); + assert_eq!(resp.peers[0].heartbeat_age_secs, 1); + assert_eq!(resp.peers[0].last_active_at, 99); + } + + #[tokio::test] + async fn desired_record_names_only_vetted_peers_with_a_party() { + let identity = + crate::onledger::identity::tests::mock_identity("node-a", participant(1)).await; + let mut config = NodeConfig::default(); + config.node.participant_id = Some(participant(1)); + // Peer 6 names a party that is not a Canton id at all. + let mut malformed = peer(6, None); + malformed.party = Some("not-a-canton-id".to_string()); + let peers = vec![ + peer(1, Some("node-a")), // self: never an observer + peer(3, Some("node-c")), // vetted, named + peer(2, Some("node-b")), // vetted, named; sorts before node-c + peer(4, Some("node-d")), // not vetted + peer(5, None), // vetted, no party + malformed, + ]; + let vetted: HashSet = [ + participant(1), + participant(2), + participant(3), + participant(5), + participant(6), + ] + .into_iter() + .collect(); + let desired = desired_node_record(&config, &identity, &peers, &vetted, 42); + + assert_eq!(desired.node, party("node-a")); + assert_eq!(desired.participant_id, participant(1).to_string()); + assert_eq!(desired.peers, vec![party("node-b"), party("node-c")]); + assert_eq!(desired.last_active_at, 42); + assert_eq!(desired.coordination_version, consts::COORDINATION_VERSION); + assert_eq!(desired.version, build_info::SEMVER); + assert!(desired.min_heartbeat_interval_secs >= 1); + assert!(desired.min_heartbeat_interval_secs <= desired.heartbeat_interval_secs); + assert_eq!(desired.display_name, "participant1"); + } +} diff --git a/crates/decman/src/onledger/submission.rs b/crates/decman/src/onledger/submission.rs new file mode 100644 index 000000000..b6283752f --- /dev/null +++ b/crates/decman/src/onledger/submission.rs @@ -0,0 +1,307 @@ +//! Contracts workflow over `SubmissionRound` (design D7). +//! +//! The proposer prepares one interactive submission per contract, opens a +//! `SubmissionRound` per prepared transaction, collects and verifies +//! `SubmissionSignature`s, and executes each round once with exactly the +//! verified set. A member checks the round against its accepted proposal and +//! the head P2P, recomputes the hash, and signs with its per-party key. +//! +//! The signatures below are the contract. Pure helpers and plain ACS reads +//! are implemented; every ledger write and every crypto step is a stub the +//! contracts agent fills in. + +use std::collections::BTreeMap; + +use anyhow::{Context, Result, bail}; +use canton_proto_rs::com::digitalasset::canton::protocol::v30::PartyToParticipant; +use common::{api::ContractDefinition, canton_id::CantonId, types::WorkflowRun}; + +use crate::config::NodeConfig; + +use super::{ + OnLedger, + daml::{ + ActiveContract, CoordinationClient, CoordinationTemplate, choices, + codec::{ + ChoiceArgument, CloseArgs, SubmissionRoundRecord, SubmissionSignatureRecord, + WorkflowProposalRecord, unit_argument, + }, + }, +}; + +/// `max_record_time = preparation time + 20 h`. +pub const MAX_RECORD_TIME_HORIZON_MICROS: i64 = 20 * 3600 * 1_000_000; + +/// Signers must finish 30 minutes before the window closes. +pub const DEADLINE_SAFETY_MARGIN_MICROS: i64 = 30 * 60 * 1_000_000; + +/// One prepared interactive submission, ready to become a `SubmissionRound`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreparedRound { + pub index: i64, + pub description: String, + /// Lowercase hex of the serialized `PreparedTransaction`. + pub prepared_transaction_hex: String, + /// Lowercase hex of `prepared_transaction_hash`. + pub prepared_hash_hex: String, + pub hashing_scheme_version: i64, + /// Micros since the epoch. + pub preparation_time: i64, + pub max_record_time: i64, + pub deadline: i64, +} + +/// A `SubmissionSignature` the proposer verified locally. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VerifiedSignature { + pub signed_by: String, + pub signature: Vec, + pub format: String, + pub algorithm: String, + pub participant_id: String, +} + +/// `deadline = min(max_record_time, preparation_time + tolerance) - 30 min`. +pub fn deadline_for(preparation_time: i64, max_record_time: i64, tolerance_micros: i64) -> i64 { + max_record_time + .min(preparation_time.saturating_add(tolerance_micros)) + .saturating_sub(DEADLINE_SAFETY_MARGIN_MICROS) +} + +/// The live `preparationTimeRecordTimeTolerance` of the synchronizer, in +/// micros. +/// +/// # Errors +/// Returns an error when the parameters cannot be read. +pub async fn read_record_time_tolerance( + _config: &NodeConfig, + _synchronizer_id: &str, +) -> Result { + bail!("not implemented: read preparationTimeRecordTimeTolerance (design D7)") +} + +/// `PrepareSubmission` once per contract definition as the party, with +/// `max_record_time = now + 20 h`, and compute each round's deadline. +/// +/// # Errors +/// Returns an error when a prepare call fails or the tolerance is unknown. +pub async fn prepare_rounds( + _ol: &OnLedger, + _run: &WorkflowRun, + _dec_party_id: &CantonId, + _contracts: &[ContractDefinition], +) -> Result> { + bail!("not implemented: PrepareSubmission per contract (design D7)") +} + +/// Create one `SubmissionRound` per prepared transaction, observed by +/// `signers`, and return the contract ids. +/// +/// # Errors +/// Returns an error when a create fails. +pub async fn open_rounds( + _client: &CoordinationClient, + _run_id: &str, + _signers: &[CantonId], + _dec_party_id: &CantonId, + _act_as: &CantonId, + _rounds: &[PreparedRound], +) -> Result> { + bail!("not implemented: create SubmissionRound contracts (design D7)") +} + +/// Every active `SubmissionRound` of one run visible to this node. +/// +/// # Errors +/// Returns an error when the read fails. +pub async fn read_rounds_for_run( + client: &CoordinationClient, + run_id: &str, +) -> Result>> { + Ok(client + .list_active::() + .await? + .into_iter() + .filter(|r| r.record.run_id == run_id) + .collect()) +} + +/// Every active `SubmissionSignature` of one round. +/// +/// # Errors +/// Returns an error when the read fails. +pub async fn read_signatures_for_round( + client: &CoordinationClient, + round_cid: &str, +) -> Result>> { + Ok(client + .list_active::() + .await? + .into_iter() + .filter(|s| s.record.round == round_cid) + .collect()) +} + +/// Proposer side: `signedBy` is one of the head P2P's party signing keys and +/// the signature verifies against that key over `hash` (Ed25519 CONCAT, +/// ECDSA DER). +/// +/// # Errors +/// Returns an error when the key is unknown or the signature does not +/// verify. +pub fn verify_signature( + _head_p2p: &PartyToParticipant, + _signature: &SubmissionSignatureRecord, + _hash: &[u8], +) -> Result { + bail!("not implemented: verify a SubmissionSignature locally (design D7)") +} + +/// One verified signature per fingerprint; the first wins. +pub fn dedupe_verified( + signatures: impl IntoIterator, +) -> BTreeMap { + let mut out = BTreeMap::new(); + for s in signatures { + out.entry(s.signed_by.clone()).or_insert(s); + } + out +} + +/// `ExecuteSubmissionAndWait` with exactly the verified set, once the count +/// reaches `party_signing_keys.threshold`. Returns the update id. +/// +/// # Errors +/// Returns an error when the execute call fails or the round expired. +pub async fn execute_round( + _ol: &OnLedger, + _dec_party_id: &CantonId, + _round: &ActiveContract, + _signatures: &BTreeMap, +) -> Result { + bail!("not implemented: ExecuteSubmissionAndWait (design D7)") +} + +/// Exercise `SubmissionRound_Close` with a result text. +/// +/// # Errors +/// Returns an error when the submission fails. +pub async fn close_round(client: &CoordinationClient, round_cid: &str, result: &str) -> Result<()> { + let args = CloseArgs { + result: result.to_string(), + }; + client + .exercise( + CoordinationTemplate::SubmissionRound, + round_cid, + choices::SUBMISSION_ROUND_CLOSE, + args.to_value(), + ) + .await + .context("SubmissionRound_Close")?; + Ok(()) +} + +/// Member side (design D7): the round names the accepted party, `act_as` is +/// that party, `maxRecordTime` is present, `deadline > now`, this node's key +/// is among `party_signing_keys`, the hash recomputes with `canton_hash`, +/// and every root node is a `Create` in one of the accepted package names. +/// +/// # Errors +/// Returns an error naming the first rule that fails. +pub fn check_round( + _round: &SubmissionRoundRecord, + _accepted: &WorkflowProposalRecord, + _head_p2p: &PartyToParticipant, + _own_key_fingerprint: &str, + _now_micros: i64, +) -> Result<()> { + bail!("not implemented: member checks of a SubmissionRound (design D7)") +} + +/// Sign the round's hash with this node's per-party key (vault export or +/// KMS) and exercise `SubmissionRound_Sign`. Returns the signature cid. +/// +/// # Errors +/// Returns an error when signing or the submission fails. +pub async fn sign_round( + _ol: &OnLedger, + _round: &ActiveContract, + _dec_party_id: &CantonId, +) -> Result { + bail!("not implemented: sign a SubmissionRound (design D7)") +} + +/// Archive this node's `SubmissionSignature`s whose round is no longer +/// active (the housekeeping sweep of design D10). +/// +/// # Errors +/// Returns an error when the read fails; a failed archive is logged. +pub async fn archive_own_signatures( + client: &CoordinationClient, + active_round_cids: &std::collections::HashSet, +) -> Result { + let me = client.node_party(); + let mut archived = 0; + for s in client + .list_active::() + .await? + .iter() + .filter(|s| s.record.signer == *me && !active_round_cids.contains(&s.record.round)) + { + match client + .exercise( + CoordinationTemplate::SubmissionSignature, + &s.contract_id, + choices::SUBMISSION_SIGNATURE_ARCHIVE, + unit_argument(), + ) + .await + { + Ok(_) => archived += 1, + Err(e) => tracing::warn!( + contract_id = %s.contract_id, + error = %e, + "archiving a stale SubmissionSignature failed; retrying next tick" + ), + } + } + Ok(archived) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deadline_is_the_earlier_bound_minus_the_margin() { + let prep = 1_000 * 1_000_000; + let max_record = prep + MAX_RECORD_TIME_HORIZON_MICROS; + // A 24 h tolerance: max_record_time is the earlier bound. + let tolerance = 24 * 3600 * 1_000_000; + assert_eq!( + deadline_for(prep, max_record, tolerance), + max_record - DEADLINE_SAFETY_MARGIN_MICROS + ); + // A 1 h tolerance: preparation time + tolerance is the earlier bound. + let tolerance = 3600 * 1_000_000; + assert_eq!( + deadline_for(prep, max_record, tolerance), + prep + tolerance - DEADLINE_SAFETY_MARGIN_MICROS + ); + } + + #[test] + fn dedupe_keeps_the_first_signature_per_fingerprint() { + let sig = |fp: &str, n: u8| VerifiedSignature { + signed_by: fp.into(), + signature: vec![n], + format: "SIGNATURE_FORMAT_CONCAT".into(), + algorithm: "SIGNING_ALGORITHM_SPEC_ED25519".into(), + participant_id: "p".into(), + }; + let deduped = dedupe_verified([sig("a", 1), sig("b", 2), sig("a", 3)]); + assert_eq!(deduped.len(), 2); + assert_eq!(deduped["a"].signature, vec![1]); + } +} diff --git a/crates/decman/src/onledger/topology.rs b/crates/decman/src/onledger/topology.rs new file mode 100644 index 000000000..67bd47278 --- /dev/null +++ b/crates/decman/src/onledger/topology.rs @@ -0,0 +1,1295 @@ +//! Topology-store reads and writes for the co-sign model (design D5). +//! +//! The proposer writes a partially-signed proposal into the synchronizer +//! store. Every member reads it back with `proposals = true`, validates it, +//! and co-signs by transaction hash. Canton merges the signatures. Nothing in +//! this module ships transaction bytes between nodes. +//! +//! Discovery reads use `Snapshot(MaxValue)`, never `HeadState`: a fresh +//! proposal is invisible to `HeadState` until the node observes a sequencer +//! timestamp past `valid_from`, and that lag can exceed one poll interval. +//! "Effective" checks compare `valid_from` with the local clock instead. +//! +//! Mapping builders return canonical bytes: owners sorted, hosts sorted by +//! participant uid, keys sorted by fingerprint. Canton hashes the serialized +//! mapping, so two nodes that build the same change must produce the same +//! bytes, or their proposals never merge. + +use std::{ + collections::{BTreeMap, BTreeSet}, + time::Duration, +}; + +use anyhow::{Context, Result, bail}; +use canton_proto_rs::com::digitalasset::canton::{ + crypto::v30::{SigningKeysWithThreshold, SigningPublicKey}, + protocol::v30::{ + DecentralizedNamespaceDefinition, PartyToParticipant, SignedTopologyTransaction, + TopologyMapping, + enums::{ParticipantPermission, TopologyChangeOp}, + party_to_participant::{HostingParticipant, hosting_participant}, + topology_mapping, + }, + topology::admin::v30::{ + AuthorizeRequest, BaseQuery, BaseResult, ListDecentralizedNamespaceDefinitionRequest, + ListNamespaceDelegationRequest, ListPartyToParticipantRequest, authorize_request, + base_query, list_namespace_delegation_response::result::Item as NsDelegationItem, + list_party_to_participant_response::result::Item as P2pItem, + topology_manager_read_service_client::TopologyManagerReadServiceClient, + }, +}; +use common::canton_id::CantonId; +use prost::Message; +use prost_types::Timestamp; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::{ + config::NodeConfig, + consts, + utils::{self, MULTIHASH_SHA256_PREFIX}, + workflow::topology::{authorize_with_topology_retry, synchronizer_store_id}, +}; + +use super::now_micros; + +/// Canton `HashPurpose.TopologyTransactionSignature`: the domain separator of +/// a topology transaction hash. +pub const HASH_PURPOSE_TOPOLOGY_TRANSACTION_SIGNATURE: i32 = 11; + +/// Canton `HashPurpose.DecentralizedNamespaceNamespace`: the domain separator +/// of a decentralized namespace. +pub const HASH_PURPOSE_DECENTRALIZED_NAMESPACE: i32 = 37; + +const MICROS_PER_SEC: i64 = 1_000_000; + +// --------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------- + +/// The Daml-LF maximum timestamp, `9999-12-31T23:59:59.999999Z`. Splice uses +/// it as `TopologySnapshot.Sequenced` so a read sees rows that are sequenced +/// but not yet effective. +pub fn max_timestamp() -> Timestamp { + Timestamp { + seconds: 253_402_300_799, + nanos: 999_999_000, + } +} + +fn snapshot_query(synchronizer_id: &str, proposals: bool) -> BaseQuery { + BaseQuery { + store: Some(synchronizer_store_id(synchronizer_id)), + proposals, + operation: TopologyChangeOp::AddReplace as i32, + time_query: Some(base_query::TimeQuery::Snapshot(max_timestamp())), + filter_signed_key: String::new(), + protocol_version: None, + client_version: None, + } +} + +/// The discovery query: pending `ADD_REPLACE` proposals in the synchronizer +/// store, sequenced or effective. +pub fn proposals_query(synchronizer_id: &str) -> BaseQuery { + snapshot_query(synchronizer_id, true) +} + +/// The accepted-state query: the fully authorized `ADD_REPLACE` mapping, +/// sequenced or effective. Compare `valid_from` with the clock before +/// treating it as in force. +pub fn accepted_query(synchronizer_id: &str) -> BaseQuery { + snapshot_query(synchronizer_id, false) +} + +/// Micros since the epoch of a proto timestamp. +pub fn timestamp_micros(ts: &Timestamp) -> i64 { + ts.seconds + .saturating_mul(MICROS_PER_SEC) + .saturating_add(i64::from(ts.nanos) / 1_000) +} + +/// Whether `valid_from` has passed on the local clock. A missing timestamp is +/// never effective. +pub fn is_effective(valid_from: Option<&Timestamp>, now: i64) -> bool { + valid_from.is_some_and(|ts| timestamp_micros(ts) <= now) +} + +// --------------------------------------------------------------------------- +// Pending and accepted rows +// --------------------------------------------------------------------------- + +/// One pending proposal for a mapping, with the context a co-signer needs. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingProposal { + /// Lowercase hex of `BaseResult.transaction_hash`: the exact string + /// `Authorize { transaction_hash }` takes. + pub hash_hex: String, + pub serial: u32, + /// Long-term key fingerprints that have signed so far. + pub signed_by: Vec, + /// `TopologyChangeOp` as the wire integer. Validation pins `ADD_REPLACE`. + pub operation: i32, + pub mapping: M, + pub sequenced: Option, + pub valid_from: Option, +} + +impl PendingProposal { + pub fn is_add_replace(&self) -> bool { + self.operation == TopologyChangeOp::AddReplace as i32 + } + + pub fn is_signed_by(&self, fingerprint: &str) -> bool { + self.signed_by.iter().any(|f| f == fingerprint) + } +} + +/// The accepted mapping at its latest serial. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AcceptedMapping { + pub serial: u32, + pub mapping: M, + pub valid_from: Option, +} + +impl AcceptedMapping { + pub fn is_effective(&self, now: i64) -> bool { + is_effective(self.valid_from.as_ref(), now) + } +} + +/// Lowercase hex of the transaction hash in a read result. +pub fn hash_hex_of(context: &BaseResult) -> String { + hex::encode(&context.transaction_hash) +} + +fn serial_of(context: &BaseResult) -> u32 { + u32::try_from(context.serial).unwrap_or(0) +} + +fn pending_from(context: &BaseResult, mapping: M) -> PendingProposal { + PendingProposal { + hash_hex: hash_hex_of(context), + serial: serial_of(context), + signed_by: context.signed_by_fingerprints.clone(), + operation: context.operation, + mapping, + sequenced: context.sequenced, + valid_from: context.valid_from, + } +} + +/// Keep the row with the highest serial; Canton promises no order. +fn latest(rows: Vec<(BaseResult, M)>) -> Option> { + rows.into_iter() + .max_by_key(|(ctx, _)| ctx.serial) + .map(|(ctx, mapping)| AcceptedMapping { + serial: serial_of(&ctx), + mapping, + valid_from: ctx.valid_from, + }) +} + +async fn read_client( + config: &NodeConfig, +) -> Result> { + Ok( + TopologyManagerReadServiceClient::new(config.admin_channel().await?) + .max_decoding_message_size(utils::MAX_GRPC_MESSAGE_SIZE), + ) +} + +async fn list_dnd( + config: &NodeConfig, + query: BaseQuery, + namespace: &str, +) -> Result> { + let mut client = read_client(config).await?; + let response = client + .list_decentralized_namespace_definition(tonic::Request::new( + ListDecentralizedNamespaceDefinitionRequest { + base_query: Some(query), + filter_namespace: namespace.to_string(), + }, + )) + .await + .context("ListDecentralizedNamespaceDefinition")? + .into_inner(); + Ok(response + .results + .into_iter() + .filter_map(|r| Some((r.context?, r.item?))) + // `filter_namespace` is a prefix match; pin the exact namespace. + .filter(|(_, m)| namespace.is_empty() || m.decentralized_namespace == namespace) + .collect()) +} + +async fn list_p2p( + config: &NodeConfig, + query: BaseQuery, + party: &str, +) -> Result> { + let mut client = read_client(config).await?; + let response = client + .list_party_to_participant(tonic::Request::new(ListPartyToParticipantRequest { + base_query: Some(query), + filter_party: party.to_string(), + filter_participant: String::new(), + })) + .await + .context("ListPartyToParticipant")? + .into_inner(); + Ok(response + .results + .into_iter() + .filter_map(|r| { + let P2pItem::V30(mapping) = r.item?; + Some((r.context?, mapping)) + }) + // `filter_party` is a prefix match; pin the exact party. + .filter(|(_, m)| party.is_empty() || m.party == party) + .collect()) +} + +/// Pending `DecentralizedNamespaceDefinition` proposals for one namespace. +/// +/// # Errors +/// Returns an error when the topology read fails. +pub async fn list_pending_dnd( + config: &NodeConfig, + synchronizer_id: &str, + namespace: &str, +) -> Result>> { + Ok( + list_dnd(config, proposals_query(synchronizer_id), namespace) + .await? + .into_iter() + .map(|(ctx, m)| pending_from(&ctx, m)) + .collect(), + ) +} + +/// Pending `PartyToParticipant` proposals for one party. +/// +/// # Errors +/// Returns an error when the topology read fails. +pub async fn list_pending_p2p( + config: &NodeConfig, + synchronizer_id: &str, + party: &CantonId, +) -> Result>> { + Ok( + list_p2p(config, proposals_query(synchronizer_id), &party.to_string()) + .await? + .into_iter() + .map(|(ctx, m)| pending_from(&ctx, m)) + .collect(), + ) +} + +/// The accepted `DecentralizedNamespaceDefinition` of one namespace, or +/// `None` when the namespace has no authorized definition yet. +/// +/// # Errors +/// Returns an error when the topology read fails. +pub async fn read_accepted_dnd( + config: &NodeConfig, + synchronizer_id: &str, + namespace: &str, +) -> Result>> { + Ok(latest( + list_dnd(config, accepted_query(synchronizer_id), namespace).await?, + )) +} + +/// The accepted `PartyToParticipant` of one party, or `None` when the party +/// has no authorized mapping yet. +/// +/// # Errors +/// Returns an error when the topology read fails. +pub async fn read_accepted_p2p( + config: &NodeConfig, + synchronizer_id: &str, + party: &CantonId, +) -> Result>> { + Ok(latest( + list_p2p(config, accepted_query(synchronizer_id), &party.to_string()).await?, + )) +} + +// --------------------------------------------------------------------------- +// Hashing +// --------------------------------------------------------------------------- + +/// The Canton transaction hash of a `SignedTopologyTransaction.transaction` +/// (the versioned envelope bytes), as the lowercase multihash hex string +/// `Authorize { transaction_hash }` and `BaseResult.transaction_hash` use. +/// +/// `multihash_sha256(be32(HashPurpose 11) || bytes)`. +pub fn transaction_hash_of(versioned_transaction: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(HASH_PURPOSE_TOPOLOGY_TRANSACTION_SIGNATURE.to_be_bytes()); + hasher.update(versioned_transaction); + format!( + "{MULTIHASH_SHA256_PREFIX}{}", + hex::encode(hasher.finalize()) + ) +} + +/// The decentralized namespace of an owner set: +/// `multihash_sha256(be32(37) || (be32(len) || utf8(owner))* )` over the +/// sorted, deduplicated owners. +pub fn compute_namespace<'a>(owners: impl IntoIterator) -> String { + let sorted: BTreeSet<&String> = owners.into_iter().collect(); + let mut hasher = Sha256::new(); + hasher.update(HASH_PURPOSE_DECENTRALIZED_NAMESPACE.to_be_bytes()); + for owner in sorted { + let bytes = owner.as_bytes(); + hasher.update(i32::try_from(bytes.len()).unwrap_or(i32::MAX).to_be_bytes()); + hasher.update(bytes); + } + format!( + "{MULTIHASH_SHA256_PREFIX}{}", + hex::encode(hasher.finalize()) + ) +} + +// --------------------------------------------------------------------------- +// Writes +// --------------------------------------------------------------------------- + +/// What `propose_mapping` wrote. +#[derive(Clone, Debug, PartialEq)] +pub struct ProposedTx { + /// Computed locally with [`transaction_hash_of`]; pin it on the run row. + pub hash_hex: String, + pub transaction: SignedTopologyTransaction, +} + +fn mapping_label(mapping: &TopologyMapping) -> &'static str { + match &mapping.mapping { + Some(topology_mapping::Mapping::DecentralizedNamespaceDefinition(_)) => "DND", + Some(topology_mapping::Mapping::PartyToParticipant(_)) => "P2P", + Some(topology_mapping::Mapping::NamespaceDelegation(_)) => "NSD", + _ => "topology mapping", + } +} + +/// Propose `mapping` at `serial` into the synchronizer store with this +/// node's own keys only (`must_fully_authorize = false`, `signed_by = []`, +/// no force flags). Canton pre-validates against the synchronizer head +/// state, so a rejection returns as an error and nothing is enqueued. +/// +/// # Errors +/// Returns an error when Canton rejects the proposal, or when the retry +/// budget for `TOPOLOGY_NO_APPROPRIATE_SIGNING_KEY_IN_STORE` is exhausted. +pub async fn propose_mapping( + config: &NodeConfig, + synchronizer_id: &str, + mapping: TopologyMapping, + serial: u32, +) -> Result { + let label = mapping_label(&mapping); + let request = AuthorizeRequest { + r#type: Some(authorize_request::Type::Proposal( + authorize_request::Proposal { + change: TopologyChangeOp::AddReplace as i32, + serial, + mapping: Some(authorize_request::proposal::Mapping::V30(mapping)), + }, + )), + must_fully_authorize: false, + force_changes: vec![], + signed_by: vec![], + store: Some(synchronizer_store_id(synchronizer_id)), + wait_to_become_effective: None, + }; + let response = authorize_with_topology_retry(config, request, label).await?; + let transaction = response + .transaction + .with_context(|| format!("{label}: Authorize returned no transaction"))?; + let hash_hex = transaction_hash_of(&transaction.transaction); + tracing::info!(%hash_hex, serial, "{label}: proposed into the synchronizer store"); + Ok(ProposedTx { + hash_hex, + transaction, + }) +} + +/// What a co-sign by hash did. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CosignOutcome { + /// The hash is not in this participant's synchronizer store yet. Retry + /// on the next poll. + NotFound, + /// This node added at least one signature. + Signed, + /// Every key this node holds had already signed; Canton returned the + /// stored transaction unchanged. + AlreadySigned, +} + +fn is_transaction_not_found(err: &anyhow::Error) -> bool { + let text = format!("{err:#}"); + text.contains("TOPOLOGY_TRANSACTION_NOT_FOUND") || text.contains("TopologyTransactionNotFound") +} + +/// Co-sign the pending proposal with hash `hash_hex` using this node's keys +/// (`Authorize { transaction_hash }`, `must_fully_authorize = false`, +/// `signed_by = []`, synchronizer store). Idempotent: Canton skips keys that +/// already signed. +/// +/// `previously_signed_by` is the `signed_by` list this node observed on the +/// proposal; the outcome compares the returned signer set against it. +/// +/// # Errors +/// Returns an error when Canton rejects the call for a reason other than a +/// missing hash, including `TOPOLOGY_NO_APPROPRIATE_SIGNING_KEY_IN_STORE` +/// after the retry budget. +pub async fn cosign_by_hash( + config: &NodeConfig, + synchronizer_id: &str, + hash_hex: &str, + previously_signed_by: &[String], +) -> Result { + let request = AuthorizeRequest { + r#type: Some(authorize_request::Type::TransactionHash( + hash_hex.to_string(), + )), + must_fully_authorize: false, + force_changes: vec![], + signed_by: vec![], + store: Some(synchronizer_store_id(synchronizer_id)), + wait_to_become_effective: None, + }; + let response = match authorize_with_topology_retry(config, request, "co-sign").await { + Ok(r) => r, + Err(e) if is_transaction_not_found(&e) => return Ok(CosignOutcome::NotFound), + Err(e) => return Err(e), + }; + let Some(transaction) = response.transaction else { + bail!("co-sign of {hash_hex}: Authorize returned no transaction"); + }; + let before: BTreeSet<&str> = previously_signed_by.iter().map(String::as_str).collect(); + let added = transaction + .signatures + .iter() + .any(|s| !before.contains(s.signed_by.as_str())); + Ok(if added { + CosignOutcome::Signed + } else { + CosignOutcome::AlreadySigned + }) +} + +// --------------------------------------------------------------------------- +// Waits +// --------------------------------------------------------------------------- + +/// How long a poll may run: `max_attempts` reads, `delay` apart. +#[derive(Clone, Copy, Debug)] +pub struct WaitBudget { + pub max_attempts: usize, + pub delay: Duration, +} + +impl Default for WaitBudget { + /// The shared topology retry knobs (`DECPM_TOPOLOGY_RETRY_*`). + fn default() -> Self { + Self { + max_attempts: consts::topology_retry_max_attempts().max(1), + delay: Duration::from_secs(consts::topology_retry_delay_secs()), + } + } +} + +/// Which mapping a wait targets. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MappingKey { + /// A `DecentralizedNamespaceDefinition`, by its namespace. + Dnd(String), + /// A `PartyToParticipant`, by its party. + P2p(CantonId), +} + +impl std::fmt::Display for MappingKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Dnd(ns) => write!(f, "DND {ns}"), + Self::P2p(party) => write!(f, "P2P {party}"), + } + } +} + +/// The accepted serial and `valid_from` of a mapping, type-erased. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AcceptedState { + pub serial: u32, + pub valid_from: Option, +} + +/// Read the accepted serial of `key`, or `None` when no mapping exists. +/// +/// # Errors +/// Returns an error when the topology read fails. +pub async fn read_accepted_state( + config: &NodeConfig, + synchronizer_id: &str, + key: &MappingKey, +) -> Result> { + Ok(match key { + MappingKey::Dnd(ns) => read_accepted_dnd(config, synchronizer_id, ns) + .await? + .map(|a| AcceptedState { + serial: a.serial, + valid_from: a.valid_from, + }), + MappingKey::P2p(party) => read_accepted_p2p(config, synchronizer_id, party) + .await? + .map(|a| AcceptedState { + serial: a.serial, + valid_from: a.valid_from, + }), + }) +} + +/// Poll the accepted state of `key` until its serial reaches `serial` and +/// its `valid_from` has passed on the local clock (design D5 step 7). +/// +/// # Errors +/// Returns an error when the budget runs out, or when the accepted serial +/// has moved past `serial` with a different transaction (the caller reads +/// the mapping again and decides). +pub async fn wait_effective( + config: &NodeConfig, + synchronizer_id: &str, + key: &MappingKey, + serial: u32, + budget: WaitBudget, +) -> Result { + for attempt in 1..=budget.max_attempts { + match read_accepted_state(config, synchronizer_id, key).await { + Ok(Some(state)) if state.serial >= serial => { + if is_effective(state.valid_from.as_ref(), now_micros()) { + tracing::info!(%key, serial = state.serial, "mapping is effective"); + return Ok(state); + } + tracing::debug!(%key, serial = state.serial, "mapping accepted, not yet effective"); + } + Ok(Some(state)) => { + tracing::debug!(%key, accepted = state.serial, wanted = serial, "waiting for serial"); + } + Ok(None) => tracing::debug!(%key, "no accepted mapping yet"), + Err(e) => tracing::warn!(%key, attempt, error = %e, "accepted-state read failed"), + } + if attempt < budget.max_attempts { + tokio::time::sleep(budget.delay).await; + } + } + bail!( + "{key} did not reach serial {serial} and become effective within {} attempts", + budget.max_attempts + ) +} + +// --------------------------------------------------------------------------- +// Root namespace delegations (design D4) +// --------------------------------------------------------------------------- + +/// The root `NamespaceDelegation` of `fingerprint` in the synchronizer +/// store, with its `valid_from`: the delegation whose namespace and target +/// key fingerprint are both `fingerprint`. +/// +/// # Errors +/// Returns an error when the topology read fails. +pub async fn read_root_delegation( + config: &NodeConfig, + synchronizer_id: &str, + fingerprint: &str, +) -> Result)>> { + let mut client = read_client(config).await?; + let response = client + .list_namespace_delegation(tonic::Request::new(ListNamespaceDelegationRequest { + base_query: Some(accepted_query(synchronizer_id)), + filter_namespace: fingerprint.to_string(), + filter_target_key_fingerprint: fingerprint.to_string(), + })) + .await + .context("ListNamespaceDelegation")? + .into_inner(); + let mut found = None; + for result in response.results { + let Some(NsDelegationItem::V30(item)) = result.item else { + continue; + }; + // Both filters are prefix matches; pin the exact namespace. + if item.namespace != fingerprint { + continue; + } + let Some(key) = item.target_key else { + continue; + }; + let valid_from = result.context.and_then(|c| c.valid_from); + found = Some((key, valid_from)); + } + Ok(found) +} + +/// The public key behind a root delegation, asserted to fingerprint to +/// `fingerprint`. This is the single source of key bytes for +/// `party_signing_keys` (design D4). +/// +/// # Errors +/// Returns an error when no root delegation exists, when it is not yet +/// effective, or when the key does not fingerprint to `fingerprint`. +pub async fn read_root_delegation_key( + config: &NodeConfig, + synchronizer_id: &str, + fingerprint: &str, +) -> Result { + let Some((key, valid_from)) = + read_root_delegation(config, synchronizer_id, fingerprint).await? + else { + bail!("no root NamespaceDelegation for {fingerprint} in the synchronizer store"); + }; + if !is_effective(valid_from.as_ref(), now_micros()) { + bail!("root NamespaceDelegation for {fingerprint} is not effective yet"); + } + let computed = utils::compute_fingerprint(&key); + if computed != fingerprint { + bail!( + "root NamespaceDelegation for {fingerprint} carries a key that fingerprints to \ + {computed}" + ); + } + Ok(key) +} + +/// Wait until every owner's root delegation is effective in the synchronizer +/// store. A DND proposal is rejected before that (design D5 order rule). +/// +/// # Errors +/// Returns an error naming the owners still missing when the budget runs out. +pub async fn wait_owner_root_delegations( + config: &NodeConfig, + synchronizer_id: &str, + fingerprints: &[String], + budget: WaitBudget, +) -> Result> { + let mut keys: BTreeMap = BTreeMap::new(); + for attempt in 1..=budget.max_attempts { + for fp in fingerprints { + if keys.contains_key(fp) { + continue; + } + match read_root_delegation_key(config, synchronizer_id, fp).await { + Ok(key) => { + keys.insert(fp.clone(), key); + } + Err(e) => { + tracing::debug!(fingerprint = %fp, attempt, error = %e, "root NSD not ready") + } + } + } + if fingerprints.iter().all(|fp| keys.contains_key(fp)) { + return Ok(keys); + } + if attempt < budget.max_attempts { + tokio::time::sleep(budget.delay).await; + } + } + let missing: Vec<&String> = fingerprints + .iter() + .filter(|fp| !keys.contains_key(*fp)) + .collect(); + bail!( + "root NamespaceDelegations still missing from the synchronizer store after {} attempts: {missing:?}", + budget.max_attempts + ) +} + +// --------------------------------------------------------------------------- +// Unsolicited scan (design D5, UI only) +// --------------------------------------------------------------------------- + +/// A pending proposal this node did not ask for. Read-only; the observer +/// never signs from this list. +/// +/// TODO(common/src/coordination.rs): this DTO belongs next to +/// `RegistryResponse` so `GET /proposals/unsolicited` and `gen-types` export +/// it. Kept here until that file is open for edits. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct UnsolicitedProposal { + /// `DecentralizedNamespaceDefinition` or `PartyToParticipant`. + pub mapping: String, + /// The namespace or the party id. + pub key: String, + pub hash_hex: String, + pub serial: u32, + pub signed_by: Vec, + /// Micros since the epoch, when known. + pub sequenced_at: Option, +} + +/// Every pending DND and P2P proposal in the synchronizer store. +/// +/// # Errors +/// Returns an error when a topology read fails. +pub async fn scan_unsolicited( + config: &NodeConfig, + synchronizer_id: &str, +) -> Result> { + let mut out = Vec::new(); + for (ctx, m) in list_dnd(config, proposals_query(synchronizer_id), "").await? { + out.push(UnsolicitedProposal { + mapping: "DecentralizedNamespaceDefinition".into(), + key: m.decentralized_namespace, + hash_hex: hash_hex_of(&ctx), + serial: serial_of(&ctx), + signed_by: ctx.signed_by_fingerprints, + sequenced_at: ctx.sequenced.as_ref().map(timestamp_micros), + }); + } + for (ctx, m) in list_p2p(config, proposals_query(synchronizer_id), "").await? { + out.push(UnsolicitedProposal { + mapping: "PartyToParticipant".into(), + key: m.party, + hash_hex: hash_hex_of(&ctx), + serial: serial_of(&ctx), + signed_by: ctx.signed_by_fingerprints, + sequenced_at: ctx.sequenced.as_ref().map(timestamp_micros), + }); + } + out.sort_by(|a, b| (&a.key, a.serial, &a.hash_hex).cmp(&(&b.key, b.serial, &b.hash_hex))); + Ok(out) +} + +// --------------------------------------------------------------------------- +// Mapping builders +// --------------------------------------------------------------------------- + +/// The DND inside a mapping, if it is one. +pub fn dnd_of(mapping: &TopologyMapping) -> Option<&DecentralizedNamespaceDefinition> { + match &mapping.mapping { + Some(topology_mapping::Mapping::DecentralizedNamespaceDefinition(d)) => Some(d), + _ => None, + } +} + +/// The P2P inside a mapping, if it is one. +pub fn p2p_of(mapping: &TopologyMapping) -> Option<&PartyToParticipant> { + match &mapping.mapping { + Some(topology_mapping::Mapping::PartyToParticipant(p)) => Some(p), + _ => None, + } +} + +fn wrap_dnd(dnd: DecentralizedNamespaceDefinition) -> TopologyMapping { + TopologyMapping { + mapping: Some(topology_mapping::Mapping::DecentralizedNamespaceDefinition( + dnd, + )), + } +} + +fn wrap_p2p(p2p: PartyToParticipant) -> TopologyMapping { + TopologyMapping { + mapping: Some(topology_mapping::Mapping::PartyToParticipant(p2p)), + } +} + +fn to_i32(threshold: u32) -> i32 { + i32::try_from(threshold).unwrap_or(i32::MAX) +} + +/// A host row at Confirmation, with or without the Onboarding marker. +fn confirmation_host(uid: &str, onboarding: bool) -> HostingParticipant { + HostingParticipant { + participant_uid: uid.to_string(), + permission: ParticipantPermission::Confirmation as i32, + onboarding: onboarding.then_some(hosting_participant::Onboarding {}), + } +} + +/// Canonical form: hosts sorted by uid (one row per uid, the first wins), +/// keys sorted by fingerprint (one per fingerprint). +fn canonical_p2p( + party: String, + hosts: Vec, + keys: Vec, + threshold: u32, +) -> PartyToParticipant { + let mut by_uid: BTreeMap = BTreeMap::new(); + for h in hosts { + by_uid.entry(h.participant_uid.clone()).or_insert(h); + } + let mut by_fp: BTreeMap = BTreeMap::new(); + for k in keys { + by_fp.entry(utils::compute_fingerprint(&k)).or_insert(k); + } + PartyToParticipant { + party, + threshold, + participants: by_uid.into_values().collect(), + party_signing_keys: Some(SigningKeysWithThreshold { + keys: by_fp.into_values().collect(), + threshold, + }), + } +} + +fn head_keys(head: &PartyToParticipant) -> Vec { + head.party_signing_keys + .as_ref() + .map(|k| k.keys.clone()) + .unwrap_or_default() +} + +/// A first DND: owners sorted, namespace computed from them. +pub fn build_dnd(owners: &[String], threshold: u32) -> TopologyMapping { + let sorted: BTreeSet = owners.iter().cloned().collect(); + wrap_dnd(DecentralizedNamespaceDefinition { + decentralized_namespace: compute_namespace(sorted.iter()), + threshold: to_i32(threshold), + owners: sorted.into_iter().collect(), + }) +} + +/// A first P2P for `prefix::namespace`: every host at Confirmation with no +/// Onboarding marker, one key per owner, both thresholds equal. +pub fn build_bootstrap_p2p( + prefix: &str, + namespace: &str, + hosts: &[CantonId], + keys: &[SigningPublicKey], + threshold: u32, +) -> TopologyMapping { + wrap_p2p(canonical_p2p( + format!("{prefix}::{namespace}"), + hosts + .iter() + .map(|h| confirmation_host(&h.to_string(), false)) + .collect(), + keys.to_vec(), + threshold, + )) +} + +/// The head DND plus one owner, at the new threshold. The namespace stays. +pub fn build_add_party_dnd( + head: &DecentralizedNamespaceDefinition, + joiner_fingerprint: &str, + threshold: u32, +) -> TopologyMapping { + let mut owners: BTreeSet = head.owners.iter().cloned().collect(); + owners.insert(joiner_fingerprint.to_string()); + wrap_dnd(DecentralizedNamespaceDefinition { + decentralized_namespace: head.decentralized_namespace.clone(), + threshold: to_i32(threshold), + owners: owners.into_iter().collect(), + }) +} + +/// The head P2P plus the joiner at Confirmation with the Onboarding marker +/// and its key, at the new thresholds. Head hosts keep their tuples. +pub fn build_add_party_p2p( + head: &PartyToParticipant, + joiner: &CantonId, + joiner_key: &SigningPublicKey, + threshold: u32, +) -> TopologyMapping { + let joiner_uid = joiner.to_string(); + let mut hosts: Vec = head + .participants + .iter() + .filter(|h| h.participant_uid != joiner_uid) + .cloned() + .collect(); + hosts.push(confirmation_host(&joiner_uid, true)); + let mut keys = head_keys(head); + keys.push(joiner_key.clone()); + wrap_p2p(canonical_p2p(head.party.clone(), hosts, keys, threshold)) +} + +/// The head DND minus one owner, at the new threshold. +pub fn build_kick_dnd( + head: &DecentralizedNamespaceDefinition, + kicked_fingerprint: &str, + threshold: u32, +) -> TopologyMapping { + let owners: BTreeSet = head + .owners + .iter() + .filter(|o| o.as_str() != kicked_fingerprint) + .cloned() + .collect(); + wrap_dnd(DecentralizedNamespaceDefinition { + decentralized_namespace: head.decentralized_namespace.clone(), + threshold: to_i32(threshold), + owners: owners.into_iter().collect(), + }) +} + +/// The head P2P minus the kicked host and minus the key with +/// `kicked_key_fingerprint`, at the new thresholds. Survivors keep their +/// tuples. +pub fn build_kick_p2p( + head: &PartyToParticipant, + kicked: &CantonId, + kicked_key_fingerprint: &str, + threshold: u32, +) -> TopologyMapping { + let kicked_uid = kicked.to_string(); + let hosts: Vec = head + .participants + .iter() + .filter(|h| h.participant_uid != kicked_uid) + .cloned() + .collect(); + let keys: Vec = head_keys(head) + .into_iter() + .filter(|k| utils::compute_fingerprint(k) != kicked_key_fingerprint) + .collect(); + wrap_p2p(canonical_p2p(head.party.clone(), hosts, keys, threshold)) +} + +/// The head DND with only the threshold changed. +pub fn build_change_threshold_dnd( + head: &DecentralizedNamespaceDefinition, + threshold: u32, +) -> TopologyMapping { + let owners: BTreeSet = head.owners.iter().cloned().collect(); + wrap_dnd(DecentralizedNamespaceDefinition { + decentralized_namespace: head.decentralized_namespace.clone(), + threshold: to_i32(threshold), + owners: owners.into_iter().collect(), + }) +} + +/// The head P2P with only the two thresholds changed. +pub fn build_change_threshold_p2p(head: &PartyToParticipant, threshold: u32) -> TopologyMapping { + wrap_p2p(canonical_p2p( + head.party.clone(), + head.participants.clone(), + head_keys(head), + threshold, + )) +} + +/// Serialized mapping bytes, for byte-stability checks and tests. +pub fn mapping_bytes(mapping: &TopologyMapping) -> Vec { + mapping.encode_to_vec() +} + +#[cfg(test)] +pub(crate) mod tests { + use canton_proto_rs::com::digitalasset::canton::crypto::v30::{ + CryptoKeyFormat, SigningKeySpec, SigningKeyUsage, + }; + + use super::*; + + pub(crate) const NS: &str = + "1220c4010d6883f367c7f45d55b2449501620130f9b21e96379f17dea455ac7a5892"; + + pub(crate) fn participant(n: u8) -> CantonId { + CantonId::parse(&format!("participant{n}::{NS}")).expect("valid id") + } + + /// A key whose fingerprint is stable and distinct per `seed`. + pub(crate) fn key(seed: u8) -> SigningPublicKey { + SigningPublicKey { + format: CryptoKeyFormat::Raw as i32, + public_key: vec![seed; 32], + key_spec: SigningKeySpec::EcCurve25519 as i32, + usage: vec![ + SigningKeyUsage::Namespace as i32, + SigningKeyUsage::Protocol as i32, + ], + ..Default::default() + } + } + + pub(crate) fn fp(seed: u8) -> String { + utils::compute_fingerprint(&key(seed)) + } + + fn owner(c: char) -> String { + format!("1220{}", c.to_string().repeat(64)) + } + + #[test] + fn queries_target_the_synchronizer_snapshot_at_max_time() { + let q = proposals_query("global::1220abcd::35-0"); + assert!(q.proposals); + assert_eq!(q.operation, TopologyChangeOp::AddReplace as i32); + assert_eq!( + q.time_query, + Some(base_query::TimeQuery::Snapshot(max_timestamp())) + ); + let a = accepted_query("global::1220abcd::35-0"); + assert!(!a.proposals); + assert_eq!(a.operation, TopologyChangeOp::AddReplace as i32); + assert_eq!(a.time_query, q.time_query); + assert_eq!(a.store, q.store); + } + + #[test] + fn max_timestamp_is_the_daml_lf_maximum() { + // 9999-12-31T23:59:59.999999Z + let ts = max_timestamp(); + assert_eq!(ts.seconds, 253_402_300_799); + assert_eq!(ts.nanos, 999_999_000); + assert_eq!(timestamp_micros(&ts), 253_402_300_799_999_999); + } + + #[test] + fn effectiveness_compares_valid_from_with_now() { + let ts = Timestamp { + seconds: 100, + nanos: 0, + }; + assert!(is_effective(Some(&ts), 100 * MICROS_PER_SEC)); + assert!(!is_effective(Some(&ts), 99 * MICROS_PER_SEC)); + assert!(!is_effective(None, i64::MAX)); + } + + #[test] + fn transaction_hash_is_sha256_with_purpose_11_in_multihash_form() { + // sha256(be32(11) || "abc"), computed independently. + assert_eq!( + transaction_hash_of(b"abc"), + "1220118011a82e79b5c12c7edc776b3bc1285aefb8a6b8eee34bde8c2381fa329455" + ); + assert_eq!(transaction_hash_of(b"").len(), 68); + } + + #[test] + fn namespace_matches_the_known_vector_regardless_of_order() { + // sha256(be32(37) || (be32(len) || owner)*) over the sorted owners, + // the algorithm `compute_decentralized_namespace` implements. + let expected = "1220a200ccc573e9f149e39dfa0b53b06815e5b062f6898706790d035ee2362a956b"; + let owners = [owner('c'), owner('a'), owner('b')]; + assert_eq!(compute_namespace(owners.iter()), expected); + let reversed: Vec = owners.iter().rev().cloned().collect(); + assert_eq!(compute_namespace(reversed.iter()), expected); + let with_duplicate = [owner('a'), owner('b'), owner('c'), owner('a')]; + assert_eq!(compute_namespace(with_duplicate.iter()), expected); + } + + #[test] + fn hash_hex_of_is_lowercase_hex_of_the_result_bytes() { + let ctx = BaseResult { + transaction_hash: vec![0x12, 0x20, 0xAB, 0xCD], + serial: 3, + signed_by_fingerprints: vec!["1220aa".into()], + operation: TopologyChangeOp::AddReplace as i32, + ..Default::default() + }; + assert_eq!(hash_hex_of(&ctx), "1220abcd"); + let pending = pending_from(&ctx, 7u8); + assert_eq!(pending.serial, 3); + assert!(pending.is_add_replace()); + assert!(pending.is_signed_by("1220aa")); + assert!(!pending.is_signed_by("1220bb")); + } + + #[test] + fn latest_keeps_the_highest_serial() { + let row = |serial: i32| { + ( + BaseResult { + serial, + ..Default::default() + }, + serial, + ) + }; + let accepted = latest(vec![row(2), row(5), row(3)]).expect("some"); + assert_eq!(accepted.serial, 5); + assert_eq!(accepted.mapping, 5); + assert!(latest::(vec![]).is_none()); + } + + #[test] + fn dnd_builder_sorts_owners_and_derives_the_namespace() { + let a = build_dnd(&[owner('c'), owner('a'), owner('b')], 2); + let b = build_dnd(&[owner('b'), owner('c'), owner('a')], 2); + assert_eq!(mapping_bytes(&a), mapping_bytes(&b)); + let dnd = dnd_of(&a).expect("dnd"); + assert_eq!(dnd.owners, vec![owner('a'), owner('b'), owner('c')]); + assert_eq!(dnd.threshold, 2); + assert_eq!( + dnd.decentralized_namespace, + compute_namespace(dnd.owners.iter()) + ); + } + + #[test] + fn bootstrap_p2p_is_byte_stable_regardless_of_input_order() { + let hosts = [participant(3), participant(1), participant(2)]; + let keys = [key(3), key(1), key(2)]; + let a = build_bootstrap_p2p("cbtc", NS, &hosts, &keys, 2); + let mut hosts_rev = hosts.clone(); + hosts_rev.reverse(); + let mut keys_rev = keys.clone(); + keys_rev.reverse(); + let b = build_bootstrap_p2p("cbtc", NS, &hosts_rev, &keys_rev, 2); + assert_eq!(mapping_bytes(&a), mapping_bytes(&b)); + + let p2p = p2p_of(&a).expect("p2p"); + assert_eq!(p2p.party, format!("cbtc::{NS}")); + assert_eq!(p2p.threshold, 2); + let uids: Vec<&str> = p2p + .participants + .iter() + .map(|h| h.participant_uid.as_str()) + .collect(); + let mut sorted = uids.clone(); + sorted.sort_unstable(); + assert_eq!(uids, sorted); + assert!(p2p.participants.iter().all(|h| { + h.permission == ParticipantPermission::Confirmation as i32 && h.onboarding.is_none() + })); + let signing = p2p.party_signing_keys.as_ref().expect("keys"); + assert_eq!(signing.threshold, 2); + assert_eq!(signing.keys.len(), 3); + } + + #[test] + fn add_party_builders_add_exactly_the_joiner() { + let head_dnd = dnd_of(&build_dnd(&[fp(1), fp(2)], 2)).expect("dnd").clone(); + let dnd = build_add_party_dnd(&head_dnd, &fp(3), 2); + let dnd = dnd_of(&dnd).expect("dnd"); + assert_eq!(dnd.owners.len(), 3); + assert!(dnd.owners.contains(&fp(3))); + assert_eq!( + dnd.decentralized_namespace, + head_dnd.decentralized_namespace + ); + + let head_p2p = p2p_of(&build_bootstrap_p2p( + "cbtc", + NS, + &[participant(1), participant(2)], + &[key(1), key(2)], + 2, + )) + .expect("p2p") + .clone(); + let p2p = build_add_party_p2p(&head_p2p, &participant(3), &key(3), 2); + let p2p = p2p_of(&p2p).expect("p2p"); + assert_eq!(p2p.participants.len(), 3); + let joiner = p2p + .participants + .iter() + .find(|h| h.participant_uid == participant(3).to_string()) + .expect("joiner"); + assert!(joiner.onboarding.is_some()); + assert!( + p2p.participants + .iter() + .filter(|h| h.participant_uid != participant(3).to_string()) + .all(|h| h.onboarding.is_none()) + ); + let keys = p2p.party_signing_keys.as_ref().expect("keys"); + assert_eq!(keys.keys.len(), 3); + // Re-adding the same joiner is idempotent. + let again = build_add_party_p2p(p2p, &participant(3), &key(3), 2); + assert_eq!(mapping_bytes(&again), mapping_bytes(&wrap_p2p(p2p.clone()))); + } + + #[test] + fn kick_builders_remove_exactly_the_kicked_member() { + let head_dnd = dnd_of(&build_dnd(&[fp(1), fp(2), fp(3)], 2)) + .expect("dnd") + .clone(); + let dnd = build_kick_dnd(&head_dnd, &fp(2), 2); + let dnd = dnd_of(&dnd).expect("dnd"); + let mut expected = vec![fp(1), fp(3)]; + expected.sort(); + assert_eq!(dnd.owners, expected); + + let head_p2p = p2p_of(&build_bootstrap_p2p( + "cbtc", + NS, + &[participant(1), participant(2), participant(3)], + &[key(1), key(2), key(3)], + 2, + )) + .expect("p2p") + .clone(); + let p2p = build_kick_p2p(&head_p2p, &participant(2), &fp(2), 2); + let p2p = p2p_of(&p2p).expect("p2p"); + assert_eq!(p2p.participants.len(), 2); + assert!( + p2p.participants + .iter() + .all(|h| h.participant_uid != participant(2).to_string()) + ); + let keys = p2p.party_signing_keys.as_ref().expect("keys"); + assert_eq!(keys.keys.len(), 2); + assert!( + keys.keys + .iter() + .all(|k| utils::compute_fingerprint(k) != fp(2)) + ); + } + + #[test] + fn change_threshold_builders_change_only_the_thresholds() { + let head_dnd = dnd_of(&build_dnd(&[fp(1), fp(2), fp(3)], 2)) + .expect("dnd") + .clone(); + let dnd = build_change_threshold_dnd(&head_dnd, 3); + let dnd = dnd_of(&dnd).expect("dnd"); + assert_eq!(dnd.owners, head_dnd.owners); + assert_eq!( + dnd.decentralized_namespace, + head_dnd.decentralized_namespace + ); + assert_eq!(dnd.threshold, 3); + + let head_p2p = p2p_of(&build_bootstrap_p2p( + "cbtc", + NS, + &[participant(1), participant(2), participant(3)], + &[key(1), key(2), key(3)], + 2, + )) + .expect("p2p") + .clone(); + let p2p = build_change_threshold_p2p(&head_p2p, 3); + let p2p = p2p_of(&p2p).expect("p2p"); + assert_eq!(p2p.participants, head_p2p.participants); + assert_eq!(p2p.threshold, 3); + let keys = p2p.party_signing_keys.as_ref().expect("keys"); + assert_eq!(keys.threshold, 3); + assert_eq!( + keys.keys, + head_p2p.party_signing_keys.as_ref().expect("keys").keys + ); + } + + #[test] + fn wait_budget_default_uses_the_topology_retry_knobs() { + let b = WaitBudget::default(); + assert!(b.max_attempts >= 1); + assert_eq!( + b.delay, + Duration::from_secs(consts::topology_retry_delay_secs()) + ); + } + + #[test] + fn transaction_not_found_is_detected_through_the_error_chain() { + let status = tonic::Status::not_found( + "TOPOLOGY_TRANSACTION_NOT_FOUND(11,0): Unable to find topology transaction", + ); + let err: anyhow::Error = status.into(); + assert!(is_transaction_not_found(&err.context("co-sign"))); + let other: anyhow::Error = tonic::Status::internal("boom").into(); + assert!(!is_transaction_not_found(&other)); + } +} diff --git a/crates/decman/src/onledger/validation.rs b/crates/decman/src/onledger/validation.rs new file mode 100644 index 000000000..ae198323a --- /dev/null +++ b/crates/decman/src/onledger/validation.rs @@ -0,0 +1,1513 @@ +//! What a member checks before it co-signs a topology proposal (design +//! section 5). +//! +//! The operator accepts a `WorkflowProposal` once. After that the node signs +//! only proposals that match that record, its counted acceptances, the head +//! state, and this node's own identity. Every check here is a pure function +//! over those inputs, so each rule has a unit test and no ledger is needed. +//! +//! Hosts compare as full `(participant_uid, permission, onboarding)` tuples. +//! Keys compare as full serialized `SigningPublicKey` byte sets. A mismatch +//! is an error the caller shows in the UI; the node never signs it. + +use std::collections::{BTreeMap, BTreeSet}; + +use canton_proto_rs::com::digitalasset::canton::{ + crypto::v30::SigningPublicKey, + protocol::v30::{ + DecentralizedNamespaceDefinition, PartyToParticipant, enums::ParticipantPermission, + }, +}; +use common::{canton_id::CantonId, types::WorkflowKind}; +use prost::Message; + +use crate::utils; + +use super::{ + daml::codec::WorkflowProposalRecord, + proposals::Acceptance, + topology::{PendingProposal, compute_namespace}, +}; + +/// Why a proposal must not be signed. The text goes to the run row and the +/// UI unchanged. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidationError(pub String); + +impl std::fmt::Display for ValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for ValidationError {} + +pub type Check = Result<(), ValidationError>; + +macro_rules! refuse { + ($($arg:tt)*) => { + return Err(ValidationError(format!($($arg)*))) + }; +} + +// --------------------------------------------------------------------------- +// Inputs +// --------------------------------------------------------------------------- + +/// A host row as a comparable tuple. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct HostTuple { + pub uid: String, + pub permission: i32, + pub onboarding: bool, +} + +impl HostTuple { + pub fn confirmation(uid: &str, onboarding: bool) -> Self { + Self { + uid: uid.to_string(), + permission: ParticipantPermission::Confirmation as i32, + onboarding, + } + } +} + +/// The host set of a mapping, as tuples. +pub fn host_tuples(mapping: &PartyToParticipant) -> BTreeSet { + mapping + .participants + .iter() + .map(|h| HostTuple { + uid: h.participant_uid.clone(), + permission: h.permission, + onboarding: h.onboarding.is_some(), + }) + .collect() +} + +/// The key set of a mapping, as serialized bytes. +pub fn key_bytes(mapping: &PartyToParticipant) -> BTreeSet> { + mapping + .party_signing_keys + .as_ref() + .map(|k| k.keys.iter().map(Message::encode_to_vec).collect()) + .unwrap_or_default() +} + +/// The key set of a mapping, as fingerprints. +pub fn key_fingerprints(mapping: &PartyToParticipant) -> BTreeSet { + mapping + .party_signing_keys + .as_ref() + .map(|k| k.keys.iter().map(utils::compute_fingerprint).collect()) + .unwrap_or_default() +} + +fn signing_threshold(mapping: &PartyToParticipant) -> Option { + mapping.party_signing_keys.as_ref().map(|k| k.threshold) +} + +/// This node, as the proposal must name it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LocalIdentity { + pub participant_id: CantonId, + /// Namespace fingerprints this node owns for the party: the dual-usage + /// key of a new party, or the legacy `{prefix}-namespace` key. + pub owner_fingerprints: BTreeSet, + /// This node's Daml signing-key fingerprint for the party, when known. + pub daml_key_fingerprint: Option, +} + +/// The member a kick removes, from this node's own cache +/// (`dec_party_participant.owner_key` / `signing_key`), never from the +/// proposer. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct KickedMember { + pub participant_id: String, + pub owner_fingerprint: String, + pub signing_key_fingerprint: Option, +} + +/// The fields of a counted acceptance the checks use. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CountedAcceptance { + pub acceptor: CantonId, + pub participant_id: String, + pub namespace_fingerprint: Option, + pub signing_public_key_hex: Option, + pub daml_key_fingerprint: Option, +} + +impl From<&Acceptance> for CountedAcceptance { + fn from(a: &Acceptance) -> Self { + Self { + acceptor: a.record.acceptor.clone(), + participant_id: a.record.participant_id.clone(), + namespace_fingerprint: a.record.namespace_fingerprint.clone(), + signing_public_key_hex: a.record.signing_public_key_hex.clone(), + daml_key_fingerprint: a.record.daml_key_fingerprint.clone(), + } + } +} + +/// The accepted head mappings of the party, when it exists. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct HeadState { + pub dnd: Option, + pub p2p: Option, +} + +/// The reference set a member validates against. +#[derive(Clone, Debug, PartialEq)] +pub struct Expectations { + pub kind: WorkflowKind, + pub proposer_participant: String, + /// The proposer's owner fingerprint. `None` fails every check closed. + pub proposer_fingerprint: Option, + /// Whether `proposer_participant` hosts the proposer node party with + /// Submission (design D2), read by the caller. + pub proposer_hosting_ok: bool, + pub participants: Vec, + pub prefix: Option, + pub dec_party_id: Option, + pub threshold: Option, + pub previous_threshold: Option, + pub dnd_base_serial: Option, + pub p2p_base_serial: Option, + pub new_participant: Option, + pub kicked_participant: Option, + pub accepted: Vec, + pub head: HeadState, + /// Root-NSD `target_key` per owner fingerprint, read from the + /// synchronizer store (design D4). + pub owner_keys: BTreeMap, + pub identity: LocalIdentity, + pub kicked: Option, + /// Daml key fingerprint claimed by each surviving member + /// (`participant_uid -> fingerprint`), for the kick legacy branch. + pub survivor_key_claims: BTreeMap, +} + +fn to_u32(v: Option) -> Option { + v.and_then(|n| u32::try_from(n).ok()) +} + +impl Expectations { + /// Build the reference set from the accepted proposal, its counted + /// acceptances, the head state, and this node's identity. Optional + /// inputs (root keys, kicked member, survivor claims, hosting check) + /// are added with the `with_*` methods. + pub fn new( + proposal: &WorkflowProposalRecord, + counted: &[Acceptance], + head: HeadState, + identity: LocalIdentity, + ) -> Self { + Self { + kind: proposal.kind, + proposer_participant: proposal.proposer_participant.clone(), + proposer_fingerprint: proposal.proposer_namespace_fingerprint.clone(), + proposer_hosting_ok: false, + participants: proposal.participants.clone(), + prefix: proposal.prefix.clone(), + dec_party_id: proposal.dec_party_id.clone(), + threshold: to_u32(proposal.threshold), + previous_threshold: to_u32(proposal.previous_threshold), + dnd_base_serial: to_u32(proposal.dnd_base_serial), + p2p_base_serial: to_u32(proposal.p2p_base_serial), + new_participant: proposal.new_participant.clone(), + kicked_participant: proposal.kicked_participant.clone(), + accepted: counted.iter().map(CountedAcceptance::from).collect(), + head, + owner_keys: BTreeMap::new(), + identity, + kicked: None, + survivor_key_claims: BTreeMap::new(), + } + } + + pub fn with_owner_keys(mut self, keys: BTreeMap) -> Self { + self.owner_keys = keys; + self + } + + pub fn with_kicked(mut self, kicked: KickedMember) -> Self { + self.kicked = Some(kicked); + self + } + + pub fn with_survivor_key_claims(mut self, claims: BTreeMap) -> Self { + self.survivor_key_claims = claims; + self + } + + pub fn with_proposer_hosting(mut self, ok: bool) -> Self { + self.proposer_hosting_ok = ok; + self + } + + /// The threshold the proposal advertised. `None` fails closed. + pub fn required_threshold(&self) -> Result { + self.threshold + .ok_or_else(|| ValidationError("the accepted proposal carries no threshold".into())) + } + + /// The proposer's owner fingerprint. `None` fails closed. + pub fn required_proposer_fingerprint(&self) -> Result<&str, ValidationError> { + self.proposer_fingerprint.as_deref().ok_or_else(|| { + ValidationError( + "the accepted proposal carries no proposer namespace fingerprint".into(), + ) + }) + } + + /// Onboarding owners: the proposer plus every counted acceptor. + pub fn onboarding_owners(&self) -> Result, ValidationError> { + let mut owners = BTreeSet::new(); + owners.insert(self.required_proposer_fingerprint()?.to_string()); + for a in &self.accepted { + let Some(fp) = &a.namespace_fingerprint else { + refuse!( + "acceptance from {} on {} carries no namespace fingerprint", + a.acceptor, + a.participant_id + ); + }; + if !owners.insert(fp.clone()) { + refuse!("owner fingerprint {fp} appears twice among the acceptances"); + } + } + Ok(owners) + } + + /// Onboarding hosts: the proposer's participant plus every counted + /// acceptor's participant, all at Confirmation without the marker. + pub fn onboarding_hosts(&self) -> BTreeSet { + let mut hosts = BTreeSet::new(); + hosts.insert(HostTuple::confirmation(&self.proposer_participant, false)); + for a in &self.accepted { + hosts.insert(HostTuple::confirmation(&a.participant_id, false)); + } + hosts + } + + /// The counted acceptance of the add-party joiner. + pub fn joiner(&self) -> Result<&CountedAcceptance, ValidationError> { + let Some(new_participant) = &self.new_participant else { + refuse!("the accepted add-party proposal names no new participant"); + }; + self.accepted + .iter() + .find(|a| &a.participant_id == new_participant) + .ok_or_else(|| { + ValidationError(format!( + "no counted acceptance from the joiner {new_participant} yet" + )) + }) + } + + fn head_dnd(&self) -> Result<&DecentralizedNamespaceDefinition, ValidationError> { + self.head + .dnd + .as_ref() + .ok_or_else(|| ValidationError("no accepted DND in the head state".into())) + } + + fn head_p2p(&self) -> Result<&PartyToParticipant, ValidationError> { + self.head + .p2p + .as_ref() + .ok_or_else(|| ValidationError("no accepted P2P in the head state".into())) + } + + fn own_participant(&self) -> String { + self.identity.participant_id.to_string() + } +} + +// --------------------------------------------------------------------------- +// Common checks +// --------------------------------------------------------------------------- + +/// The serial guard inputs: the accepted serial read this tick and the base +/// serial the `WorkflowProposal` recorded. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SerialContext { + pub accepted: Option, + pub base: Option, +} + +/// Design section 5, "Common": `ADD_REPLACE`; the proposer's owner +/// fingerprint has signed; this node has not; `serial == accepted + 1` and +/// `accepted == base` (or `1` with no accepted mapping). +pub fn check_common( + pending: &PendingProposal, + exp: &Expectations, + serials: &SerialContext, +) -> Check { + if !pending.is_add_replace() { + refuse!( + "proposal {} is not an ADD_REPLACE (operation {}); a REMOVE is never co-signed", + pending.hash_hex, + pending.operation + ); + } + let proposer = exp.required_proposer_fingerprint()?; + if !pending.is_signed_by(proposer) { + refuse!( + "proposal {} is not signed by the proposer's owner key {proposer}", + pending.hash_hex + ); + } + if let Some(own) = exp + .identity + .owner_fingerprints + .iter() + .find(|fp| pending.is_signed_by(fp)) + { + refuse!( + "proposal {} already carries this node's signature {own}", + pending.hash_hex + ); + } + match serials.accepted { + None => { + if serials.base.is_some_and(|b| b != 0) { + refuse!( + "no accepted mapping exists but the proposal recorded base serial {:?}", + serials.base + ); + } + if pending.serial != 1 { + refuse!( + "first mapping must be proposed at serial 1, found {}", + pending.serial + ); + } + } + Some(accepted) => { + if serials.base != Some(accepted) { + refuse!( + "topology moved: accepted serial is {accepted} but the proposal recorded \ + base serial {:?}", + serials.base + ); + } + if pending.serial != accepted + 1 { + refuse!( + "proposal serial {} is not accepted serial {accepted} + 1", + pending.serial + ); + } + } + } + Ok(()) +} + +/// DND rule: this node's owner fingerprint is among the owners. +pub fn check_own_owner(dnd: &DecentralizedNamespaceDefinition, exp: &Expectations) -> Check { + if exp.identity.owner_fingerprints.is_empty() { + refuse!("this node has no owner fingerprint for the party"); + } + if !exp + .identity + .owner_fingerprints + .iter() + .any(|fp| dnd.owners.contains(fp)) + { + refuse!( + "DND owners {:?} do not include this node's fingerprint(s) {:?}", + dnd.owners, + exp.identity.owner_fingerprints + ); + } + Ok(()) +} + +/// P2P rule: this node's participant is a host at Confirmation. The +/// Onboarding marker is allowed only when `as_joiner` is set. +pub fn check_own_host(p2p: &PartyToParticipant, exp: &Expectations, as_joiner: bool) -> Check { + let own = exp.own_participant(); + let Some(host) = p2p.participants.iter().find(|h| h.participant_uid == own) else { + refuse!("P2P does not host this node ({own})"); + }; + if host.permission != ParticipantPermission::Confirmation as i32 { + refuse!( + "P2P hosts this node ({own}) with permission {}, not Confirmation", + host.permission + ); + } + if host.onboarding.is_some() != as_joiner { + refuse!( + "P2P marks this node ({own}) as onboarding: {}, expected {as_joiner}", + host.onboarding.is_some() + ); + } + Ok(()) +} + +fn check_dnd_threshold(dnd: &DecentralizedNamespaceDefinition, exp: &Expectations) -> Check { + let expected = exp.required_threshold()?; + let owners = u32::try_from(dnd.owners.len()).unwrap_or(u32::MAX); + if dnd.threshold < 1 || u32::try_from(dnd.threshold).unwrap_or(0) > owners { + refuse!("DND threshold {} is outside 1..={owners}", dnd.threshold); + } + if u32::try_from(dnd.threshold).ok() != Some(expected) { + refuse!( + "DND threshold {} differs from the accepted {expected}", + dnd.threshold + ); + } + Ok(()) +} + +fn check_p2p_thresholds(p2p: &PartyToParticipant, exp: &Expectations) -> Check { + let expected = exp.required_threshold()?; + let hosts = u32::try_from(p2p.participants.len()).unwrap_or(u32::MAX); + if p2p.threshold < 1 || p2p.threshold > hosts { + refuse!( + "P2P hosting threshold {} is outside 1..={hosts}", + p2p.threshold + ); + } + if p2p.threshold != expected { + refuse!( + "P2P hosting threshold {} differs from the accepted {expected}", + p2p.threshold + ); + } + let Some(signing) = signing_threshold(p2p) else { + refuse!("P2P carries no party signing keys, which would leave the party unable to sign"); + }; + if signing != expected { + refuse!("P2P signing threshold {signing} differs from the accepted {expected}"); + } + let keys = u32::try_from(key_bytes(p2p).len()).unwrap_or(u32::MAX); + if signing < 1 || signing > keys { + refuse!("P2P signing threshold {signing} is outside 1..={keys}"); + } + Ok(()) +} + +fn owner_key_bytes( + exp: &Expectations, + owners: &BTreeSet, +) -> Result>, ValidationError> { + let mut out = BTreeSet::new(); + for fp in owners { + let Some(key) = exp.owner_keys.get(fp) else { + refuse!("no root NamespaceDelegation key for owner {fp}"); + }; + out.insert(key.encode_to_vec()); + } + Ok(out) +} + +// --------------------------------------------------------------------------- +// Onboarding +// --------------------------------------------------------------------------- + +/// Onboarding DND: owners are exactly the proposer plus the counted +/// acceptors, one per participant; threshold as accepted; namespace derived +/// from the owners; this node among the owners. +pub fn check_onboarding_dnd( + pending: &PendingProposal, + exp: &Expectations, +) -> Check { + let dnd = &pending.mapping; + let expected = exp.onboarding_owners()?; + let proposed: BTreeSet = dnd.owners.iter().cloned().collect(); + if proposed.len() != dnd.owners.len() { + refuse!("DND repeats an owner"); + } + if proposed != expected { + refuse!( + "DND owners {:?} differ from the accepted set {:?}", + proposed, + expected + ); + } + if proposed.len() != exp.participants.len() { + refuse!( + "DND has {} owners for {} participants", + proposed.len(), + exp.participants.len() + ); + } + check_dnd_threshold(dnd, exp)?; + let namespace = compute_namespace(proposed.iter()); + if dnd.decentralized_namespace != namespace { + refuse!( + "DND namespace {} is not derived from its owners ({namespace})", + dnd.decentralized_namespace + ); + } + check_own_owner(dnd, exp) +} + +/// Onboarding P2P: party is `prefix::namespace`; hosts are exactly the +/// proposer's and acceptors' participants at Confirmation without marker; +/// the proposer is hosted where it claims; keys are exactly the root-NSD +/// keys of the owners; both thresholds as accepted; this node hosts. +pub fn check_onboarding_p2p( + pending: &PendingProposal, + exp: &Expectations, +) -> Check { + let p2p = &pending.mapping; + let owners = exp.onboarding_owners()?; + let Some(prefix) = &exp.prefix else { + refuse!("the accepted onboarding proposal carries no prefix"); + }; + let party = format!("{prefix}::{}", compute_namespace(owners.iter())); + if p2p.party != party { + refuse!("P2P party {} is not the accepted {party}", p2p.party); + } + let hosts = host_tuples(p2p); + if hosts.len() != p2p.participants.len() { + refuse!("P2P lists a participant twice"); + } + let expected_hosts = exp.onboarding_hosts(); + if hosts != expected_hosts { + refuse!( + "P2P hosts {:?} differ from the accepted set {:?}", + hosts, + expected_hosts + ); + } + if !exp.proposer_hosting_ok { + refuse!( + "the proposer node party is not hosted on {} with Submission", + exp.proposer_participant + ); + } + let expected_keys = owner_key_bytes(exp, &owners)?; + if key_bytes(p2p) != expected_keys { + refuse!("P2P party signing keys differ from the owners' root delegation keys"); + } + check_p2p_thresholds(p2p, exp)?; + check_own_host(p2p, exp, false) +} + +// --------------------------------------------------------------------------- +// Add party +// --------------------------------------------------------------------------- + +/// Add-party DND: head owners plus the joiner; threshold as accepted; this +/// node among the owners. +pub fn check_add_party_dnd( + pending: &PendingProposal, + exp: &Expectations, +) -> Check { + let dnd = &pending.mapping; + let head = exp.head_dnd()?; + let joiner = exp.joiner()?; + let Some(joiner_fp) = &joiner.namespace_fingerprint else { + refuse!("the joiner's acceptance carries no namespace fingerprint"); + }; + let mut expected: BTreeSet = head.owners.iter().cloned().collect(); + if !expected.insert(joiner_fp.clone()) { + refuse!("joiner fingerprint {joiner_fp} is already an owner"); + } + let proposed: BTreeSet = dnd.owners.iter().cloned().collect(); + if proposed.len() != dnd.owners.len() { + refuse!("DND repeats an owner"); + } + if proposed != expected { + refuse!( + "DND owners {:?} are not the head owners plus the joiner {:?}", + proposed, + expected + ); + } + if dnd.decentralized_namespace != head.decentralized_namespace { + refuse!( + "DND namespace {} differs from the head {}", + dnd.decentralized_namespace, + head.decentralized_namespace + ); + } + check_dnd_threshold(dnd, exp)?; + check_own_owner(dnd, exp) +} + +/// Add-party P2P: head hosts unchanged plus the joiner at Confirmation with +/// the Onboarding marker; head keys plus the joiner's root-NSD key; both +/// thresholds as accepted; this node hosts (or is the joiner). +pub fn check_add_party_p2p( + pending: &PendingProposal, + exp: &Expectations, +) -> Check { + let p2p = &pending.mapping; + let head = exp.head_p2p()?; + let joiner = exp.joiner()?; + if p2p.party != head.party { + refuse!( + "P2P party {} differs from the head {}", + p2p.party, + head.party + ); + } + let hosts = host_tuples(p2p); + if hosts.len() != p2p.participants.len() { + refuse!("P2P lists a participant twice"); + } + let mut expected = host_tuples(head); + if expected.iter().any(|h| h.uid == joiner.participant_id) { + refuse!("joiner {} already hosts the party", joiner.participant_id); + } + expected.insert(HostTuple::confirmation(&joiner.participant_id, true)); + if hosts != expected { + refuse!( + "P2P hosts {:?} are not the head hosts plus the onboarding joiner {:?}", + hosts, + expected + ); + } + let Some(joiner_fp) = &joiner.namespace_fingerprint else { + refuse!("the joiner's acceptance carries no namespace fingerprint"); + }; + let Some(joiner_key) = exp.owner_keys.get(joiner_fp) else { + refuse!("no root NamespaceDelegation key for the joiner {joiner_fp}"); + }; + let mut expected_keys = key_bytes(head); + expected_keys.insert(joiner_key.encode_to_vec()); + if key_bytes(p2p) != expected_keys { + refuse!("P2P party signing keys are not the head keys plus the joiner's root key"); + } + check_p2p_thresholds(p2p, exp)?; + let as_joiner = exp.own_participant() == joiner.participant_id; + check_own_host(p2p, exp, as_joiner) +} + +// --------------------------------------------------------------------------- +// Kick +// --------------------------------------------------------------------------- + +/// Kick DND: head owners minus the kicked fingerprint from this node's own +/// cache; exactly one fewer owner; threshold as accepted; this node among +/// the owners. +pub fn check_kick_dnd( + pending: &PendingProposal, + exp: &Expectations, +) -> Check { + let dnd = &pending.mapping; + let head = exp.head_dnd()?; + let Some(kicked) = &exp.kicked else { + refuse!("this node has no cached owner key for the kicked participant"); + }; + if !head.owners.contains(&kicked.owner_fingerprint) { + refuse!( + "cached owner key {} of the kicked participant is not a head owner", + kicked.owner_fingerprint + ); + } + let expected: BTreeSet = head + .owners + .iter() + .filter(|o| **o != kicked.owner_fingerprint) + .cloned() + .collect(); + let proposed: BTreeSet = dnd.owners.iter().cloned().collect(); + if proposed.len() != dnd.owners.len() { + refuse!("DND repeats an owner"); + } + if proposed != expected { + refuse!( + "DND owners {:?} are not the head owners minus the kicked {} ({:?})", + proposed, + kicked.owner_fingerprint, + expected + ); + } + if proposed.len() + 1 != head.owners.len() { + refuse!( + "DND has {} owners; the head has {}", + proposed.len(), + head.owners.len() + ); + } + if dnd.decentralized_namespace != head.decentralized_namespace { + refuse!( + "DND namespace {} differs from the head {}", + dnd.decentralized_namespace, + head.decentralized_namespace + ); + } + check_dnd_threshold(dnd, exp)?; + check_own_owner(dnd, exp) +} + +/// Kick P2P: head hosts minus the kicked participant, survivors unchanged; +/// exactly one key removed, which is not this node's and not claimed by a +/// survivor; both thresholds as accepted; this node hosts. +/// +/// Dual-key parties (party signing key fingerprints equal the DND owners) +/// pin the removed key to the kicked owner fingerprint. Legacy parties use +/// the elimination rule alone. +pub fn check_kick_p2p(pending: &PendingProposal, exp: &Expectations) -> Check { + let p2p = &pending.mapping; + let head = exp.head_p2p()?; + let Some(kicked) = &exp.kicked else { + refuse!("this node has no cached owner key for the kicked participant"); + }; + if p2p.party != head.party { + refuse!( + "P2P party {} differs from the head {}", + p2p.party, + head.party + ); + } + let hosts = host_tuples(p2p); + if hosts.len() != p2p.participants.len() { + refuse!("P2P lists a participant twice"); + } + let head_hosts = host_tuples(head); + if !head_hosts.iter().any(|h| h.uid == kicked.participant_id) { + refuse!( + "kicked participant {} does not host the party", + kicked.participant_id + ); + } + let expected: BTreeSet = head_hosts + .into_iter() + .filter(|h| h.uid != kicked.participant_id) + .collect(); + if hosts != expected { + refuse!( + "P2P hosts {:?} are not the head hosts minus the kicked {} ({:?})", + hosts, + kicked.participant_id, + expected + ); + } + + let head_keys = key_fingerprints(head); + let proposed_keys = key_fingerprints(p2p); + if !proposed_keys.is_subset(&head_keys) { + refuse!("P2P adds a party signing key during a kick"); + } + let removed: Vec<&String> = head_keys.difference(&proposed_keys).collect(); + let [removed] = removed.as_slice() else { + refuse!( + "P2P removes {} party signing key(s); a kick removes exactly one", + removed.len() + ); + }; + if exp.identity.daml_key_fingerprint.as_deref() == Some(removed.as_str()) { + refuse!("P2P removes this node's own Daml signing key {removed}"); + } + if let Some((survivor, _)) = exp + .survivor_key_claims + .iter() + .find(|(uid, fp)| **uid != kicked.participant_id && *fp == *removed) + { + refuse!("P2P removes key {removed}, which surviving member {survivor} claims"); + } + let head_owners: BTreeSet = exp + .head + .dnd + .as_ref() + .map(|d| d.owners.iter().cloned().collect()) + .unwrap_or_default(); + let dual_key_party = !head_owners.is_empty() && head_keys == head_owners; + if dual_key_party && **removed != kicked.owner_fingerprint { + refuse!( + "P2P removes key {removed} but the kicked member's key is {}", + kicked.owner_fingerprint + ); + } + if let Some(claimed) = &kicked.signing_key_fingerprint + && head_keys.contains(claimed) + && *removed != claimed + { + refuse!("P2P removes key {removed} but the kicked member's recorded key is {claimed}"); + } + check_p2p_thresholds(p2p, exp)?; + check_own_host(p2p, exp, false) +} + +// --------------------------------------------------------------------------- +// Change threshold +// --------------------------------------------------------------------------- + +/// Change-threshold DND: equal to the head except the threshold. +pub fn check_change_threshold_dnd( + pending: &PendingProposal, + exp: &Expectations, +) -> Check { + let dnd = &pending.mapping; + let head = exp.head_dnd()?; + let proposed: BTreeSet<&String> = dnd.owners.iter().collect(); + let head_owners: BTreeSet<&String> = head.owners.iter().collect(); + if proposed != head_owners || proposed.len() != dnd.owners.len() { + refuse!( + "DND owners {:?} differ from the head owners {:?}", + dnd.owners, + head.owners + ); + } + if dnd.decentralized_namespace != head.decentralized_namespace { + refuse!( + "DND namespace {} differs from the head {}", + dnd.decentralized_namespace, + head.decentralized_namespace + ); + } + check_dnd_threshold(dnd, exp)?; + check_own_owner(dnd, exp) +} + +/// Change-threshold P2P: equal to the head except both thresholds. +pub fn check_change_threshold_p2p( + pending: &PendingProposal, + exp: &Expectations, +) -> Check { + let p2p = &pending.mapping; + let head = exp.head_p2p()?; + if p2p.party != head.party { + refuse!( + "P2P party {} differs from the head {}", + p2p.party, + head.party + ); + } + let hosts = host_tuples(p2p); + if hosts.len() != p2p.participants.len() { + refuse!("P2P lists a participant twice"); + } + if hosts != host_tuples(head) { + refuse!("P2P hosts {:?} differ from the head hosts", hosts); + } + if key_bytes(p2p) != key_bytes(head) { + refuse!("P2P party signing keys differ from the head keys"); + } + check_p2p_thresholds(p2p, exp)?; + check_own_host(p2p, exp, false) +} + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +/// Common checks plus the kind's DND rule, using the DND base serial. +pub fn validate_dnd( + pending: &PendingProposal, + exp: &Expectations, + accepted_serial: Option, +) -> Check { + check_common( + pending, + exp, + &SerialContext { + accepted: accepted_serial, + base: exp.dnd_base_serial, + }, + )?; + match exp.kind { + WorkflowKind::Onboarding => check_onboarding_dnd(pending, exp), + WorkflowKind::AddParty => check_add_party_dnd(pending, exp), + WorkflowKind::Kick => check_kick_dnd(pending, exp), + WorkflowKind::ChangeThreshold => check_change_threshold_dnd(pending, exp), + other => Err(ValidationError(format!( + "{other} proposals carry no topology mapping" + ))), + } +} + +/// Common checks plus the kind's P2P rule, using the P2P base serial. +pub fn validate_p2p( + pending: &PendingProposal, + exp: &Expectations, + accepted_serial: Option, +) -> Check { + check_common( + pending, + exp, + &SerialContext { + accepted: accepted_serial, + base: exp.p2p_base_serial, + }, + )?; + match exp.kind { + WorkflowKind::Onboarding => check_onboarding_p2p(pending, exp), + WorkflowKind::AddParty => check_add_party_p2p(pending, exp), + WorkflowKind::Kick => check_kick_p2p(pending, exp), + WorkflowKind::ChangeThreshold => check_change_threshold_p2p(pending, exp), + other => Err(ValidationError(format!( + "{other} proposals carry no topology mapping" + ))), + } +} + +#[cfg(test)] +mod tests { + use canton_proto_rs::com::digitalasset::canton::protocol::v30::{ + enums::{ParticipantPermission, TopologyChangeOp}, + party_to_participant::hosting_participant, + }; + + use super::*; + use crate::onledger::{ + daml::{ + ActiveContract, + codec::{WorkflowAcceptanceRecord, tests::proposal_full}, + }, + topology::{ + build_add_party_dnd, build_add_party_p2p, build_bootstrap_p2p, + build_change_threshold_dnd, build_change_threshold_p2p, build_dnd, build_kick_dnd, + build_kick_p2p, dnd_of, p2p_of, + tests::{NS, fp, key, participant}, + }, + }; + + fn pending(mapping: M, serial: u32, signed_by: &[String]) -> PendingProposal { + PendingProposal { + hash_hex: "1220ff".into(), + serial, + signed_by: signed_by.to_vec(), + operation: TopologyChangeOp::AddReplace as i32, + mapping, + sequenced: None, + valid_from: None, + } + } + + fn acceptance(participant_n: u8, seed: u8) -> Acceptance { + ActiveContract { + contract_id: format!("00acc{participant_n}"), + offset: 1, + record: WorkflowAcceptanceRecord { + proposal: "00proposal".into(), + proposer: CantonId::parse(&format!("node-a::{NS}")).expect("id"), + acceptor: CantonId::parse(&format!("node-{participant_n}::{NS}")).expect("id"), + observers: vec![], + run_id: "cbtc-creation".into(), + participant_id: participant(participant_n).to_string(), + namespace_fingerprint: Some(fp(seed)), + signing_public_key_hex: Some(hex::encode(key(seed).encode_to_vec())), + daml_key_fingerprint: Some(fp(seed)), + member_party: None, + accepted_at: 1, + }, + } + } + + fn identity(participant_n: u8, seed: u8) -> LocalIdentity { + LocalIdentity { + participant_id: participant(participant_n), + owner_fingerprints: [fp(seed)].into_iter().collect(), + daml_key_fingerprint: Some(fp(seed)), + } + } + + fn owner_keys(seeds: &[u8]) -> BTreeMap { + seeds.iter().map(|s| (fp(*s), key(*s))).collect() + } + + /// Proposer = participant1 / key 1; acceptors = participant2 / key 2 and + /// participant3 / key 3; this node = participant2. + fn onboarding_expectations() -> Expectations { + let mut record = proposal_full(); + record.kind = WorkflowKind::Onboarding; + record.proposer_namespace_fingerprint = Some(fp(1)); + record.threshold = Some(2); + record.dnd_base_serial = None; + record.p2p_base_serial = None; + record.new_participant = None; + record.kicked_participant = None; + Expectations::new( + &record, + &[acceptance(2, 2), acceptance(3, 3)], + HeadState::default(), + identity(2, 2), + ) + .with_owner_keys(owner_keys(&[1, 2, 3])) + .with_proposer_hosting(true) + } + + fn onboarding_dnd() -> PendingProposal { + let m = build_dnd(&[fp(1), fp(2), fp(3)], 2); + pending(dnd_of(&m).expect("dnd").clone(), 1, &[fp(1)]) + } + + fn onboarding_p2p() -> PendingProposal { + let namespace = compute_namespace([fp(1), fp(2), fp(3)].iter()); + let m = build_bootstrap_p2p( + "cbtc", + &namespace, + &[participant(1), participant(2), participant(3)], + &[key(1), key(2), key(3)], + 2, + ); + pending(p2p_of(&m).expect("p2p").clone(), 1, &[fp(1)]) + } + + fn head() -> HeadState { + let dnd = dnd_of(&build_dnd(&[fp(1), fp(2), fp(3)], 2)) + .expect("dnd") + .clone(); + let p2p = p2p_of(&build_bootstrap_p2p( + "cbtc", + &dnd.decentralized_namespace, + &[participant(1), participant(2), participant(3)], + &[key(1), key(2), key(3)], + 2, + )) + .expect("p2p") + .clone(); + HeadState { + dnd: Some(dnd), + p2p: Some(p2p), + } + } + + fn party_expectations(kind: WorkflowKind, threshold: i64) -> Expectations { + let mut record = proposal_full(); + record.kind = kind; + record.proposer_namespace_fingerprint = Some(fp(1)); + record.threshold = Some(threshold); + record.previous_threshold = Some(2); + record.dnd_base_serial = Some(1); + record.p2p_base_serial = Some(1); + record.new_participant = Some(participant(4).to_string()); + record.kicked_participant = Some(participant(3).to_string()); + Expectations::new(&record, &[acceptance(4, 4)], head(), identity(2, 2)) + .with_owner_keys(owner_keys(&[4])) + .with_kicked(KickedMember { + participant_id: participant(3).to_string(), + owner_fingerprint: fp(3), + signing_key_fingerprint: Some(fp(3)), + }) + .with_survivor_key_claims( + [ + (participant(1).to_string(), fp(1)), + (participant(2).to_string(), fp(2)), + ] + .into_iter() + .collect(), + ) + } + + fn head_dnd() -> DecentralizedNamespaceDefinition { + head().dnd.expect("dnd") + } + + fn head_p2p() -> PartyToParticipant { + head().p2p.expect("p2p") + } + + // ----- common ----- + + #[test] + fn common_accepts_a_first_serial_with_no_base() { + let exp = onboarding_expectations(); + let ctx = SerialContext { + accepted: None, + base: None, + }; + check_common(&onboarding_dnd(), &exp, &ctx).expect("ok"); + } + + #[test] + fn common_refuses_a_remove_operation() { + let exp = onboarding_expectations(); + let mut p = onboarding_dnd(); + p.operation = TopologyChangeOp::Remove as i32; + let err = check_common(&p, &exp, &SerialContext::default()).expect_err("remove"); + assert!(err.0.contains("REMOVE"), "{err}"); + } + + #[test] + fn common_refuses_when_the_proposer_has_not_signed() { + let exp = onboarding_expectations(); + let mut p = onboarding_dnd(); + p.signed_by = vec![fp(9)]; + let err = check_common(&p, &exp, &SerialContext::default()).expect_err("no proposer"); + assert!(err.0.contains("not signed by the proposer"), "{err}"); + } + + #[test] + fn common_fails_closed_without_a_proposer_fingerprint() { + let mut exp = onboarding_expectations(); + exp.proposer_fingerprint = None; + let err = + check_common(&onboarding_dnd(), &exp, &SerialContext::default()).expect_err("missing"); + assert!(err.0.contains("no proposer namespace fingerprint"), "{err}"); + } + + #[test] + fn common_refuses_when_own_fingerprint_already_signed() { + let exp = onboarding_expectations(); + let mut p = onboarding_dnd(); + p.signed_by = vec![fp(1), fp(2)]; + let err = check_common(&p, &exp, &SerialContext::default()).expect_err("already"); + assert!( + err.0.contains("already carries this node's signature"), + "{err}" + ); + } + + #[test] + fn common_refuses_a_serial_mismatch() { + let exp = party_expectations(WorkflowKind::ChangeThreshold, 3); + let m = build_change_threshold_dnd(&head_dnd(), 3); + let p = pending(dnd_of(&m).expect("dnd").clone(), 3, &[fp(1)]); + let ctx = SerialContext { + accepted: Some(1), + base: Some(1), + }; + let err = check_common(&p, &exp, &ctx).expect_err("serial"); + assert!(err.0.contains("is not accepted serial 1 + 1"), "{err}"); + } + + #[test] + fn common_refuses_a_base_serial_mismatch() { + let exp = party_expectations(WorkflowKind::ChangeThreshold, 3); + let m = build_change_threshold_dnd(&head_dnd(), 3); + let p = pending(dnd_of(&m).expect("dnd").clone(), 3, &[fp(1)]); + let ctx = SerialContext { + accepted: Some(2), + base: Some(1), + }; + let err = check_common(&p, &exp, &ctx).expect_err("base"); + assert!(err.0.contains("topology moved"), "{err}"); + } + + #[test] + fn common_refuses_a_first_serial_other_than_one() { + let exp = onboarding_expectations(); + let mut p = onboarding_dnd(); + p.serial = 2; + let err = check_common(&p, &exp, &SerialContext::default()).expect_err("serial 2"); + assert!(err.0.contains("serial 1"), "{err}"); + } + + // ----- onboarding ----- + + #[test] + fn onboarding_dnd_and_p2p_pass() { + let exp = onboarding_expectations(); + validate_dnd(&onboarding_dnd(), &exp, None).expect("dnd ok"); + validate_p2p(&onboarding_p2p(), &exp, None).expect("p2p ok"); + } + + #[test] + fn onboarding_dnd_refuses_a_foreign_owner() { + let exp = onboarding_expectations(); + let m = build_dnd(&[fp(1), fp(2), fp(9)], 2); + let p = pending(dnd_of(&m).expect("dnd").clone(), 1, &[fp(1)]); + let err = check_onboarding_dnd(&p, &exp).expect_err("foreign"); + assert!(err.0.contains("differ from the accepted set"), "{err}"); + } + + #[test] + fn onboarding_dnd_refuses_a_wrong_threshold() { + let exp = onboarding_expectations(); + let m = build_dnd(&[fp(1), fp(2), fp(3)], 3); + let p = pending(dnd_of(&m).expect("dnd").clone(), 1, &[fp(1)]); + let err = check_onboarding_dnd(&p, &exp).expect_err("threshold"); + assert!(err.0.contains("differs from the accepted 2"), "{err}"); + } + + #[test] + fn onboarding_dnd_refuses_a_namespace_not_derived_from_owners() { + let exp = onboarding_expectations(); + let mut p = onboarding_dnd(); + p.mapping.decentralized_namespace = NS.into(); + let err = check_onboarding_dnd(&p, &exp).expect_err("namespace"); + assert!(err.0.contains("not derived from its owners"), "{err}"); + } + + #[test] + fn onboarding_p2p_refuses_a_host_permission_flip() { + let exp = onboarding_expectations(); + let mut p = onboarding_p2p(); + p.mapping.participants[0].permission = ParticipantPermission::Submission as i32; + let err = check_onboarding_p2p(&p, &exp).expect_err("permission"); + assert!(err.0.contains("hosts"), "{err}"); + } + + #[test] + fn onboarding_p2p_refuses_an_onboarding_marker() { + let exp = onboarding_expectations(); + let mut p = onboarding_p2p(); + p.mapping.participants[1].onboarding = Some(hosting_participant::Onboarding {}); + let err = check_onboarding_p2p(&p, &exp).expect_err("marker"); + assert!(err.0.contains("hosts"), "{err}"); + } + + #[test] + fn onboarding_p2p_refuses_a_key_not_from_the_root_delegations() { + let exp = onboarding_expectations(); + let mut p = onboarding_p2p(); + p.mapping.party_signing_keys.as_mut().expect("keys").keys[0] = key(9); + let err = check_onboarding_p2p(&p, &exp).expect_err("keys"); + assert!(err.0.contains("root delegation keys"), "{err}"); + } + + #[test] + fn onboarding_p2p_refuses_a_signing_threshold_drift() { + let exp = onboarding_expectations(); + let mut p = onboarding_p2p(); + p.mapping + .party_signing_keys + .as_mut() + .expect("keys") + .threshold = 1; + let err = check_onboarding_p2p(&p, &exp).expect_err("signing threshold"); + assert!(err.0.contains("signing threshold 1"), "{err}"); + } + + #[test] + fn onboarding_p2p_refuses_an_unverified_proposer_host() { + let exp = onboarding_expectations().with_proposer_hosting(false); + let err = check_onboarding_p2p(&onboarding_p2p(), &exp).expect_err("hosting"); + assert!(err.0.contains("Submission"), "{err}"); + } + + #[test] + fn onboarding_p2p_refuses_a_missing_own_host() { + let mut exp = onboarding_expectations(); + exp.identity.participant_id = participant(9); + let err = check_onboarding_p2p(&onboarding_p2p(), &exp).expect_err("own host"); + assert!(err.0.contains("does not host this node"), "{err}"); + } + + // ----- add party ----- + + #[test] + fn add_party_dnd_and_p2p_pass_for_a_member_and_for_the_joiner() { + let exp = party_expectations(WorkflowKind::AddParty, 3); + let dnd = build_add_party_dnd(&head_dnd(), &fp(4), 3); + let dnd = pending(dnd_of(&dnd).expect("dnd").clone(), 2, &[fp(1)]); + validate_dnd(&dnd, &exp, Some(1)).expect("dnd ok"); + let p2p = build_add_party_p2p(&head_p2p(), &participant(4), &key(4), 3); + let p2p = pending(p2p_of(&p2p).expect("p2p").clone(), 2, &[fp(1)]); + validate_p2p(&p2p, &exp, Some(1)).expect("p2p ok"); + + let mut joiner_exp = exp.clone(); + joiner_exp.identity = identity(4, 4); + check_add_party_p2p(&p2p, &joiner_exp).expect("joiner ok"); + } + + #[test] + fn add_party_p2p_refuses_a_marker_on_a_survivor() { + let exp = party_expectations(WorkflowKind::AddParty, 3); + let p2p = build_add_party_p2p(&head_p2p(), &participant(4), &key(4), 3); + let mut p = pending(p2p_of(&p2p).expect("p2p").clone(), 2, &[fp(1)]); + p.mapping.participants[0].onboarding = Some(hosting_participant::Onboarding {}); + let err = check_add_party_p2p(&p, &exp).expect_err("marker"); + assert!( + err.0.contains("head hosts plus the onboarding joiner"), + "{err}" + ); + } + + #[test] + fn add_party_dnd_refuses_a_foreign_owner() { + let exp = party_expectations(WorkflowKind::AddParty, 3); + let dnd = build_add_party_dnd(&head_dnd(), &fp(9), 3); + let p = pending(dnd_of(&dnd).expect("dnd").clone(), 2, &[fp(1)]); + let err = check_add_party_dnd(&p, &exp).expect_err("foreign"); + assert!(err.0.contains("head owners plus the joiner"), "{err}"); + } + + #[test] + fn add_party_p2p_refuses_a_permission_flip_on_a_survivor() { + let exp = party_expectations(WorkflowKind::AddParty, 3); + let p2p = build_add_party_p2p(&head_p2p(), &participant(4), &key(4), 3); + let mut p = pending(p2p_of(&p2p).expect("p2p").clone(), 2, &[fp(1)]); + p.mapping.participants[1].permission = ParticipantPermission::Submission as i32; + let err = check_add_party_p2p(&p, &exp).expect_err("permission"); + assert!( + err.0.contains("head hosts plus the onboarding joiner"), + "{err}" + ); + } + + // ----- kick ----- + + #[test] + fn kick_dnd_and_p2p_pass() { + let exp = party_expectations(WorkflowKind::Kick, 2); + let dnd = build_kick_dnd(&head_dnd(), &fp(3), 2); + let dnd = pending(dnd_of(&dnd).expect("dnd").clone(), 2, &[fp(1)]); + validate_dnd(&dnd, &exp, Some(1)).expect("dnd ok"); + let p2p = build_kick_p2p(&head_p2p(), &participant(3), &fp(3), 2); + let p2p = pending(p2p_of(&p2p).expect("p2p").clone(), 2, &[fp(1)]); + validate_p2p(&p2p, &exp, Some(1)).expect("p2p ok"); + } + + #[test] + fn kick_dnd_uses_the_local_cache_not_the_proposer() { + let mut exp = party_expectations(WorkflowKind::Kick, 2); + exp.kicked = None; + let dnd = build_kick_dnd(&head_dnd(), &fp(3), 2); + let p = pending(dnd_of(&dnd).expect("dnd").clone(), 2, &[fp(1)]); + let err = check_kick_dnd(&p, &exp).expect_err("no cache"); + assert!(err.0.contains("no cached owner key"), "{err}"); + } + + #[test] + fn kick_dnd_refuses_removing_the_wrong_owner() { + let exp = party_expectations(WorkflowKind::Kick, 2); + let dnd = build_kick_dnd(&head_dnd(), &fp(2), 2); + let p = pending(dnd_of(&dnd).expect("dnd").clone(), 2, &[fp(1)]); + let err = check_kick_dnd(&p, &exp).expect_err("wrong owner"); + assert!(err.0.contains("head owners minus the kicked"), "{err}"); + } + + #[test] + fn kick_p2p_refuses_removing_own_key() { + let exp = party_expectations(WorkflowKind::Kick, 2); + // Host removal is right, key removal targets this node (key 2). + let p2p = build_kick_p2p(&head_p2p(), &participant(3), &fp(2), 2); + let p = pending(p2p_of(&p2p).expect("p2p").clone(), 2, &[fp(1)]); + let err = check_kick_p2p(&p, &exp).expect_err("own key"); + assert!(err.0.contains("this node's own Daml signing key"), "{err}"); + } + + #[test] + fn kick_p2p_refuses_removing_a_survivors_claimed_key() { + let mut exp = party_expectations(WorkflowKind::Kick, 2); + exp.identity.daml_key_fingerprint = None; + // Legacy party: signing keys are not the DND owners. + exp.head.dnd = None; + exp.kicked = Some(KickedMember { + participant_id: participant(3).to_string(), + owner_fingerprint: fp(30), + signing_key_fingerprint: None, + }); + let p2p = build_kick_p2p(&head_p2p(), &participant(3), &fp(1), 2); + let p = pending(p2p_of(&p2p).expect("p2p").clone(), 2, &[fp(1)]); + let err = check_kick_p2p(&p, &exp).expect_err("claimed"); + assert!(err.0.contains("surviving member"), "{err}"); + } + + #[test] + fn kick_p2p_legacy_branch_accepts_the_one_unclaimed_key() { + let mut exp = party_expectations(WorkflowKind::Kick, 2); + exp.head.dnd = None; + exp.kicked = Some(KickedMember { + participant_id: participant(3).to_string(), + owner_fingerprint: fp(30), + signing_key_fingerprint: None, + }); + let p2p = build_kick_p2p(&head_p2p(), &participant(3), &fp(3), 2); + let p = pending(p2p_of(&p2p).expect("p2p").clone(), 2, &[fp(1)]); + check_kick_p2p(&p, &exp).expect("legacy ok"); + } + + #[test] + fn kick_p2p_refuses_removing_two_keys_or_none() { + let exp = party_expectations(WorkflowKind::Kick, 2); + let p2p = build_kick_p2p(&head_p2p(), &participant(3), &fp(3), 2); + let mut p = pending(p2p_of(&p2p).expect("p2p").clone(), 2, &[fp(1)]); + p.mapping + .party_signing_keys + .as_mut() + .expect("keys") + .keys + .pop(); + let err = check_kick_p2p(&p, &exp).expect_err("two"); + assert!(err.0.contains("removes exactly one"), "{err}"); + + let mut none = pending(p2p_of(&p2p).expect("p2p").clone(), 2, &[fp(1)]); + none.mapping = PartyToParticipant { + participants: none.mapping.participants.clone(), + party_signing_keys: head_p2p().party_signing_keys.clone().map(|mut k| { + k.threshold = 2; + k + }), + ..none.mapping + }; + let err = check_kick_p2p(&none, &exp).expect_err("none"); + assert!(err.0.contains("removes 0"), "{err}"); + } + + #[test] + fn kick_p2p_refuses_a_permission_flip_on_a_survivor() { + let exp = party_expectations(WorkflowKind::Kick, 2); + let p2p = build_kick_p2p(&head_p2p(), &participant(3), &fp(3), 2); + let mut p = pending(p2p_of(&p2p).expect("p2p").clone(), 2, &[fp(1)]); + p.mapping.participants[0].permission = ParticipantPermission::Observation as i32; + let err = check_kick_p2p(&p, &exp).expect_err("permission"); + assert!(err.0.contains("head hosts minus the kicked"), "{err}"); + } + + // ----- change threshold ----- + + #[test] + fn change_threshold_dnd_and_p2p_pass() { + let exp = party_expectations(WorkflowKind::ChangeThreshold, 3); + let dnd = build_change_threshold_dnd(&head_dnd(), 3); + let dnd = pending(dnd_of(&dnd).expect("dnd").clone(), 2, &[fp(1)]); + validate_dnd(&dnd, &exp, Some(1)).expect("dnd ok"); + let p2p = build_change_threshold_p2p(&head_p2p(), 3); + let p2p = pending(p2p_of(&p2p).expect("p2p").clone(), 2, &[fp(1)]); + validate_p2p(&p2p, &exp, Some(1)).expect("p2p ok"); + } + + #[test] + fn change_threshold_refuses_a_wrong_threshold() { + let exp = party_expectations(WorkflowKind::ChangeThreshold, 3); + let dnd = build_change_threshold_dnd(&head_dnd(), 1); + let p = pending(dnd_of(&dnd).expect("dnd").clone(), 2, &[fp(1)]); + let err = check_change_threshold_dnd(&p, &exp).expect_err("threshold"); + assert!(err.0.contains("differs from the accepted 3"), "{err}"); + } + + #[test] + fn change_threshold_refuses_a_membership_change() { + let exp = party_expectations(WorkflowKind::ChangeThreshold, 3); + let dnd = build_add_party_dnd(&head_dnd(), &fp(9), 3); + let p = pending(dnd_of(&dnd).expect("dnd").clone(), 2, &[fp(1)]); + let err = check_change_threshold_dnd(&p, &exp).expect_err("owners"); + assert!(err.0.contains("differ from the head owners"), "{err}"); + + let p2p = build_change_threshold_p2p(&head_p2p(), 3); + let mut p = pending(p2p_of(&p2p).expect("p2p").clone(), 2, &[fp(1)]); + p.mapping.participants[2].onboarding = Some(hosting_participant::Onboarding {}); + let err = check_change_threshold_p2p(&p, &exp).expect_err("hosts"); + assert!(err.0.contains("differ from the head hosts"), "{err}"); + } + + #[test] + fn change_threshold_refuses_a_signing_threshold_left_behind() { + let exp = party_expectations(WorkflowKind::ChangeThreshold, 3); + let p2p = build_change_threshold_p2p(&head_p2p(), 3); + let mut p = pending(p2p_of(&p2p).expect("p2p").clone(), 2, &[fp(1)]); + p.mapping + .party_signing_keys + .as_mut() + .expect("keys") + .threshold = 2; + let err = check_change_threshold_p2p(&p, &exp).expect_err("signing"); + assert!(err.0.contains("signing threshold 2"), "{err}"); + } + + #[test] + fn contracts_and_dars_carry_no_topology_mapping() { + let mut exp = onboarding_expectations(); + exp.kind = WorkflowKind::Dars; + let err = validate_dnd(&onboarding_dnd(), &exp, None).expect_err("no mapping"); + assert!(err.0.contains("no topology mapping"), "{err}"); + } + + #[test] + fn tuples_and_key_sets_compare_the_full_shape() { + let p2p = head_p2p(); + let tuples = host_tuples(&p2p); + assert_eq!(tuples.len(), 3); + assert!( + tuples.iter().all( + |t| !t.onboarding && t.permission == ParticipantPermission::Confirmation as i32 + ) + ); + assert_eq!(key_bytes(&p2p).len(), 3); + assert_eq!( + key_fingerprints(&p2p), + [fp(1), fp(2), fp(3)].into_iter().collect() + ); + } +} diff --git a/crates/decman/src/server/handlers/auth.rs b/crates/decman/src/server/handlers/auth.rs index 4d82e188f..b268e5a33 100644 --- a/crates/decman/src/server/handlers/auth.rs +++ b/crates/decman/src/server/handlers/auth.rs @@ -9,7 +9,7 @@ use keycloak::login::{ClientCredentialsParams, client_credentials, token_url}; use crate::{ auth::WorkflowAuth, canton_id::CantonId, - config::{Auth0M2MConfig, NodeConfig, PartyCredentials}, + config::{Auth0M2MConfig, CredentialKind, NodeConfig, PartyCredentials}, error::Result, server::{ AppState, @@ -124,7 +124,10 @@ pub async fn get_auth_status(data: web::Data) -> impl Responder { // real dec party — the hardcoded placeholder above never matched it, // so auth-gated actions (member-party discovery, contract deploy) // stayed blocked even after `/party-config` succeeded. - for creds in party_creds_list.iter() { + for creds in party_creds_list + .iter() + .filter(|c| c.kind == CredentialKind::Decparty) + { party_statuses.push(PartyAuthStatus { dec_party_id: creds.dec_party_id.clone(), member_party_id: creds.member_party_id.clone(), @@ -150,8 +153,12 @@ pub async fn get_auth_status(data: web::Data) -> impl Responder { let party_creds_list = data.party_credentials.read().await; - // Check each configured party - for party_creds in party_creds_list.iter() { + // Check each configured decparty. The node identity row is a node-level + // credential, not a party mapping; `GET /node-identity` reports it. + for party_creds in party_creds_list + .iter() + .filter(|c| c.kind == CredentialKind::Decparty) + { let dec_party_id = party_creds.dec_party_id.clone(); let member_party_id = party_creds.member_party_id.clone(); let user_id = party_creds.user_id.clone(); @@ -802,6 +809,7 @@ mod tests { discovery_completed: Arc::new(RwLock::new(HashMap::new())), http_client: reqwest::Client::new(), health_cache: crate::server::HealthCache::new(), + onledger: crate::onledger::OnLedger::placeholder(), }) } @@ -863,6 +871,7 @@ mod tests { /// empty shape because nothing fills it in. fn auth0_party() -> PartyCredentials { PartyCredentials { + kind: crate::config::CredentialKind::Decparty, dec_party_id: CantonId::parse(VALID_CANTON_ID).expect("valid dec party id"), member_party_id: CantonId::parse(VALID_CANTON_ID).expect("valid member party id"), user_id: "attestor-1".to_string(), diff --git a/crates/decman/src/server/handlers/governance.rs b/crates/decman/src/server/handlers/governance.rs index 3d2a9a4f0..34a91e2f1 100644 --- a/crates/decman/src/server/handlers/governance.rs +++ b/crates/decman/src/server/handlers/governance.rs @@ -2580,6 +2580,7 @@ mod get_party_credentials_tests { let id = party_id()?; let credentials = PartyCredentials { + kind: crate::config::CredentialKind::Decparty, dec_party_id: id.clone(), member_party_id: id.clone(), user_id: "user".to_string(), diff --git a/crates/decman/src/server/handlers/mod.rs b/crates/decman/src/server/handlers/mod.rs index 4e817d2f7..3636db3db 100644 --- a/crates/decman/src/server/handlers/mod.rs +++ b/crates/decman/src/server/handlers/mod.rs @@ -3,6 +3,7 @@ mod config; mod governance; mod invitations; mod keys; +mod node_identity; mod parties; mod party_config; mod tenant; @@ -29,6 +30,7 @@ pub(crate) use governance::{ pub(crate) use governance::{get_party_credentials, packages}; pub(crate) use invitations::{accept_invitation, decline_invitation, get_invitations}; pub(crate) use keys::get_key_status; +pub(crate) use node_identity::{get_node_identity, get_registry, save_node_identity}; pub(crate) use parties::{ Discovery, DiscoveryGate, MAX_CONCURRENT_DISCOVERIES, clear_acs_import_quarantine, compare_peer_packages, discover_and_cache, get_decentralized_parties, get_participants_status, diff --git a/crates/decman/src/server/handlers/node_identity.rs b/crates/decman/src/server/handlers/node_identity.rs new file mode 100644 index 000000000..23038bc86 --- /dev/null +++ b/crates/decman/src/server/handlers/node_identity.rs @@ -0,0 +1,424 @@ +//! `GET`/`PUT /node-identity` (design D1) and `GET /registry` (design D3). +//! +//! The node identity is a `party_credentials` row with `kind = 'node'`. The +//! PUT is admin-gated, and exempt from authentication only while the table is +//! entirely empty (the same predicate and mutex as `PUT /party-config`, in +//! the auth middleware). Before persisting, the handler reads the head +//! `PartyToParticipant` of the node party and refuses unless this participant +//! hosts it with Submission permission: a Confirmation-only host cannot +//! submit Daml commands, so it cannot serve as a node party. + +use actix_web::{HttpRequest, HttpResponse, Responder, get, put, web}; +use common::coordination::{NodeIdentityRequest, NodeIdentityResponse, RegistryResponse}; + +use crate::{ + config::{ + Auth0M2MConfig, CredentialKind, KeycloakConfig, PartyCredentials, default_package_config, + }, + db::schema::{Commitable, SchemaWrite}, + onledger::{identity::node_credentials, verify_hosting}, + server::{ + AppState, handlers::party_config::reload_auth, middleware::require_admin, + types::ErrorResponse, + }, +}; + +/// Read the node identity and how this participant hosts it. +#[utoipa::path( + tag = "Node identity", + responses( + (status = 200, description = "Node identity (or `configured: false`)", body = NodeIdentityResponse), + (status = 500, description = "Participant id not resolved", body = ErrorResponse) + ) +)] +#[get("/node-identity")] +pub async fn get_node_identity(http_req: HttpRequest, data: web::Data) -> impl Responder { + if let Err(resp) = require_admin(&http_req, data.admin_role.as_deref()) { + return resp; + } + let Some(participant_id) = data.config.node.participant_id.clone() else { + return HttpResponse::InternalServerError().json(ErrorResponse { + error: "participant id not resolved yet".to_string(), + }); + }; + let node_row = { + let rows = data.party_credentials.read().await; + node_credentials(&rows).cloned() + }; + let Some(row) = node_row else { + return HttpResponse::Ok().json(NodeIdentityResponse { + configured: false, + node_party_id: None, + participant_id, + user_id: None, + hosting_permission: None, + }); + }; + let hosting_permission = + match verify_hosting(&data.config, &row.member_party_id, &participant_id).await { + Ok(check) => check.permission, + Err(e) => { + tracing::warn!(error = %e, "hosting check for the node party failed"); + None + } + }; + HttpResponse::Ok().json(NodeIdentityResponse { + configured: true, + node_party_id: Some(row.member_party_id.clone()), + participant_id, + user_id: Some(row.user_id.clone()), + hosting_permission, + }) +} + +/// Set or replace the node identity. +#[utoipa::path( + tag = "Node identity", + request_body = NodeIdentityRequest, + responses( + (status = 200, description = "Node identity saved", body = NodeIdentityResponse), + (status = 400, description = "Bad request, or the party is not hosted here with Submission", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse) + ) +)] +#[put("/node-identity")] +pub async fn save_node_identity( + http_req: HttpRequest, + data: web::Data, + body: web::Json, +) -> impl Responder { + let req = body.into_inner(); + + // Bootstrap exemption: the middleware lets the first PUT on a fresh node + // through unauthenticated. Once any credential row exists, admin only. + let is_fresh = data.party_credentials.read().await.is_empty(); + if !is_fresh && let Err(resp) = require_admin(&http_req, data.admin_role.as_deref()) { + return resp; + } + + let Some(participant_id) = data.config.node.participant_id.clone() else { + return HttpResponse::InternalServerError().json(ErrorResponse { + error: "participant id not resolved yet".to_string(), + }); + }; + + // The one trust check (design D1): this participant hosts the node party + // with Submission permission in the synchronizer head state. + match verify_hosting(&data.config, &req.node_party_id, &participant_id).await { + Ok(check) if check.has_submission() => {} + Ok(check) => { + return HttpResponse::BadRequest().json(ErrorResponse { + error: format!( + "participant {participant_id} does not host {} with Submission permission \ + ({}); allocate the node party on this participant with Submission \ + permission first", + req.node_party_id, + check.describe() + ), + }); + } + Err(e) => { + return HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("failed to read the node party's hosting from topology: {e}"), + }); + } + } + + let existing = { + let rows = data.party_credentials.read().await; + node_credentials(&rows).cloned() + }; + let creds = match build_credentials(&req, existing.as_ref(), data.test_mode) { + Ok(c) => c, + Err(message) => { + return HttpResponse::BadRequest().json(ErrorResponse { error: message }); + } + }; + + // Persist: one node row at a time. A node party change deletes the old row. + { + let mut tx = match data.db.begin_transaction().await { + Ok(tx) => tx, + Err(e) => { + return HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Failed to begin transaction: {e}"), + }); + } + }; + if let Some(old) = existing + .as_ref() + .filter(|old| old.dec_party_id != creds.dec_party_id) + && let Err(e) = tx.delete_party_credentials(&old.dec_party_id).await + { + return HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Failed to remove the previous node identity: {e}"), + }); + } + if let Err(e) = tx.upsert_party_credentials(&creds).await { + return HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Failed to save node identity: {e}"), + }); + } + if let Err(e) = Commitable::commit(tx).await { + return HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Failed to commit transaction: {e}"), + }); + } + } + + { + let mut pc = data.party_credentials.write().await; + pc.retain(|p| p.kind != CredentialKind::Node); + pc.push(creds.clone()); + } + + if !data.test_mode + && let Err(e) = reload_auth(&data.party_credentials, &data.auth).await + { + tracing::warn!("Failed to reinitialize auth registry: {e}"); + } + if let Err(e) = data.onledger.reload_identity().await { + tracing::warn!(error = %e, "node identity saved but failed to load"); + } + + HttpResponse::Ok().json(NodeIdentityResponse { + configured: true, + node_party_id: Some(creds.member_party_id.clone()), + participant_id, + user_id: Some(creds.user_id.clone()), + hosting_permission: Some(common::types::Permission::Submission), + }) +} + +/// The registry as this node sees it: own entry, configured peers, and +/// inbound entries from operators that added this node first. +#[utoipa::path( + tag = "Node identity", + responses( + (status = 200, description = "Registry view", body = RegistryResponse), + (status = 409, description = "No node identity configured", body = ErrorResponse), + (status = 500, description = "Ledger or topology read failed", body = ErrorResponse) + ) +)] +#[get("/registry")] +pub async fn get_registry(http_req: HttpRequest, data: web::Data) -> impl Responder { + if let Err(resp) = require_admin(&http_req, data.admin_role.as_deref()) { + return resp; + } + if data.onledger.identity().await.is_none() { + return HttpResponse::Conflict().json(ErrorResponse { + error: "node identity not configured; set one with PUT /node-identity first" + .to_string(), + }); + } + match data.onledger.read_registry().await { + Ok(view) => HttpResponse::Ok().json(view), + Err(e) => { + tracing::error!(error = %e, "GET /registry failed"); + HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("failed to read the registry: {e}"), + }) + } + } +} + +/// Turn the request into the `kind = 'node'` row, with the same secret-merge +/// and IdP-change rules `PUT /party-config` applies: an omitted secret keeps +/// the stored one, an empty one clears it, and a changed IdP host never +/// inherits a stored secret. +fn build_credentials( + req: &NodeIdentityRequest, + existing: Option<&PartyCredentials>, + test_mode: bool, +) -> Result { + let same_party = existing.filter(|e| e.member_party_id == req.node_party_id); + let existing_keycloak = same_party.map(|e| e.keycloak.clone()); + let existing_auth0 = same_party.and_then(|e| e.auth0.clone()); + let present = |s: &Option| s.as_deref().is_some_and(|v| !v.is_empty()); + + let base = |keycloak: KeycloakConfig, auth0: Option| PartyCredentials { + kind: CredentialKind::Node, + dec_party_id: req.node_party_id.clone(), + member_party_id: req.node_party_id.clone(), + user_id: req.user_id.clone(), + keycloak, + auth0, + packages: default_package_config(), + }; + + if let Some(domain) = req.auth0_domain.as_deref().filter(|s| !s.is_empty()) { + let Some(audience) = req.auth0_audience.as_deref().filter(|s| !s.is_empty()) else { + return Err("auth0_audience is required".to_string()); + }; + let Some(client_id) = req.auth0_client_id.as_deref().filter(|s| !s.is_empty()) else { + return Err("auth0_client_id is required".to_string()); + }; + let domain_changed = existing_auth0.as_ref().is_some_and(|e| e.domain != domain); + if domain_changed && !present(&req.auth0_client_secret) { + return Err( + "auth0_domain changed; resubmit with a fresh auth0_client_secret \ + in the same request" + .to_string(), + ); + } + let client_secret = match req.auth0_client_secret.as_deref().filter(|s| !s.is_empty()) { + Some(s) => s.to_string(), + None => existing_auth0 + .as_ref() + .map(|a| a.client_secret.clone()) + .unwrap_or_default(), + }; + if client_secret.is_empty() { + return Err("auth0_client_secret is required for first-time Auth0 setup".to_string()); + } + return Ok(base( + KeycloakConfig::default(), + Some(Auth0M2MConfig { + domain: domain.to_string(), + audience: audience.to_string(), + client_id: client_id.to_string(), + client_secret, + }), + )); + } + + if !test_mode + && (req.keycloak_url.trim().is_empty() + || req.keycloak_realm.trim().is_empty() + || req.keycloak_client_id.trim().is_empty()) + { + return Err( + "keycloak_url, keycloak_realm, and keycloak_client_id are required \ + (or supply auth0_domain to use the Auth0 path)" + .to_string(), + ); + } + + let url_changed = existing_keycloak + .as_ref() + .is_some_and(|e| e.url != req.keycloak_url); + let fresh_password_pair = present(&req.keycloak_username) && present(&req.keycloak_password); + if url_changed && !present(&req.keycloak_client_secret) && !fresh_password_pair { + return Err( + "keycloak_url changed; resubmit with a fresh keycloak_client_secret \ + (or keycloak_username + keycloak_password) in the same request" + .to_string(), + ); + } + + let carry = |new: &Option, old: Option| -> Option { + if url_changed { + return new.clone().filter(|s| !s.is_empty()); + } + match new { + None => old, + Some(v) if v.is_empty() => None, + Some(v) => Some(v.clone()), + } + }; + let keycloak = KeycloakConfig { + url: req.keycloak_url.clone(), + internal_url: None, + realm: req.keycloak_realm.clone(), + client_id: req.keycloak_client_id.clone(), + client_secret: carry( + &req.keycloak_client_secret, + existing_keycloak + .as_ref() + .and_then(|k| k.client_secret.clone()), + ), + username: carry( + &req.keycloak_username, + existing_keycloak.as_ref().and_then(|k| k.username.clone()), + ), + password: carry( + &req.keycloak_password, + existing_keycloak.as_ref().and_then(|k| k.password.clone()), + ), + }; + Ok(base(keycloak, None)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::canton_id::CantonId; + + const NS: &str = "1220c4010d6883f367c7f45d55b2449501620130f9b21e96379f17dea455ac7a5892"; + + fn node_party() -> CantonId { + CantonId::parse(&format!("node::{NS}")).expect("valid id") + } + + fn keycloak_request(secret: Option<&str>) -> NodeIdentityRequest { + NodeIdentityRequest { + node_party_id: node_party(), + user_id: "node-user".into(), + keycloak_url: "https://kc.example.com".into(), + keycloak_realm: "decman".into(), + keycloak_client_id: "node-client".into(), + keycloak_client_secret: secret.map(str::to_string), + keycloak_username: None, + keycloak_password: None, + auth0_domain: None, + auth0_audience: None, + auth0_client_id: None, + auth0_client_secret: None, + } + } + + #[test] + fn a_node_row_stores_the_node_party_in_both_columns() { + let creds = build_credentials(&keycloak_request(Some("s3cret")), None, false).expect("ok"); + assert_eq!(creds.kind, CredentialKind::Node); + assert_eq!(creds.dec_party_id, node_party()); + assert_eq!(creds.member_party_id, node_party()); + assert_eq!(creds.keycloak.client_secret.as_deref(), Some("s3cret")); + } + + #[test] + fn an_omitted_secret_keeps_the_stored_one_for_the_same_idp() { + let existing = + build_credentials(&keycloak_request(Some("s3cret")), None, false).expect("ok"); + let updated = + build_credentials(&keycloak_request(None), Some(&existing), false).expect("ok"); + assert_eq!(updated.keycloak.client_secret.as_deref(), Some("s3cret")); + let cleared = + build_credentials(&keycloak_request(Some("")), Some(&existing), false).expect("ok"); + assert_eq!(cleared.keycloak.client_secret, None); + } + + #[test] + fn a_changed_keycloak_url_never_inherits_the_secret() { + let existing = + build_credentials(&keycloak_request(Some("s3cret")), None, false).expect("ok"); + let mut moved = keycloak_request(None); + moved.keycloak_url = "https://attacker.example.com".into(); + let err = build_credentials(&moved, Some(&existing), false).expect_err("must refuse"); + assert!(err.contains("keycloak_url changed"), "{err}"); + } + + #[test] + fn a_missing_keycloak_realm_is_rejected_outside_test_mode() { + let mut req = keycloak_request(Some("s")); + req.keycloak_realm = String::new(); + assert!(build_credentials(&req, None, false).is_err()); + assert!(build_credentials(&req, None, true).is_ok()); + } + + #[test] + fn auth0_requires_a_secret_on_first_setup() { + let mut req = keycloak_request(None); + req.auth0_domain = Some("tenant.eu.auth0.com".into()); + req.auth0_audience = Some("https://canton.network.global".into()); + req.auth0_client_id = Some("m2m".into()); + let err = build_credentials(&req, None, false).expect_err("no secret"); + assert!(err.contains("auth0_client_secret"), "{err}"); + req.auth0_client_secret = Some("top".into()); + let creds = build_credentials(&req, None, false).expect("ok"); + assert_eq!( + creds.auth0.map(|a| a.client_secret), + Some("top".to_string()) + ); + } +} diff --git a/crates/decman/src/server/handlers/parties.rs b/crates/decman/src/server/handlers/parties.rs index ae313a0b2..574da98a8 100644 --- a/crates/decman/src/server/handlers/parties.rs +++ b/crates/decman/src/server/handlers/parties.rs @@ -2710,6 +2710,7 @@ mod tests { let namespace_c = "1220e6010d6883f367c7f45d55b2449501620130f9b21e96379f17dea455ac7a5894"; let credentials = |party: &str, namespace: &str| -> anyhow::Result { Ok(PartyCredentials { + kind: crate::config::CredentialKind::Decparty, dec_party_id: CantonId::parse(&format!("{party}::{namespace}"))?, member_party_id: CantonId::parse(&format!("member::{namespace}"))?, user_id: "user".to_string(), diff --git a/crates/decman/src/server/handlers/party_config.rs b/crates/decman/src/server/handlers/party_config.rs index e635fa86c..cf243cd3a 100644 --- a/crates/decman/src/server/handlers/party_config.rs +++ b/crates/decman/src/server/handlers/party_config.rs @@ -10,7 +10,9 @@ use tokio::sync::RwLock; use crate::{ auth::{AuthRegistry, WorkflowAuth, auth0_client_credentials}, canton_id::CantonId, - config::{Auth0M2MConfig, KeycloakConfig, PartyCredentials, default_package_config}, + config::{ + Auth0M2MConfig, CredentialKind, KeycloakConfig, PartyCredentials, default_package_config, + }, db::schema::{Commitable, SchemaWrite}, error::Result, server::{ @@ -203,6 +205,7 @@ pub async fn save_party_config( } let creds = PartyCredentials { + kind: CredentialKind::Decparty, dec_party_id: req.dec_party_id.clone(), member_party_id: req.member_party_id.clone(), user_id: req.user_id.clone(), @@ -304,6 +307,7 @@ pub async fn save_party_config( }; let creds = PartyCredentials { + kind: CredentialKind::Decparty, dec_party_id: req.dec_party_id.clone(), member_party_id: req.member_party_id, user_id: req.user_id, @@ -583,7 +587,9 @@ mod tests { use crate::{ auth::{MockAuthRegistry, MockValidator, TokenValidator, WorkflowAuth}, canton_id::CantonId, - config::{KeycloakConfig, NodeConfig, PartyCredentials, default_package_config}, + config::{ + CredentialKind, KeycloakConfig, NodeConfig, PartyCredentials, default_package_config, + }, server::{AppState, middleware::AuthMiddleware}, }; @@ -628,6 +634,7 @@ mod tests { discovery_completed: Arc::new(RwLock::new(HashMap::new())), http_client: reqwest::Client::new(), health_cache: crate::server::HealthCache::new(), + onledger: crate::onledger::OnLedger::placeholder(), }); let app = test::init_service(App::new().app_data(state).service(discover_member_party)).await; @@ -661,6 +668,7 @@ mod tests { let existing_member = CantonId::parse(&format!("member-party::{ns}")).expect("parse member party"); let existing = PartyCredentials { + kind: CredentialKind::Decparty, dec_party_id: existing_dec.clone(), member_party_id: existing_member.clone(), user_id: "test-user".to_string(), @@ -706,6 +714,7 @@ mod tests { discovery_completed: Arc::new(RwLock::new(HashMap::new())), http_client: reqwest::Client::new(), health_cache: crate::server::HealthCache::new(), + onledger: crate::onledger::OnLedger::placeholder(), }); let app = test::init_service( App::new() @@ -782,6 +791,7 @@ mod tests { discovery_completed: Arc::new(RwLock::new(HashMap::new())), http_client: reqwest::Client::new(), health_cache: crate::server::HealthCache::new(), + onledger: crate::onledger::OnLedger::placeholder(), }); let app = test::init_service(App::new().app_data(state).service(get_party_config)).await; diff --git a/crates/decman/src/server/middleware/auth.rs b/crates/decman/src/server/middleware/auth.rs index 911b3e369..3a281ee79 100644 --- a/crates/decman/src/server/middleware/auth.rs +++ b/crates/decman/src/server/middleware/auth.rs @@ -93,8 +93,12 @@ where // request: only one bootstrap-shaped call is in flight at a time; // any concurrent attempt is rejected with 409. After the holder // writes credentials, subsequent calls fall through to normal auth. + // + // `PUT /node-identity` (design D1) shares the exemption, the + // predicate, and the mutex: the node identity is the first row a + // fresh node writes, before any decparty exists. if method == "PUT" - && path == "/party-config" + && is_bootstrap_path(&path) && app_state.party_credentials.read().await.is_empty() { let Ok(guard) = app_state.bootstrap_mu.clone().try_lock_owned() else { @@ -106,7 +110,7 @@ where // finished writing; if so, drop the lock and require auth. if app_state.party_credentials.read().await.is_empty() { tracing::info!( - "PUT /party-config bootstrap: unauthenticated call allowed because \ + "PUT {path} bootstrap: unauthenticated call allowed because \ party_credentials is empty. Subsequent writes will require admin role." ); let res = service.call(req).await?; @@ -205,6 +209,11 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 } +/// The two writes a fresh node may make before it holds any credential row. +fn is_bootstrap_path(path: &str) -> bool { + matches!(path, "/party-config" | "/node-identity") +} + /// Extract the bearer token from the request. fn bearer_token(req: &ServiceRequest) -> Option { let header = req.headers().get(AUTHORIZATION)?.to_str().ok()?; @@ -401,6 +410,7 @@ mod tests { discovery_completed: Arc::new(RwLock::new(HashMap::new())), http_client: reqwest::Client::new(), health_cache: crate::server::HealthCache::new(), + onledger: crate::onledger::OnLedger::placeholder(), }) } diff --git a/crates/decman/src/server/mod.rs b/crates/decman/src/server/mod.rs index 9d348a3b7..4d8e2f1f1 100644 --- a/crates/decman/src/server/mod.rs +++ b/crates/decman/src/server/mod.rs @@ -10,15 +10,15 @@ mod assets; mod audit; mod chain_audit; -mod event_filters; +pub(crate) mod event_filters; mod handlers; -mod ledger_paging; +pub(crate) mod ledger_paging; mod middleware; mod node_health; -mod package_inventory; +pub(crate) mod package_inventory; mod queries; mod record; -mod reward_automation; +pub(crate) mod reward_automation; mod transfer_context; mod types; @@ -182,6 +182,10 @@ pub struct AppState { /// the warm gRPC channels the probes reuse. Shared so a Config tab open in /// many browsers costs one probe per TTL, not one per browser. pub health_cache: HealthCache, + /// Canton-native coordination: node identity, registry snapshot, and the + /// client that submits as the node party. Shares `auth` and + /// `party_credentials` with this state. + pub onledger: Arc, } #[cfg(test)] @@ -219,6 +223,7 @@ impl AppState { discovery_completed: Arc::new(RwLock::new(HashMap::new())), http_client: reqwest::Client::new(), health_cache: HealthCache::new(), + onledger: crate::onledger::OnLedger::placeholder(), })) } } @@ -1064,6 +1069,17 @@ pub async fn start_server( let relay_sessions = Arc::new(crate::workflow::party_replication::relay::RelaySessions::new()); + // On-ledger coordination facade. Loads the node identity once; a node + // without one starts normally and waits for `PUT /node-identity`. + let onledger = crate::onledger::OnLedger::new( + config.clone(), + db.clone(), + auth.clone(), + party_credentials.clone(), + insecure, + ) + .await; + let app_state = web::Data::new(AppState { db: db.clone(), config: config.clone(), @@ -1089,6 +1105,7 @@ pub async fn start_server( discovery_completed: Arc::new(RwLock::new(HashMap::new())), http_client, health_cache: HealthCache::new(), + onledger, }); // Boot-time workflow recovery. For any `workflow_runs` row that was @@ -1147,6 +1164,11 @@ pub async fn start_server( }, ); + // Background task: the on-ledger observer loop (design D5, D11). It idles + // until a node identity exists, so it is safe on a fresh node, and it + // never panics, so it needs no supervisor. + let _observer = crate::onledger::spawn_observer(app_state.onledger.clone()); + // Single peer-job listener: drains the queue and spawns one // `workflow::start_peer` per accepted / retried / resumed invite, so this // node can be a peer in many concurrent workflows at once. @@ -1418,6 +1440,9 @@ pub async fn start_server( .service(handlers::get_party_config) .service(handlers::save_party_config) .service(handlers::discover_member_party) + .service(handlers::get_node_identity) + .service(handlers::save_node_identity) + .service(handlers::get_registry) .split_for_parts(); let mut app = app.wrap(AuthMiddleware).wrap(cors); diff --git a/crates/decman/src/server/reward_automation.rs b/crates/decman/src/server/reward_automation.rs index 47b4210b4..65c2b480f 100644 --- a/crates/decman/src/server/reward_automation.rs +++ b/crates/decman/src/server/reward_automation.rs @@ -2612,6 +2612,7 @@ mod tests { let decparty = CantonId::parse(EVE)?; let registry = AuthRegistry::new(&[PartyCredentials { + kind: crate::config::CredentialKind::Decparty, dec_party_id: decparty.clone(), member_party_id: CantonId::parse(BOB)?, user_id: "user".to_string(), diff --git a/daml/decman-coordination-test/daml.yaml b/daml/decman-coordination-test/daml.yaml new file mode 100644 index 000000000..5e5088c9f --- /dev/null +++ b/daml/decman-coordination-test/daml.yaml @@ -0,0 +1,17 @@ +sdk-version: 3.4.11 +name: decman-coordination-test +version: 0.1.0 +source: daml +dependencies: + - daml-prim + - daml-stdlib + - daml-script +data-dependencies: + - ../decman-coordination/.daml/dist/decman-coordination-v1-0.1.0.dar + - ../dars/testlib-0.1.0.dar +build-options: + - --target=2.2 + - -Wno-upgrade-interfaces + - -Wno-template-interface-depends-on-daml-script + - --ghc-option=-Wunused-binds + - --ghc-option=-Wunused-matches diff --git a/daml/decman-coordination-test/daml/Decman/Coordination/Test/AcsTest.daml b/daml/decman-coordination-test/daml/Decman/Coordination/Test/AcsTest.daml new file mode 100644 index 000000000..23cfd9ec4 --- /dev/null +++ b/daml/decman-coordination-test/daml/Decman/Coordination/Test/AcsTest.daml @@ -0,0 +1,120 @@ +-- Copyright (c) 2026 DLC-Link, Inc. and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | Tests for the AcsManifest pin. +module Decman.Coordination.Test.AcsTest where + +import Daml.Script + +import Decman.Coordination.Acs +import Decman.Coordination.TestUtils + +import TestHarness + +-- ============================================================================ +-- Given +-- ============================================================================ + +given_parties : Script CoordinationTestParties +given_parties = do + _ <- startClock + allocateCoordinationTestParties + +-- ============================================================================ +-- When +-- ============================================================================ + +when_manifest_published : CoordinationTestParties -> Script (ContractId AcsManifest) +when_manifest_published parties = do + now <- getTime + submit parties.invitee1 $ createCmd (testManifest parties now) + +when_empty_manifest_published : CoordinationTestParties -> Script (ContractId AcsManifest) +when_empty_manifest_published parties = do + now <- getTime + submit parties.invitee1 $ createCmd (testManifest parties now) with + sizeBytes = 0 + +when_published_with_negative_size : CoordinationTestParties -> Script () +when_published_with_negative_size parties = do + now <- getTime + submitMustFail parties.invitee1 $ createCmd (testManifest parties now) with + sizeBytes = -1 + +when_published_with_zero_activation_serial : CoordinationTestParties -> Script () +when_published_with_zero_activation_serial parties = do + now <- getTime + submitMustFail parties.invitee1 $ createCmd (testManifest parties now) with + activationSerial = 0 + +when_exporter_archives : CoordinationTestParties -> Script () +when_exporter_archives parties = do + now <- getTime + manifestCid <- submit parties.invitee1 $ createCmd (testManifest parties now) + submit parties.invitee1 $ exerciseCmd manifestCid AcsManifest_Archive + +-- ============================================================================ +-- Then +-- ============================================================================ + +then_manifest_visible_to_target : CoordinationTestParties -> ContractId AcsManifest -> Script Failures +then_manifest_visible_to_target parties manifestCid = do + Some manifest <- queryContractId parties.invitee2 manifestCid + proposerView <- query @AcsManifest parties.proposer + pure $ + shouldBe "exporter" parties.invitee1 manifest.exporter <> + shouldBe "exporterParticipant" invitee1ParticipantId manifest.exporterParticipant <> + shouldBe "decPartyId" testDecPartyId manifest.decPartyId <> + shouldBe "targetParticipant" invitee2ParticipantId manifest.targetParticipant <> + shouldBe "activationSerial" 3 manifest.activationSerial <> + shouldBe "sizeBytes" 4096 manifest.sizeBytes <> + shouldBe "packageIds" ["1220aaaa", "1220bbbb"] manifest.packageIds <> + shouldBe "exportedAt" testStartTime manifest.exportedAt <> + shouldBe "proposer sees the manifest" [manifestCid] (map fst proposerView) + +then_empty_manifest_accepted : CoordinationTestParties -> ContractId AcsManifest -> Script Failures +then_empty_manifest_accepted parties manifestCid = do + Some manifest <- queryContractId parties.invitee2 manifestCid + pure $ shouldBe "sizeBytes" 0 manifest.sizeBytes + +then_no_manifest : CoordinationTestParties -> () -> Script Failures +then_no_manifest parties _ = do + targetView <- query @AcsManifest parties.invitee2 + exporterView <- query @AcsManifest parties.invitee1 + pure $ + shouldBe "target view" 0 (length targetView) <> + shouldBe "exporter view" 0 (length exporterView) + +-- ============================================================================ +-- Tests +-- ============================================================================ + +test_when_manifest_published_then_visible_to_target = script do + run Test with + given = given_parties + when = when_manifest_published + then_ = then_manifest_visible_to_target + +test_when_empty_snapshot_then_manifest_accepted = script do + run Test with + given = given_parties + when = when_empty_manifest_published + then_ = then_empty_manifest_accepted + +test_when_negative_size_then_create_rejected = script do + run Test with + given = given_parties + when = when_published_with_negative_size + then_ = then_no_manifest + +test_when_activation_serial_zero_then_create_rejected = script do + run Test with + given = given_parties + when = when_published_with_zero_activation_serial + then_ = then_no_manifest + +test_when_exporter_archives_then_manifest_gone = script do + run Test with + given = given_parties + when = when_exporter_archives + then_ = then_no_manifest diff --git a/daml/decman-coordination-test/daml/Decman/Coordination/Test/NodeTest.daml b/daml/decman-coordination-test/daml/Decman/Coordination/Test/NodeTest.daml new file mode 100644 index 000000000..a9ec0a49b --- /dev/null +++ b/daml/decman-coordination-test/daml/Decman/Coordination/Test/NodeTest.daml @@ -0,0 +1,181 @@ +-- Copyright (c) 2026 DLC-Link, Inc. and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | Tests for the DecmanNode registry entry. +module Decman.Coordination.Test.NodeTest where + +import DA.Time (addRelTime, seconds) +import Daml.Script + +import Decman.Coordination.Node +import Decman.Coordination.TestUtils + +import TestHarness + +-- ============================================================================ +-- Fixtures +-- ============================================================================ + +data NodeFixture = NodeFixture + with + parties : CoordinationTestParties + nodeCid : ContractId DecmanNode + createdAt : Time + deriving (Eq, Show) + +-- ============================================================================ +-- Given +-- ============================================================================ + +given_parties : Script CoordinationTestParties +given_parties = do + _ <- startClock + allocateCoordinationTestParties + +given_registered_node : Script NodeFixture +given_registered_node = do + createdAt <- startClock + parties <- allocateCoordinationTestParties + nodeCid <- createTestNode parties + pure NodeFixture with .. + +-- ============================================================================ +-- When +-- ============================================================================ + +when_heartbeat_at_floor : NodeFixture -> Script (ContractId DecmanNode) +when_heartbeat_at_floor f = do + passTime (seconds testMinHeartbeatIntervalSecs) + submit f.parties.proposer $ exerciseCmd f.nodeCid DecmanNode_Heartbeat + +when_heartbeat_below_floor : NodeFixture -> Script () +when_heartbeat_below_floor f = do + passTime (seconds (testMinHeartbeatIntervalSecs - 1)) + submitMustFail f.parties.proposer $ exerciseCmd f.nodeCid DecmanNode_Heartbeat + +when_node_updated : NodeFixture -> Script (ContractId DecmanNode) +when_node_updated f = do + passTime (seconds 5) + submit f.parties.proposer $ exerciseCmd f.nodeCid DecmanNode_Update with + newPeers = [f.parties.invitee1] + newDisplayName = "renamed" + newVersion = "2.1.0" + newBuildVersion = "2.1.0-test" + newCoordinationVersion = 2 + newHeartbeatIntervalSecs = 7200 + newMinHeartbeatIntervalSecs = 120 + +when_node_retired : NodeFixture -> Script () +when_node_retired f = + submit f.parties.proposer $ exerciseCmd f.nodeCid DecmanNode_Retire + +when_created_with_floor_above_interval : CoordinationTestParties -> Script () +when_created_with_floor_above_interval parties = do + now <- getTime + submitMustFail parties.proposer $ createCmd (testNode parties now) with + minHeartbeatIntervalSecs = testHeartbeatIntervalSecs + 1 + +when_created_with_zero_floor : CoordinationTestParties -> Script () +when_created_with_zero_floor parties = do + now <- getTime + submitMustFail parties.proposer $ createCmd (testNode parties now) with + minHeartbeatIntervalSecs = 0 + +when_created_with_coordination_version_zero : CoordinationTestParties -> Script () +when_created_with_coordination_version_zero parties = do + now <- getTime + submitMustFail parties.proposer $ createCmd (testNode parties now) with + coordinationVersion = 0 + +-- ============================================================================ +-- Then +-- ============================================================================ + +then_last_active_at_moves_to_heartbeat_time : NodeFixture -> ContractId DecmanNode -> Script Failures +then_last_active_at_moves_to_heartbeat_time f newCid = do + Some node <- queryContractId f.parties.proposer newCid + ownEntries <- query @DecmanNode f.parties.proposer + peerView <- query @DecmanNode f.parties.invitee1 + pure $ + shouldBe "lastActiveAt" (addRelTime f.createdAt (seconds testMinHeartbeatIntervalSecs)) node.lastActiveAt <> + shouldBe "one entry after the heartbeat" 1 (length ownEntries) <> + shouldBe "peer sees the new entry" [newCid] (map fst peerView) + +then_entry_unchanged : NodeFixture -> () -> Script Failures +then_entry_unchanged f _ = do + Some node <- queryContractId f.parties.proposer f.nodeCid + pure $ shouldBe "lastActiveAt unchanged" f.createdAt node.lastActiveAt + +then_fields_replaced : NodeFixture -> ContractId DecmanNode -> Script Failures +then_fields_replaced f newCid = do + Some node <- queryContractId f.parties.proposer newCid + droppedPeerView <- query @DecmanNode f.parties.invitee2 + pure $ + shouldBe "peers" [f.parties.invitee1] node.peers <> + shouldBe "displayName" "renamed" node.displayName <> + shouldBe "version" "2.1.0" node.version <> + shouldBe "buildVersion" "2.1.0-test" node.buildVersion <> + shouldBe "coordinationVersion" 2 node.coordinationVersion <> + shouldBe "heartbeatIntervalSecs" 7200 node.heartbeatIntervalSecs <> + shouldBe "minHeartbeatIntervalSecs" 120 node.minHeartbeatIntervalSecs <> + shouldBe "lastActiveAt" (addRelTime f.createdAt (seconds 5)) node.lastActiveAt <> + shouldBe "dropped peer sees no entry" 0 (length droppedPeerView) + +then_entry_gone : NodeFixture -> () -> Script Failures +then_entry_gone f _ = do + ownEntries <- query @DecmanNode f.parties.proposer + peerView <- query @DecmanNode f.parties.invitee1 + pure $ + shouldBe "own entries" 0 (length ownEntries) <> + shouldBe "peer view" 0 (length peerView) + +then_no_entry_created : CoordinationTestParties -> () -> Script Failures +then_no_entry_created parties _ = do + entries <- query @DecmanNode parties.proposer + pure $ shouldBe "entries" 0 (length entries) + +-- ============================================================================ +-- Tests +-- ============================================================================ + +test_when_heartbeat_at_floor_then_last_active_at_updated = script do + run Test with + given = given_registered_node + when = when_heartbeat_at_floor + then_ = then_last_active_at_moves_to_heartbeat_time + +test_when_heartbeat_below_floor_then_rejected = script do + run Test with + given = given_registered_node + when = when_heartbeat_below_floor + then_ = then_entry_unchanged + +test_when_node_updated_then_fields_replaced = script do + run Test with + given = given_registered_node + when = when_node_updated + then_ = then_fields_replaced + +test_when_node_retired_then_entry_gone = script do + run Test with + given = given_registered_node + when = when_node_retired + then_ = then_entry_gone + +test_when_floor_above_interval_then_create_rejected = script do + run Test with + given = given_parties + when = when_created_with_floor_above_interval + then_ = then_no_entry_created + +test_when_floor_below_one_then_create_rejected = script do + run Test with + given = given_parties + when = when_created_with_zero_floor + then_ = then_no_entry_created + +test_when_coordination_version_zero_then_create_rejected = script do + run Test with + given = given_parties + when = when_created_with_coordination_version_zero + then_ = then_no_entry_created diff --git a/daml/decman-coordination-test/daml/Decman/Coordination/Test/SubmissionTest.daml b/daml/decman-coordination-test/daml/Decman/Coordination/Test/SubmissionTest.daml new file mode 100644 index 000000000..00805ce2f --- /dev/null +++ b/daml/decman-coordination-test/daml/Decman/Coordination/Test/SubmissionTest.daml @@ -0,0 +1,143 @@ +-- Copyright (c) 2026 DLC-Link, Inc. and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | Tests for SubmissionRound signature collection. +module Decman.Coordination.Test.SubmissionTest where + +import DA.Time (addRelTime, hours, seconds) +import Daml.Script + +import Decman.Coordination.Submission +import Decman.Coordination.TestUtils + +import TestHarness + +-- ============================================================================ +-- Fixtures +-- ============================================================================ + +data RoundFixture = RoundFixture + with + parties : CoordinationTestParties + roundCid : ContractId SubmissionRound + preparedAt : Time + deriving (Eq, Show) + +-- ============================================================================ +-- Given +-- ============================================================================ + +given_parties : Script CoordinationTestParties +given_parties = do + _ <- startClock + allocateCoordinationTestParties + +given_open_round : Script RoundFixture +given_open_round = do + preparedAt <- startClock + parties <- allocateCoordinationTestParties + roundCid <- createTestRound parties + pure RoundFixture with .. + +-- ============================================================================ +-- When +-- ============================================================================ + +when_signer_signs : RoundFixture -> Script (ContractId SubmissionSignature) +when_signer_signs f = + submit f.parties.invitee1 $ signCmd f.parties.invitee1 invitee1ParticipantId f.roundCid + +-- | readAs stands in for explicit disclosure: the outsider can read the round, +-- so only the "not a signer" guard can reject it. +when_outsider_signs : RoundFixture -> Script () +when_outsider_signs f = + submitMustFail (disclosedContext f.parties.outsider f.parties.invitee1) $ + signCmd f.parties.outsider proposerParticipantId f.roundCid + +when_signer_signs_after_deadline : RoundFixture -> Script () +when_signer_signs_after_deadline f = do + passTime (testRoundDeadline + seconds 1) + submitMustFail f.parties.invitee1 $ signCmd f.parties.invitee1 invitee1ParticipantId f.roundCid + +when_proposer_closes : RoundFixture -> Script () +when_proposer_closes f = + submit f.parties.proposer $ exerciseCmd f.roundCid SubmissionRound_Close with + result = "executed" + +when_created_with_deadline_after_max_record_time : CoordinationTestParties -> Script () +when_created_with_deadline_after_max_record_time parties = do + now <- getTime + submitMustFail parties.proposer $ createCmd (testRound parties now) with + deadline = addRelTime now (hours 21) + +-- ============================================================================ +-- Then +-- ============================================================================ + +then_signature_visible_to_proposer : RoundFixture -> ContractId SubmissionSignature -> Script Failures +then_signature_visible_to_proposer f signatureCid = do + Some signature <- queryContractId f.parties.proposer signatureCid + siblingView <- query @SubmissionSignature f.parties.invitee2 + rounds <- query @SubmissionRound f.parties.proposer + pure $ + shouldBe "round" f.roundCid signature.round <> + shouldBe "signer" f.parties.invitee1 signature.signer <> + shouldBe "participantId" invitee1ParticipantId signature.participantId <> + shouldBe "runId" testRunId signature.runId <> + shouldBe "index" 0 signature.index <> + shouldBe "signedBy" testFingerprint signature.signedBy <> + shouldBe "signedAt" f.preparedAt signature.signedAt <> + shouldBe "sibling signer sees the signature" [signatureCid] (map fst siblingView) <> + shouldBe "round stays active" [f.roundCid] (map fst rounds) + +then_no_signature_recorded : RoundFixture -> () -> Script Failures +then_no_signature_recorded f _ = do + signatures <- query @SubmissionSignature f.parties.proposer + pure $ shouldBe "signatures" 0 (length signatures) + +then_round_archived : RoundFixture -> () -> Script Failures +then_round_archived f _ = do + proposerView <- query @SubmissionRound f.parties.proposer + signerView <- query @SubmissionRound f.parties.invitee1 + pure $ + shouldBe "proposer view" 0 (length proposerView) <> + shouldBe "signer view" 0 (length signerView) + +then_no_round_created : CoordinationTestParties -> () -> Script Failures +then_no_round_created parties _ = do + rounds <- query @SubmissionRound parties.proposer + pure $ shouldBe "rounds" 0 (length rounds) + +-- ============================================================================ +-- Tests +-- ============================================================================ + +test_when_signer_signs_then_signature_visible_to_proposer = script do + run Test with + given = given_open_round + when = when_signer_signs + then_ = then_signature_visible_to_proposer + +test_when_outsider_signs_then_rejected = script do + run Test with + given = given_open_round + when = when_outsider_signs + then_ = then_no_signature_recorded + +test_when_sign_after_deadline_then_rejected = script do + run Test with + given = given_open_round + when = when_signer_signs_after_deadline + then_ = then_no_signature_recorded + +test_when_proposer_closes_then_round_archived = script do + run Test with + given = given_open_round + when = when_proposer_closes + then_ = then_round_archived + +test_when_deadline_after_max_record_time_then_create_rejected = script do + run Test with + given = given_parties + when = when_created_with_deadline_after_max_record_time + then_ = then_no_round_created diff --git a/daml/decman-coordination-test/daml/Decman/Coordination/Test/WorkflowTest.daml b/daml/decman-coordination-test/daml/Decman/Coordination/Test/WorkflowTest.daml new file mode 100644 index 000000000..03e1ffcc4 --- /dev/null +++ b/daml/decman-coordination-test/daml/Decman/Coordination/Test/WorkflowTest.daml @@ -0,0 +1,230 @@ +-- Copyright (c) 2026 DLC-Link, Inc. and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | Tests for WorkflowProposal and the contracts it creates. +module Decman.Coordination.Test.WorkflowTest where + +import DA.Time (seconds) +import Daml.Script + +import Decman.Coordination.TestUtils +import Decman.Coordination.Types (WorkflowKind(..)) +import Decman.Coordination.Workflow + +import TestHarness + +-- ============================================================================ +-- Fixtures +-- ============================================================================ + +data ProposalFixture = ProposalFixture + with + parties : CoordinationTestParties + proposalCid : ContractId WorkflowProposal + createdAt : Time + deriving (Eq, Show) + +-- ============================================================================ +-- Given +-- ============================================================================ + +given_parties : Script CoordinationTestParties +given_parties = do + _ <- startClock + allocateCoordinationTestParties + +given_open_proposal : Script ProposalFixture +given_open_proposal = do + createdAt <- startClock + parties <- allocateCoordinationTestParties + proposalCid <- createTestProposal parties + pure ProposalFixture with .. + +-- ============================================================================ +-- When +-- ============================================================================ + +when_both_invitees_accept : ProposalFixture -> Script (ContractId WorkflowAcceptance, ContractId WorkflowAcceptance) +when_both_invitees_accept f = do + first <- submit f.parties.invitee1 $ acceptCmd f.parties.invitee1 invitee1ParticipantId f.proposalCid + second <- submit f.parties.invitee2 $ acceptCmd f.parties.invitee2 invitee2ParticipantId f.proposalCid + pure (first, second) + +-- | readAs stands in for explicit disclosure: the outsider can read the +-- proposal, so only the "not invited" guard can reject it. +when_outsider_accepts : ProposalFixture -> Script () +when_outsider_accepts f = + submitMustFail (disclosedContext f.parties.outsider f.parties.invitee1) $ + acceptCmd f.parties.outsider proposerParticipantId f.proposalCid + +when_outsider_declines : ProposalFixture -> Script () +when_outsider_declines f = + submitMustFail (disclosedContext f.parties.outsider f.parties.invitee1) $ + declineCmd f.parties.outsider "not my party" f.proposalCid + +when_invitee_accepts_after_expiry : ProposalFixture -> Script () +when_invitee_accepts_after_expiry f = do + passTime (testProposalTtl + seconds 1) + submitMustFail f.parties.invitee1 $ acceptCmd f.parties.invitee1 invitee1ParticipantId f.proposalCid + +when_invitee_declines : ProposalFixture -> Script (ContractId WorkflowDecline) +when_invitee_declines f = + submit f.parties.invitee1 $ declineCmd f.parties.invitee1 "key generation failed" f.proposalCid + +when_proposer_cancels : ProposalFixture -> Script () +when_proposer_cancels f = + submit f.parties.proposer $ exerciseCmd f.proposalCid WorkflowProposal_Cancel + +when_proposer_finishes_with_success : ProposalFixture -> Script (ContractId WorkflowOutcome) +when_proposer_finishes_with_success f = + submit f.parties.proposer $ exerciseCmd f.proposalCid WorkflowProposal_Finish with + succeeded = True + error = None + +when_proposer_finishes_with_failure : ProposalFixture -> Script (ContractId WorkflowOutcome) +when_proposer_finishes_with_failure f = + submit f.parties.proposer $ exerciseCmd f.proposalCid WorkflowProposal_Finish with + succeeded = False + error = Some "Peer Invitee2 declined the invitation" + +when_created_with_expiry_not_after_creation : CoordinationTestParties -> Script () +when_created_with_expiry_not_after_creation parties = do + now <- getTime + submitMustFail parties.proposer $ createCmd (testProposal parties now) with + expiresAt = now + +-- ============================================================================ +-- Then +-- ============================================================================ + +then_both_acceptances_recorded : ProposalFixture -> (ContractId WorkflowAcceptance, ContractId WorkflowAcceptance) -> Script Failures +then_both_acceptances_recorded f (first, second) = do + Some acceptance1 <- queryContractId f.parties.proposer first + Some acceptance2 <- queryContractId f.parties.proposer second + proposerView <- query @WorkflowAcceptance f.parties.proposer + siblingView <- query @WorkflowAcceptance f.parties.invitee1 + proposals <- query @WorkflowProposal f.parties.proposer + pure $ + shouldBe "acceptances visible to the proposer" 2 (length proposerView) <> + shouldBe "acceptances visible to a sibling invitee" 2 (length siblingView) <> + shouldBe "proposal stays active" [f.proposalCid] (map fst proposals) <> + shouldBe "acceptance 1 proposal" f.proposalCid acceptance1.proposal <> + shouldBe "acceptance 1 acceptor" f.parties.invitee1 acceptance1.acceptor <> + shouldBe "acceptance 1 participantId" invitee1ParticipantId acceptance1.participantId <> + shouldBe "acceptance 1 runId" testRunId acceptance1.runId <> + shouldBe "acceptance 1 acceptedAt" f.createdAt acceptance1.acceptedAt <> + shouldBe "acceptance 2 acceptor" f.parties.invitee2 acceptance2.acceptor <> + shouldBe "acceptance 2 participantId" invitee2ParticipantId acceptance2.participantId + +then_no_acceptance_recorded : ProposalFixture -> () -> Script Failures +then_no_acceptance_recorded f _ = do + acceptances <- query @WorkflowAcceptance f.parties.proposer + pure $ shouldBe "acceptances" 0 (length acceptances) + +then_no_decline_recorded : ProposalFixture -> () -> Script Failures +then_no_decline_recorded f _ = do + declines <- query @WorkflowDecline f.parties.proposer + pure $ shouldBe "declines" 0 (length declines) + +then_decline_visible_to_proposer : ProposalFixture -> ContractId WorkflowDecline -> Script Failures +then_decline_visible_to_proposer f declineCid = do + Some decline <- queryContractId f.parties.proposer declineCid + proposals <- query @WorkflowProposal f.parties.proposer + pure $ + shouldBe "decliner" f.parties.invitee1 decline.decliner <> + shouldBe "reason" "key generation failed" decline.reason <> + shouldBe "proposal" f.proposalCid decline.proposal <> + shouldBe "declinedAt" f.createdAt decline.declinedAt <> + shouldBe "proposal stays active" 1 (length proposals) + +then_proposal_archived_for_everyone : ProposalFixture -> () -> Script Failures +then_proposal_archived_for_everyone f _ = do + proposerView <- query @WorkflowProposal f.parties.proposer + inviteeView <- query @WorkflowProposal f.parties.invitee1 + pure $ + shouldBe "proposer view" 0 (length proposerView) <> + shouldBe "invitee view" 0 (length inviteeView) + +then_success_outcome_visible_to_invitees : ProposalFixture -> ContractId WorkflowOutcome -> Script Failures +then_success_outcome_visible_to_invitees f outcomeCid = do + Some outcome <- queryContractId f.parties.invitee1 outcomeCid + invitee2View <- query @WorkflowOutcome f.parties.invitee2 + proposals <- query @WorkflowProposal f.parties.proposer + pure $ + shouldBe "succeeded" True outcome.succeeded <> + shouldBe "error" None outcome.error <> + shouldBe "runId" testRunId outcome.runId <> + shouldBe "kind" Onboarding outcome.kind <> + shouldBe "finishedAt" f.createdAt outcome.finishedAt <> + shouldBe "second invitee sees the outcome" [outcomeCid] (map fst invitee2View) <> + shouldBe "proposal archived" 0 (length proposals) + +then_failed_outcome_carries_error : ProposalFixture -> ContractId WorkflowOutcome -> Script Failures +then_failed_outcome_carries_error f outcomeCid = do + Some outcome <- queryContractId f.parties.invitee1 outcomeCid + pure $ + shouldBe "succeeded" False outcome.succeeded <> + shouldBe "error" (Some "Peer Invitee2 declined the invitation") outcome.error + +then_no_proposal_created : CoordinationTestParties -> () -> Script Failures +then_no_proposal_created parties _ = do + proposals <- query @WorkflowProposal parties.proposer + pure $ shouldBe "proposals" 0 (length proposals) + +-- ============================================================================ +-- Tests +-- ============================================================================ + +test_when_two_invitees_accept_then_both_acceptances_recorded = script do + run Test with + given = given_open_proposal + when = when_both_invitees_accept + then_ = then_both_acceptances_recorded + +test_when_outsider_accepts_then_rejected = script do + run Test with + given = given_open_proposal + when = when_outsider_accepts + then_ = then_no_acceptance_recorded + +test_when_outsider_declines_then_rejected = script do + run Test with + given = given_open_proposal + when = when_outsider_declines + then_ = then_no_decline_recorded + +test_when_accept_after_expiry_then_rejected = script do + run Test with + given = given_open_proposal + when = when_invitee_accepts_after_expiry + then_ = then_no_acceptance_recorded + +test_when_invitee_declines_then_decline_visible_to_proposer = script do + run Test with + given = given_open_proposal + when = when_invitee_declines + then_ = then_decline_visible_to_proposer + +test_when_proposer_cancels_then_proposal_archived = script do + run Test with + given = given_open_proposal + when = when_proposer_cancels + then_ = then_proposal_archived_for_everyone + +test_when_proposer_finishes_then_outcome_visible_to_invitees = script do + run Test with + given = given_open_proposal + when = when_proposer_finishes_with_success + then_ = then_success_outcome_visible_to_invitees + +test_when_proposer_finishes_with_failure_then_outcome_carries_error = script do + run Test with + given = given_open_proposal + when = when_proposer_finishes_with_failure + then_ = then_failed_outcome_carries_error + +test_when_expiry_not_after_creation_then_create_rejected = script do + run Test with + given = given_parties + when = when_created_with_expiry_not_after_creation + then_ = then_no_proposal_created diff --git a/daml/decman-coordination-test/daml/Decman/Coordination/TestUtils.daml b/daml/decman-coordination-test/daml/Decman/Coordination/TestUtils.daml new file mode 100644 index 000000000..a5fc43f21 --- /dev/null +++ b/daml/decman-coordination-test/daml/Decman/Coordination/TestUtils.daml @@ -0,0 +1,214 @@ +-- Copyright (c) 2026 DLC-Link, Inc. and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | Test utilities for decman-coordination tests. +-- Every party is a node party. Daml Script does not model participants, so +-- participant ids are plain text constants and hosting checks stay in Rust. +module Decman.Coordination.TestUtils where + +import DA.Date (Month(..), date) +import DA.Time (RelTime, addRelTime, days, hours, time) +import Daml.Script + +import Decman.Coordination.Acs +import Decman.Coordination.Node +import Decman.Coordination.Submission +import Decman.Coordination.Types +import Decman.Coordination.Workflow + +-- | Standard test party allocation. +data CoordinationTestParties = CoordinationTestParties + with + proposer : Party + invitee1 : Party + invitee2 : Party + outsider : Party + deriving (Show, Eq) + +-- | Allocate standard test parties. +allocateCoordinationTestParties : Script CoordinationTestParties +allocateCoordinationTestParties = do + proposer <- allocateParty "Proposer" + invitee1 <- allocateParty "Invitee1" + invitee2 <- allocateParty "Invitee2" + outsider <- allocateParty "Outsider" + pure CoordinationTestParties with .. + +-- | Fixed ledger time for every fixture. Tests move time relative to it. +testStartTime : Time +testStartTime = time (date 2026 Sep 14) 12 0 0 + +-- | Set the ledger clock to testStartTime and return it. +startClock : Script Time +startClock = do + setTime testStartTime + pure testStartTime + +-- | Submission context for a party that can read a contract it does not hold. +-- Stands in for explicit disclosure, so a negative test reaches the choice +-- guard instead of failing on visibility. Qualified because the SubmissionRound +-- field `actAs` shadows the Daml.Script function of the same name. +disclosedContext actor reader = Daml.Script.actAs actor <> Daml.Script.readAs reader + +proposerParticipantId : Text +proposerParticipantId = "PAR::proposer::1220aa" + +invitee1ParticipantId : Text +invitee1ParticipantId = "PAR::invitee1::1220bb" + +invitee2ParticipantId : Text +invitee2ParticipantId = "PAR::invitee2::1220cc" + +testFingerprint : Text +testFingerprint = "1220deadbeef" + +testSigningPublicKeyHex : Text +testSigningPublicKeyHex = "0a20deadbeef" + +testDecPartyId : Text +testDecPartyId = "decparty::1220dd" + +testRunId : Text +testRunId = "run-1" + +-- ============================================================================ +-- Registry +-- ============================================================================ + +testHeartbeatIntervalSecs : Int +testHeartbeatIntervalSecs = 3600 + +testMinHeartbeatIntervalSecs : Int +testMinHeartbeatIntervalSecs = 60 + +-- | The proposer's registry entry with both invitees as peers. +testNode : CoordinationTestParties -> Time -> DecmanNode +testNode parties now = DecmanNode with + node = parties.proposer + participantId = proposerParticipantId + displayName = "proposer-node" + version = "2.0.0" + buildVersion = "2.0.0-test" + coordinationVersion = 1 + peers = [parties.invitee1, parties.invitee2] + lastActiveAt = now + heartbeatIntervalSecs = testHeartbeatIntervalSecs + minHeartbeatIntervalSecs = testMinHeartbeatIntervalSecs + +createTestNode : CoordinationTestParties -> Script (ContractId DecmanNode) +createTestNode parties = do + now <- getTime + submit parties.proposer $ createCmd (testNode parties now) + +-- ============================================================================ +-- Workflow +-- ============================================================================ + +testProposalTtl : RelTime +testProposalTtl = days 7 + +-- | An onboarding proposal from the proposer to both invitees. +testProposal : CoordinationTestParties -> Time -> WorkflowProposal +testProposal parties now = WorkflowProposal with + proposer = parties.proposer + proposerParticipant = proposerParticipantId + proposerNamespaceFingerprint = Some testFingerprint + proposerSigningPublicKeyHex = Some testSigningPublicKeyHex + proposerDamlKeyFingerprint = Some testFingerprint + runId = testRunId + kind = Onboarding + invitees = [parties.invitee1, parties.invitee2] + participants = [proposerParticipantId, invitee1ParticipantId, invitee2ParticipantId] + decPartyId = None + prefix = Some "decparty" + threshold = Some 2 + previousThreshold = None + dndBaseSerial = None + p2pBaseSerial = None + newParticipant = None + kickedParticipant = None + darPins = [] + packageNames = [] + description = "Onboard decparty" + createdAt = now + expiresAt = addRelTime now testProposalTtl + +createTestProposal : CoordinationTestParties -> Script (ContractId WorkflowProposal) +createTestProposal parties = do + now <- getTime + submit parties.proposer $ createCmd (testProposal parties now) + +-- | The Accept command of one node. A command, not a submission, so a test can +-- submit it as a success or as an expected failure. +acceptCmd : Party -> Text -> ContractId WorkflowProposal -> Commands (ContractId WorkflowAcceptance) +acceptCmd acceptor participantId proposalCid = + exerciseCmd proposalCid WorkflowProposal_Accept with + acceptor + participantId + namespaceFingerprint = Some testFingerprint + signingPublicKeyHex = Some testSigningPublicKeyHex + damlKeyFingerprint = Some testFingerprint + memberParty = None + +declineCmd : Party -> Text -> ContractId WorkflowProposal -> Commands (ContractId WorkflowDecline) +declineCmd decliner reason proposalCid = + exerciseCmd proposalCid WorkflowProposal_Decline with + decliner + reason + +-- ============================================================================ +-- Submission +-- ============================================================================ + +testRoundDeadline : RelTime +testRoundDeadline = hours 1 + +-- | A round for both invitees, prepared at `now`. +testRound : CoordinationTestParties -> Time -> SubmissionRound +testRound parties now = SubmissionRound with + proposer = parties.proposer + runId = testRunId + index = 0 + signers = [parties.invitee1, parties.invitee2] + decPartyId = testDecPartyId + actAs = testDecPartyId + description = "Create GovernanceRules" + preparedTransactionHex = "0a00" + preparedHashHex = "1220cafe" + hashingSchemeVersion = 2 + preparationTime = now + maxRecordTime = addRelTime now (hours 20) + deadline = addRelTime now testRoundDeadline + +createTestRound : CoordinationTestParties -> Script (ContractId SubmissionRound) +createTestRound parties = do + now <- getTime + submit parties.proposer $ createCmd (testRound parties now) + +signCmd : Party -> Text -> ContractId SubmissionRound -> Commands (ContractId SubmissionSignature) +signCmd signer participantId roundCid = + exerciseCmd roundCid SubmissionRound_Sign with + signer + participantId + signedBy = testFingerprint + signatureHex = "abcd" + format = "SIGNATURE_FORMAT_CONCAT" + algorithm = "SIGNING_ALGORITHM_SPEC_ED25519" + +-- ============================================================================ +-- ACS +-- ============================================================================ + +-- | A manifest exported by invitee1 for a snapshot that invitee2 imports. +testManifest : CoordinationTestParties -> Time -> AcsManifest +testManifest parties now = AcsManifest with + exporter = parties.invitee1 + exporterParticipant = invitee1ParticipantId + observers = [parties.proposer, parties.invitee2] + decPartyId = testDecPartyId + targetParticipant = invitee2ParticipantId + activationSerial = 3 + sizeBytes = 4096 + sha256Hex = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + packageIds = ["1220aaaa", "1220bbbb"] + exportedAt = now diff --git a/daml/decman-coordination/daml.yaml b/daml/decman-coordination/daml.yaml new file mode 100644 index 000000000..3be4d1daf --- /dev/null +++ b/daml/decman-coordination/daml.yaml @@ -0,0 +1,11 @@ +sdk-version: 3.4.11 +name: decman-coordination-v1 +version: 0.1.0 +source: daml +dependencies: + - daml-prim + - daml-stdlib +build-options: + - --target=2.2 + - --ghc-option=-Wunused-binds + - --ghc-option=-Wunused-matches diff --git a/daml/decman-coordination/daml/Decman/Coordination/Acs.daml b/daml/decman-coordination/daml/Decman/Coordination/Acs.daml new file mode 100644 index 000000000..7ede9f4a3 --- /dev/null +++ b/daml/decman-coordination/daml/Decman/Coordination/Acs.daml @@ -0,0 +1,32 @@ +-- Copyright (c) 2026 DLC-Link, Inc. and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | The on-ledger pin for an ACS snapshot that the operator moves by hand. +module Decman.Coordination.Acs where + +-- | Pins one exported snapshot. The joiner verifies the file against sizeBytes +-- and sha256Hex before it imports, and trusts the manifest only after it has +-- checked the exporter's hosting against the topology store. +template AcsManifest + with + exporter : Party + exporterParticipant : Text + observers : [Party] + decPartyId : Text + targetParticipant : Text + activationSerial : Int + -- ^ The P2P serial that marked the target participant Onboarding. + sizeBytes : Int + -- ^ Zero means the party had no active contracts. The joiner then clears the flag without an import. + sha256Hex : Text + packageIds : [Text] + -- ^ The joiner requires every id to be vetted locally before the import. + exportedAt : Time + where + signatory exporter + observer observers + ensure sizeBytes >= 0 && activationSerial >= 1 + + choice AcsManifest_Archive : () + controller exporter + do pure () diff --git a/daml/decman-coordination/daml/Decman/Coordination/Node.daml b/daml/decman-coordination/daml/Decman/Coordination/Node.daml new file mode 100644 index 000000000..4aa883e81 --- /dev/null +++ b/daml/decman-coordination/daml/Decman/Coordination/Node.daml @@ -0,0 +1,73 @@ +-- Copyright (c) 2026 DLC-Link, Inc. and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | The self-signed registry entry of one decman node. +module Decman.Coordination.Node where + +import DA.Time (addRelTime, seconds) + +-- | One node's registry entry. Readers key the entry by its signatory, so an +-- entry can only describe the node that signed it. Observers are the peers +-- whose participant has vetted this package; Canton rejects a create for any +-- other informee. +template DecmanNode + with + node : Party + participantId : Text + -- ^ A claim. Readers cross-check it against the topology store. + displayName : Text + version : Text + buildVersion : Text + coordinationVersion : Int + -- ^ A proposer refuses to start a run with a peer below its own version. + peers : [Party] + lastActiveAt : Time + heartbeatIntervalSecs : Int + -- ^ Peers derive staleness from this value, not from a liveness probe. + minHeartbeatIntervalSecs : Int + -- ^ Floor for DecmanNode_Heartbeat. Stops a misconfigured node from flooding the ledger. + where + signatory node + observer peers + ensure heartbeatIntervalSecs > 0 + && minHeartbeatIntervalSecs >= 1 + && minHeartbeatIntervalSecs <= heartbeatIntervalSecs + && coordinationVersion >= 1 + + -- | Re-create the entry with lastActiveAt = now. + choice DecmanNode_Heartbeat : ContractId DecmanNode + controller node + do + now <- getTime + assertMsg "heartbeat too soon" + (now >= addRelTime lastActiveAt (seconds minHeartbeatIntervalSecs)) + create this with lastActiveAt = now + + -- | Replace every mutable field at once. The publisher exercises this only + -- when the on-ledger fields differ from its desired state. + choice DecmanNode_Update : ContractId DecmanNode + with + newPeers : [Party] + newDisplayName : Text + newVersion : Text + newBuildVersion : Text + newCoordinationVersion : Int + newHeartbeatIntervalSecs : Int + newMinHeartbeatIntervalSecs : Int + controller node + do + now <- getTime + create this with + peers = newPeers + displayName = newDisplayName + version = newVersion + buildVersion = newBuildVersion + coordinationVersion = newCoordinationVersion + heartbeatIntervalSecs = newHeartbeatIntervalSecs + minHeartbeatIntervalSecs = newMinHeartbeatIntervalSecs + lastActiveAt = now + + -- | Remove this node from the registry. + choice DecmanNode_Retire : () + controller node + do pure () diff --git a/daml/decman-coordination/daml/Decman/Coordination/Submission.daml b/daml/decman-coordination/daml/Decman/Coordination/Submission.daml new file mode 100644 index 000000000..7f56cf513 --- /dev/null +++ b/daml/decman-coordination/daml/Decman/Coordination/Submission.daml @@ -0,0 +1,91 @@ +-- Copyright (c) 2026 DLC-Link, Inc. and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | Signature collection for one prepared interactive submission. +module Decman.Coordination.Submission where + +-- | One prepared transaction for the decentralized party. Signers recompute the +-- hash from the bytes before they sign; the proposer verifies each signature +-- before it counts toward the party threshold. +template SubmissionRound + with + proposer : Party + runId : Text + index : Int + signers : [Party] + decPartyId : Text + actAs : Text + -- ^ The party the prepared transaction acts as. Signers require it to equal decPartyId. + description : Text + preparedTransactionHex : Text + preparedHashHex : Text + hashingSchemeVersion : Int + preparationTime : Time + maxRecordTime : Time + deadline : Time + -- ^ Set below maxRecordTime so the proposer has time to execute after the last signature. + where + signatory proposer + observer signers + ensure deadline <= maxRecordTime + + -- | Nonconsuming, so every signer signs the same round. + nonconsuming choice SubmissionRound_Sign : ContractId SubmissionSignature + with + signer : Party + participantId : Text + signedBy : Text + signatureHex : Text + format : Text + algorithm : Text + controller signer + do + assertMsg "not a signer" (signer `elem` signers) + now <- getTime + assertMsg "round expired" (now < deadline) + create SubmissionSignature with + round = self + proposer + signer + observers = signers + runId + index + participantId + signedBy + signatureHex + format + algorithm + signedAt = now + + -- | Close the round after execution or expiry. + choice SubmissionRound_Close : () + with + result : Text + -- ^ Recorded in the exercise event as the audit trail of the round. + controller proposer + do pure () + +template SubmissionSignature + with + round : ContractId SubmissionRound + proposer : Party + signer : Party + observers : [Party] + runId : Text + index : Int + participantId : Text + signedBy : Text + -- ^ Fingerprint of the signing key. The proposer counts the signature only when this key is in the head party_signing_keys. + signatureHex : Text + format : Text + -- ^ Canton SignatureFormat name. + algorithm : Text + -- ^ Canton SigningAlgorithmSpec name. + signedAt : Time + where + signatory signer + observer proposer, observers + + choice SubmissionSignature_Archive : () + controller signer + do pure () diff --git a/daml/decman-coordination/daml/Decman/Coordination/Types.daml b/daml/decman-coordination/daml/Decman/Coordination/Types.daml new file mode 100644 index 000000000..fc7a84189 --- /dev/null +++ b/daml/decman-coordination/daml/Decman/Coordination/Types.daml @@ -0,0 +1,22 @@ +-- Copyright (c) 2026 DLC-Link, Inc. and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | Shared data types for decman node coordination. +-- Fingerprints and hashes are Canton hex strings. Public keys and signatures +-- are lowercase hex. The Rust side validates the hex; Daml only carries it. +module Decman.Coordination.Types where + +-- | The kinds of run a decman node coordinates. +data WorkflowKind = Onboarding | AddParty | Kick | ChangeThreshold | Contracts | Dars + deriving (Eq, Show) + +-- | A DAR that each invitee uploads locally. No DAR bytes travel on the ledger, +-- so the pin is what lets an operator verify the file they received. +data DarPin = DarPin + with + filename : Text + sha256Hex : Text + mainPackageId : Text + -- ^ Pinned as the expected main package id on the local upload. + sizeBytes : Int + deriving (Eq, Show) diff --git a/daml/decman-coordination/daml/Decman/Coordination/Workflow.daml b/daml/decman-coordination/daml/Decman/Coordination/Workflow.daml new file mode 100644 index 000000000..eb3b0db0c --- /dev/null +++ b/daml/decman-coordination/daml/Decman/Coordination/Workflow.daml @@ -0,0 +1,169 @@ +-- Copyright (c) 2026 DLC-Link, Inc. and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | Workflow intent and the responses to it. +-- The proposer signs the intent alone. Invitees answer with their own +-- self-signed contracts, so no submission ever needs two acting parties. +module Decman.Coordination.Workflow where + +import Decman.Coordination.Types + +-- | The proposer's intent for one run. The reference fields are what a member +-- validates every later topology proposal against before it co-signs. +template WorkflowProposal + with + proposer : Party + proposerParticipant : Text + proposerNamespaceFingerprint : Optional Text + proposerSigningPublicKeyHex : Optional Text + proposerDamlKeyFingerprint : Optional Text + runId : Text + kind : WorkflowKind + invitees : [Party] + participants : [Text] + decPartyId : Optional Text + prefix : Optional Text + threshold : Optional Int + previousThreshold : Optional Int + -- ^ Head DND threshold at creation. Sets the quorum for kick and change-threshold. + dndBaseSerial : Optional Int + -- ^ Accepted DND serial at creation. A member refuses a proposal built on another base. + p2pBaseSerial : Optional Int + newParticipant : Optional Text + kickedParticipant : Optional Text + darPins : [DarPin] + packageNames : [Text] + description : Text + createdAt : Time + expiresAt : Time + where + signatory proposer + observer invitees + ensure expiresAt > createdAt + + -- | Nonconsuming, so every invitee accepts the same contract. The guard, + -- not visibility, rejects an outsider who obtained the contract by disclosure. + nonconsuming choice WorkflowProposal_Accept : ContractId WorkflowAcceptance + with + acceptor : Party + participantId : Text + namespaceFingerprint : Optional Text + signingPublicKeyHex : Optional Text + damlKeyFingerprint : Optional Text + memberParty : Optional Party + controller acceptor + do + assertMsg "not invited" (acceptor `elem` invitees) + now <- getTime + assertMsg "proposal expired" (now < expiresAt) + create WorkflowAcceptance with + proposal = self + proposer + acceptor + observers = invitees + runId + participantId + namespaceFingerprint + signingPublicKeyHex + damlKeyFingerprint + memberParty + acceptedAt = now + + nonconsuming choice WorkflowProposal_Decline : ContractId WorkflowDecline + with + decliner : Party + reason : Text + controller decliner + do + assertMsg "not invited" (decliner `elem` invitees) + now <- getTime + create WorkflowDecline with + proposal = self + proposer + decliner + observers = invitees + runId + reason + declinedAt = now + + -- | A topology proposal cannot be withdrawn. Cancel archives the intent, so + -- members stop co-signing because the match disappears. + choice WorkflowProposal_Cancel : () + controller proposer + do pure () + + choice WorkflowProposal_Finish : ContractId WorkflowOutcome + with + succeeded : Bool + error : Optional Text + controller proposer + do + now <- getTime + create WorkflowOutcome with + proposer + runId + kind + observers = invitees + succeeded + error + finishedAt = now + +-- | One invitee's acceptance. The proposer counts it only after it has checked +-- the acceptor against the proposal and the hosting against the topology store. +template WorkflowAcceptance + with + proposal : ContractId WorkflowProposal + proposer : Party + acceptor : Party + observers : [Party] + runId : Text + participantId : Text + namespaceFingerprint : Optional Text + signingPublicKeyHex : Optional Text + -- ^ Informational. The root NamespaceDelegation is the source of key bytes. + damlKeyFingerprint : Optional Text + memberParty : Optional Party + acceptedAt : Time + where + signatory acceptor + observer proposer, observers + + choice WorkflowAcceptance_Archive : () + controller acceptor + do pure () + +template WorkflowDecline + with + proposal : ContractId WorkflowProposal + proposer : Party + decliner : Party + observers : [Party] + runId : Text + reason : Text + declinedAt : Time + where + signatory decliner + observer proposer, observers + + choice WorkflowDecline_Archive : () + controller decliner + do pure () + +-- | The proposer's verdict on a finished run. It outlives the proposal, so +-- invitees can settle their own run rows after the proposal is gone. +template WorkflowOutcome + with + proposer : Party + runId : Text + kind : WorkflowKind + observers : [Party] + succeeded : Bool + error : Optional Text + finishedAt : Time + where + signatory proposer + observer observers + + choice WorkflowOutcome_Archive : () + controller proposer + do pure () diff --git a/daml/multi-package.yaml b/daml/multi-package.yaml index 7e6aff963..58a4037d5 100644 --- a/daml/multi-package.yaml +++ b/daml/multi-package.yaml @@ -1,4 +1,6 @@ packages: + - decman-coordination + - decman-coordination-test - governance-action-v1 - governance-core - orphan-marker diff --git a/releases/v1/decman-coordination-v1-0.1.0.dar b/releases/v1/decman-coordination-v1-0.1.0.dar new file mode 100644 index 0000000000000000000000000000000000000000..08ef3083847075903ef6adbad058de3a90b94a70 GIT binary patch literal 498531 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 (d%g195hk1xxJ`SC zYfKkf$2B?;T6!;Zw-sNrlVlZ_6^z0M)%&v6%L={&V2~UhV$uJ`cv7Y=s-5wBnXAMP z$JQ_EX+zDkRHqz441*j4*PN23k0>^NYW=L`m86*DR+AU5N$C%{wGQ3%f2hIAOD3aU zeAjc3cQ^gP@pzG2 z-6J)@;CM_9HIc>hCbgt1cQ&h}nOku(LA68BS2O85=#^YxsKoh(M6Vh5T!AtRmpkR7 z6tq$^f~*#-pzC>ro!&c;3N7ZQRR3LyQKs_=>nqp^Lz(?iN-2PdbgWn&&zL-90Q9MY zOqS-6*NPw~h;`^e+Y>rj2#nxLn_EI|OCjKzRQRJj-n2UeSO0+=Ez|`)W`6V@w&J^z zXJMygod%!}I=vR7^?Qz`-JA3T24(QsEjFUOeAY`BlB>6_ zz>cdB_J0)8^H>h{D?9AZZut23JwBJ}AFHSvv-<;PuB z+bF7;BOFi&buKFn{EH&p*G_A1OD^@a?tRU3`+koymHcPvde$Q?rM0(s+!CCcy2IkH zVq$_|(odCM!|!u*YxL{t7+J)}43h1Ytn&zHE7`!Yxg|7sbceOe(mQ9);}3s&J)L`B zZi6WCK415OZIoq-)_0|@Wr>r#$@N}kPabTw8p%l4lsHOKvw;b2^lNZr`}P(HUFmte z7s@n;d0UhByV!_)YhCQ;cz<`$AC-HgP;YWcWj~2 zX`y$jqwn31W&&?gRFsCna5-5n6tf65*-BIqp>QiIHNi)T$1F-X97&c1y`75EZrO{3 zTth?Jzj*FPn;Wn8$YT9DWMEEuWxNWkMXy5%HLZ-P{zEt#rBg!Tn!i*AJ;+RVIhOzkhC{Z`|W)upMmVn+RhNY>8Y&d0n+({58w=t{U#ujFt@*orf~&9Ksp};k-}Nfu#tcVy*rg+&zpyy58h`V-oyB}0A9SE9PNysCoPfB=10?c-zG(I2r9M)LeDXma8e;u%kTGm<bvuVO>6sO#^jdH`TUgBJh_ zGuO5aKTTrL!x6bMK z;Qv`63I3OPtFYD+KYHb;@9h7*D?Ews1IbNl!rFo&qqtI2g$A;mZSgO zra-;0a+d5-K(n6uE@2ewy80loZ=nh7s*=8!^e#aUj}-{UixCb1%RA=$-t}A=RNeMd z>*lU;zrZ%DrCH)^lh~zj^oA-;utAHeTjjqERf*`mRpS5Qf7`5-gw5F0^rgEFI^yDQ zhg=?)H-7wnMi63V`fRZUf8oQgL3{kX;IYLS#jKa47X#Ro9bik$qn%nNHI?PMXc$>LkXL|%}xXqXOf^?`j=t1&Qx7sTcfkwUQaBRo`D zSPyvrO9H88^7?6CN z)4Y2_{0?VgcK*}wZu6DoV~I+|LRnOmY~FLG;RyB^Y%+Ez+$mI%B(3MJ>M}l?A2wN_ zA&U`HA`4=jS`|DVa4!@|ABuDjMKYz`p;nzCRVBn@i>yLoha#0ik~38(eAA+tiL^J%d4V4n z)3>3z=$L3NmY-)*yRDo(-CHT;3Vi7W9lc+JB~px+3t6Pai!%t{_$bL2>T350yR(7p zPKG8+$R69H7eqfX&SB8K;%}0WAVlXqVISH~JiOMgIQfvQl+HK8_rQ4*3%ok+!sPJ6 z)8KRf_NTloPLu!E!|$XJg&kzYiwQ9a{vo0q0^g=BpHSksohE2GO6-Wax)aC|5UQq6sHS_J zWI#5F5o46I%An=W`GryDqVU6=JtSe)=ZCv6sw}j}SXA%H1h|Baj9CMhu#E>XYu3mq zXyR9=VjpyN5On&BvF)-%IW!wxgUjMS`|Mx_tPlVhtPt$}e3%n;ckPa1eDf#N1-M~B zcLH}Tq9#w0<-O@#+ar3s(Gtl5E*M1{BDN7pb~)kH!g=gG zOakHT64+#>P`FVjk}mBHh3aEGHfcPz)hZ-;sA8(Dg&b2=G&{o`HU@mQG%T`ThNv7_ zRJ$yyM%DsbqX3_61dGfJ{GIj4_51zelbJ~gx5rp&sr-cL2Ditb)N=WbrV;&hBhhAj zsXb+bPt>#P+{e|l`tC5#e7nI%qNS^Z-ud=}cSM_4<2(R70MNRlJdx!+v;N_T&q3ge zQ}Oo{2LHw{&ZHF;yWh*3KH(=nIjh|L$;BZ$Blcsti1zdQ8Mh^hX05?|K(dT!Sh;eP-(WLAB&dF_^0jLHCzkhee-(Y+kx9wA?KDTr9FB zneO(pv^eEKG8-aZ<%SK<3Z)~-CUdq}@>ix;{%}tc9Ci0vz_EJwJlBdD&pS_2tuffH zn(dYiPcv;amo2n6sa@gFJ(>N8)2PR)_ePCM(8w(~DMB;t3iRU8XqPnArEK0RDpT6s zFHV-^E>VXBEX1pqMnTe=kJL-ukVY7Zsk_Ameqc$-TTfRX!*FNtVaX<`jg+FarwY41 zX2?5WwS!hpWgF;9DP%d4PVIv3#1P3@9knrR^9IR^f=z|2Yf^D^)HMikbAFQDoz7R@ zFtrn?YrNdD28h}Q-LC^f&q2g?LGU@&K@w8&Mc@t=jc0V!PQa7N-X^+$fvI1%S8i1QeBWh}CMC|o}jNt3pI zSM?a5Z5*47)-X?rX_y-^NulcS3QiNM_*E8_DT|U!^I=0gAXm*gnKVUZMeTBO)z$Sr z^)3}@TG1Ng7~b`8BwNy&;HW~w8UQ>1Ny}RLEL^*K>`AKt?(cfAC#`51u!sY28_F(M zJe=83FMy1n`jY5*t1=eG4U0yP83gNp!v<}{kS^?aA?05B6EXImuSvAvu0O(`TbP^x zX#z;f?{PmP8+X5P34E8DbVEhrzSL6msa!!mC8^JfJ~saaf63q=es|?t8u>7Trnu|h z@Z}8L!0@r`a5EC$#aVFVdi8Jc1UCq-kNSqE%JM$I1)79e#E=zI5WK;_b}hL+|LAC8 zqHF%q)qh9twM)!fqCLi}kbgu+BwDgJkrB9@Ou=0iBoY@v3!w}PhaoA`nC$Zj5S}C1 zUT?6(z|gvU^}q8QNlL&3#BeJ#X-@*OCFRrW&5Cje>#+uM17va&$RKOxyPveDg`C1p zoMm`$v5QeT0b&J0PW^!_hZM(3>;f`+RRPHS5W1rU_W6ptYqZgHXR^I#V3&&T$K@-j z5XL7ihfm2?xR4ijSw2fWJCwp)D8p56UxWNx4juV`moWS1gBtqaD`#92!Vbe{gW<7J zVUuYXTF5dTm@rlGAkO31r?JWCp>WDjBsI^09@8)r;+z{{_=7AJ3h#!(g+r0eQM;0; zS|f)t+|(aGeV-XQgyClY;PCxze@& zmZ0Bo^3x~$zSZ!x*V-Znt*Bm(90RE-Lng^zmhaGNP`#BJ9`!U^5_Ld)61 zv%Egq_cGb|jFh}g`@DNMX9w?P3a9!kI@1Q%ZMcH0`X1^w$Rmxg5`W}8@O+4|9}27t zP{Yk$joH4tHrR?U{PIS3!2Qh=oy`~E{)Us^^NT{(3F*Zsd7%>#Lf$xG^x*&H!XR@)b#U|&=ZhIUFbdMgR&2DXy;TVxJAH?Q%EsJ| z9U{0f`vq})4i|V%9(n=hK6d+?1KWok^9=s0Ia?{ z1g*gjX*XV9Hyjh?9h1;HPdv|8Lr2jF%#RA@% ztlHP$1uFrl0^TElp-*2@d21~(UZqv6Ft*_j4yS@+{O<}$gX;zAz^~Wqua_5QCdI{q z9iG$&9djQJRLJ;IUQikGyN!{=cSkU`#l>~A^Gn=sL>2n~HaQ8Sn<8Mxd$@j{N zg^0HL^vbfKTCo##U=Qv`=-eGgXXXC~qONBMhV||v=m-+K~lloH6 z^tINQ=c?Xp!O(B#&B9HQvr$6#4qRX=0h#m4wQBJ`JTb z7<(O*>AZEb^s2`BZ}1a`BYMe$s~h?G@@%4@jt<$Oo25nSbzt}h#Y7vhaWe9)?>W9> zee_)AK<5bTT@4s*#oDJlE55Ii<;;9YFKsu6d5*y-Ob*d7*FHI!^1i_MA{UV@BuvB@k3<*Jlo;Z@RDNNOCxO7$+YsXqdA-_6sZ!5ERwY# z&}gMnZM~}+K&~2aHgfItdMD?)KX$t2Z(Ffc%b(b}nwGX=`sRt)=}%W1^q1l_3MAj= z_sT@46ACK~+Dn{Wo||2t^sTiH2WhsOU9^hYY_%;6O!@Kqx!2EW%w>H&s}^k;fE`#j zOnsT#7V#g*oU-m}?X5qxsR;J(k+m^6Z`&=G6)D~ijQBoBQC0D5GL5CJx4t%kHhS9N z!G%+NU_?^I!hTUhQ_EblhSVS}zLj(B06XOU`n&oju=}aa_pb>JN;9g55Xi5g?^l5A z0C8_U5K*ZwxQZ4cVL56W7BK+(o<)4Wc8&s%=*I@>u%pB9})-a|vgEC09NVeTKYnZQ-O{4H4C z8Hok$O7fH=sz7Y#VS_`eYs*1s@OPsH{cUK)sq&sc*>8i5+f}Z>0jz)Oo|--U9h7z* z{)84Z^|vWe!Jq$ITkL##GRKhGF%$mDDt4}S;Cr` zJee9!qb`(&x$!B@g8a#Osx4^Ff7HF*&#m_kavu0~`J^}4z{WNO~E&A12j~fIx2LkC?no=7^zP28v&6Q97=(xaYw0NtnaufmC72}>-0reqQKfv7Z!bPo|{U|GTXr4N07)_`vP!om!SbH zIv_IWxi9l!QHygQfoI5XBgP*Q&9dvQhw@TP9y-3Cq)D60T7N_Cn4U1JRE*dtzCV<3 zQGWZd0@Fqj86S_bQ7kx=Uyh3g17zK806}e#^eYiP_gxKqncb9;5BU`bqZQFYVp%Jx zIIAw&L$Mj%rRU%6D+1^z2gd26-jm_4{ks<-9jI{bnkSH>z-O;tfNwLBp(8H2EE1-} zPb7x{>KFmb;ZJhGgNK1W-i4w}-uAFF9((1!hR9rfY`7`|+Zb^>XwqHEnFl?iXZGbPe$HaK{K9RcIRj50 z3TM4*&RR>@a)XHxubv+46nkwPYCvEw!o$gYz!Qkw-eQ>^;uWleF43)@+FPGDKY?~; z+PSDgSut7*7ac&ha?Q$l$vS_qfLeJuS07R8uVAHBh)a6s`h=3-TO{ z*?7bCb7$H6u*oP5QOU9vFeV)?gijp1^c^-1EHZ5@GJQh}O{N1~CLLZx@i($-C>#TQ z-JM6QQH#e0uEcp?$+cEB7UQMVBzP^`D6ta0Tx6|6qB7#u z&NC}gD@92PBdq&l#7e|!DGB)ued`@F8=qz-M0+M=w%gi5(9#2wzkH!z?Y`SUyxy>m znG1zvlTDW4&|+*Sb#e$Bl9)GOiIje$f%wO}!&YYfvyM*d+Ly8nSdSUk0%#l;#MbWa zu62uRmk=?mJ>eGc$oeBpFFk0>{f=(!ajJ5H{Rj1vk-;yFH!j1r@K$bhrGo@SqINWeT^a&lI#(rf|BvSR>5YAmT4|7 z#;H)#Xktk&3%ibW2_!cfs zGUMjIEZqGt#)>2NDed$ld_~xaLUyh_B=X|Z;E*Sz>)IzfH#1s@jKvuU+7uzfLB&Vz zpk{&0%muUQhn8p|VwR=rXKS1oor*G}7i!Q)QzTQ#-v7KBI4!Ti;W?U~S0RZ)72nFT zJY`CZXXnFbmH8#2|$1Z?V9WF(a!^^<4JbnuKx zCx#uQe$=u5yB#nDsUH??CLKOR@&0qio22rFOM)@^3@NR;XVUbajUNznZ&3A(xHd-| zUWm=gU$|vCeJTT$!5(a(^;NXK19b5%jfQ7~S0lVBvF#oNE7knh?0oIV>5bFJ z0C+6_5fJ);Ao%|a9$jp6=}zz2E=P!xt<0KVC2eYmYL2K)Z$?eCzPh#hER~e+?4o^J z+3m%$1tjp?bnAG+AH0&oB@g-1Yq4wujf@s&!GF>bkT%iM=b$Vt)o{kofh9L zlK{Jg!MrT$+g`-voI5O`3+C)(gOWO?S05>)1jad1^Eh*+RSMVP*&_KAQm!MbDS4b6 z)1WvGQs(6`>rIB>)l{|a_Ohv!dR4M_+D_%e+T5>PN*zbUkI~g}>I>2JbK3tam*al( zUtOKB|LW=#p!M41MVc z7qetOESEXlT!O@%0V^~Fe)FV48r!u<-s%I8!O3qQ$Y47)TBdfL3-7tQ>G?8*`DmWe z_XHb4b$Qf!Nd_|dER2_|73~zAD!|H*SvixtHYTH-rOi~`2mHn$`a6y4h%=HTTq60owL(0U10 z_RqnX2GK{titWJf8eC*tCmP%mcb)^uS-U_%X}kq6Nj#(s%Sh6#m#fx_(#^PVyd^Px}mCf)y? znxSx)P$UtL4l|;dLKS@*Xo3FOtdeEXWpX9Zup(e{!}{+sfhUm-p-QDXL#2u|hyR8C zZyiF>A;j(+)bhZXK4v({*W443={pWuQOTTh{`cn3)mKJGzmBu~r+auZ)5kf70Bq54 z0zf;ccB5?pIBEo?{J5SVa6jNw+85(F_cCxb7wrWeOLRjr&P9dsACYerX<=BSrl07CWPlPlK&U7( z!mzgZQ_J0{N%uhn)XowAb62+i{*>rDKJ4gMf7W%-rE=Q;E0u$RE`cLZjIN0j`CpNo zVsw$5d(Z!?ngeR@Wd5Z6uSm{||BC65eB_;gLre**1tIuvAy!R6SS}I}qjDk0AkIF| zgfFrQk{FB8<7NtV5eITEh6&z+QTdl>cOsSvOvFkv#)37Z+5|kqN!3|nGK9V9mP7Iu zS06KR#qVyXMre;)8{tB#Y|aWzq*#T5l@C+3i8JKh-DsYPOo%ehAh zc23G#HO*l^;zQUpaj*=sMQai(rrG0%lz=EtF&{|**_wIKA-W$A$b2DZ=uV8CtsuB@ zv{)=twHT>UIXgu9j$+!D0z?DTH_cugRTk3I2ck;j?5lyHzd*`}myOCjqiYyga&cIG z)nRo01XdA@NxEoW1B(Rd|8m(3V0Ivv4L-s&wz)Uc4YX1A8+9l4?yl1@7W!cEzXWog zHq&qdI|CkD9~SzDXzl+crzlhjU%?4O6+wQ$kVznn9feJX2Zif|A_;h0HJI+QBkqx_ zwj#+4u*oK{$ST0!D&$$nEc+ln+uR+tpH)ceP~-$?&w=(UxOga%GZg6`ij0!g-vAXr ze$;v!G06I+r(&ddU$BLo@_oU4vsWzwU<(b*05YKf*a75X|NcLOeFs!i(bFfrN(;Rc z2_T>p5kYzTv* z`<*?9nK$#Bxi>kvy!Y;%nRnmJqacD!D?hUPx{v$`{ysRw3tgc$zk<~Yr8 zx%j=Q+IR1bCMoDvsiwji_W4LwACY(Ru_r59pj^75S2`@VSNBoEv zm+<0B$TP$@jvsVIKN>pt9vYKGm@!24H$Vt@NuW}j(D1ja3Q|>EUdAd9AJN|&;UPYT zQDi?hA}}W3?Qf97n9-%`Jx|FVC$Mz@*4G4XwAk#&gY-_E8Xw7_QdPul1AGrleF-PI zF;XL^^iIkewD?}{gg;m9MS{{G**K@t7L{BF`jeZW*BXLgZt&4E1j+c$kK*=-HFSZy zJEW}~^b!KjVc`A)I-o~?hZp#f60m}g{zx+SlztF-7AgBxZP5jMiE#o;IyJ{BvhW=6 z%{S$-3Y=HET_kRf3=juE*|HzA5;ij>{%5eRjvFUgr%HJ;t41i~vOjiCF&stx3?Sb)He7np%?2ME66affgIMLN^H zC!mcZ%((jNxk*jQz;6RbYyZ`NmZSv=rjQx_CPoLje`q=_B zALT3uvA?9ib`G=$^uR_1YzF!gJ(eQ0j!7kHw&U^NbX*{I$S_Wnk zuCQ0+K+pt&5m~*~&js6VcpwBRGTUh#Cb#e5(;9swehVR8E?;qU<+;I!+gXb%LA7Js z>||~+TtuC|2;fxf=LWM#k$hE?B6;Y+=8d73U zYnlOPS0Vz|vAN2Rf&`vsGy{(dJ`#9p(uDpD?NJQ38JlZ9l;0w>G<;4CX{4Rgs@;DE zJAP((S|RohH~PGGj`Sq)+%I|>*$T2R1PVeCQZBW`ZF>;e zDze`M&3qBVYbZZf!Kp5KKc^O^j;+c}^~t=0yA)sQt!eE1$6cWt0~{(j8T6xT{OCC4%e}uHC^{z=sOo54d11&(ftNF*{RpjXg z6(MtWZX~rR?rukg#J^K^Ze5+nSLhr#dAj{v*yP{YZIr~+C2({<8IU+^Q#``jZ4lS; z(k+0UM0X`OQ#VZT9{_I`h=?1Hy9SN|*8xP$A2!I5zWfE9+cOJ91F)Jk65S+)y zdNH?2`>{>zN_=#U^x0j z1lD&@F}t8zOt;AH3h~Y<;#T53!Hg^_MiPWug@z@xhyvDuAK1xJ?`CJf84zhVC1yYy z$wMH20Uq6Xm`*l58V4fjMAWE+pjV6}`_>&&ItLJV4PXN+&$iAiN?Y(%K-_6ZVu>h# zj&31@U=N7IrfswiP@ZnxO$n9-yX)>Mm?L<#xduG!E?2iGOqLD#L@SF`dV3 zZ9)NTV6xiXX-aH+gQpwh*motANNWk{9QecU1+e81y4l6nG1EC@`mz1qk@&b^x6#%) ziJ);{l}?-AD!E>QS}*et{2FYx$2Lf1vvJMG@4 zlir8&i$93lXq*qwmhoB#&}8Sj^=eLTb}Q+~8fX40s09q7`myg__GVH(+VPZ>Ks;Yf>H-p;saKdC3Zm&{L&&dW zw%{e(BQ?Zqp;Sp`uLp`@5AV(@M7KH;iZ%JhH#puRhlUTA!H=}}R;39XbP>xU=fTTN zYoZOfriG=R6;6zr4fGJNr<)iT7+gqN4bG*EvL~L4%6^Wxxy9tPiz&cf&yO<9?Me@b zuMbGm^^f-03n?C@+&u5ZYw=67W;6E7R0*k$NRbtAvSFkqJ{uOp%Cn8{i}plk>G%ro9ffo^`=G7wm`2{Pup3~6mi ztrcDoD0L?+Q-&J_>^>kiea@-}p7jja9VI^AXKFF$7FztKu(vxO-4l zvfs?w%3Mt{*_(~Us$?;sELA1*fWzOaWF>G|u1Y4HSqm62<9?nyCW$fQ?gWPpGw$Nt zvE9gex$eoa-KP^{yBsyiG6BAc3uC((=G@!w#&+|~xq07>Nw%ADr|)wXUAq8QDQt%S!` za^}YK%k^bPrV7it-YkPBBY(Q~n6HWFE?v)0D7%xe{PXbu_H*#-`haRYRDp-407NPO z!%Sg9uRcNO{MMI%C%KNSdYH$4yMGjZh~@W=Y+yh8zP3`(FU#&?4B@yJW$!Mpv~=rt z@`Ra1UD@zdQxHd0RpW@WdsjiXtLXeKYYSA_?$lkmqzU7HkNw===Ef|X-!iv&Oi)^& z1C5oU#eR$K($dc|m8y0$aW&v#-89Pk6-rTmPkh8=GPk5ZOBM;A(uQl+^c zqp{7|=HGM5Uj#^@6J-qh?nt2{r3^*yNF7Kzl`75mkBST+tCsplIs8h;vDxM&dF2P# z?5S`k7ykV6djX}l4W-c0ugj}Oks(??MsE)w-D4jQML4+#g~hB3Y`JgQ<%YT0r>RngWxulJyaPXJ=Pr%b7ZVJQ@@yjWdZc^_2S+s( z3^U(lHz9wF4vr$N3LV!n^RnNcoUA_%SYuM;tD_pR-@Fy5V$a={G_pd70{g)07L&uk z2wwmcyTQKlCSR2f4#LzO?EB#uO#<;epjv{5%Q!4YPrnmb%k%#_;N$YslJaMnPe_>? zj#_t;)zgV8m{+*rwQyzmF)B_fw@lzkKF}GS#q2d;s_=TzfM*XD-Y>zEP`WMt}3Ja!IjsOkDYcf zy2{Um{JPl{>ZAtePFz%Jd+zdmCVHZsq5KemvnPPD2N|S(P z0cAxrN32lCOJo`^bjVN8w}TNQuf<+*cxH^P+ZsA zFlws&w!0}pYCLI2{?>fm6S7*;7rvt5gFB{R+Me9gMBJvUFm6TQw@?Slr&wS3f~Nxl zDnaSxK=Q#H`~J$dIKl8pP7qtZ9wtJXqjmsN*_ElgO2#Y8?}+=z>o5Ri}?v zhm2&PA_h%sWaq)6z(JJ5%9pdqzNDgPZdtx?U&_LN*vN~XS=@@5T3IN{MUn2Q5u{0k zy~v=X0y4imdViqD_NUsNXWoI=gggc){x1H=F3fGdBlO5Vzb@9852CHtURv#T6>h|fQ$8|2e}2CK8-x~Zy~R{@ ztX=5bl^I>cY{L_860CX6diPafK$kLYm*^z|C`N^HqSg!dJ4wnZH6K}n>u=$^{tEJ) z$Ja#mI0)RX01P|?1Ta^^T4|bBdt|3Z`g{+;YiS?cSHWsNmc!XnQMWij*Xt3jh=(epsmf|ht_1kj54pa*>D$*jQhN z3BC)tAUbP$H28#WW8GwO0x>!9t+K|@)TZo1tFA*x-oUAx`pP=;l=DMvO+6wwDI`zb zUhL492X!(z;Q_zi)*7mVmETxbgCx|}Bp|N0w~7hWAvf2bZmb*Y*qGScnAq8vIM__o zwpKq1vFbTJRa{@EtE^d%ZoPzusS_U_2thdOYI5Li-K|fcbCNlgk;v%WFjp8k701J@vyb|WN{iueI|#c2Ll0J zS%0TKIS~V>8&D~rB|yWmqpitzG>H3w-4r6u71`Pf$G%k`n#e>nKczT56+unVg0T_W z?FJ4xuo*d$kdPdVeC(T=?X-aHl%EqvbnQPs>*4fm|rHdv>BT-|h(1RBae;KLWi7O-jz8Yj;77PLDzS z=`Nq*=4!y^sIm$q6#@q3@fLJu#yUgZ@^a2-4Q{Is_u}opFm=%i$LAR^eJQ!-r=x#< zU9(O*-oA#ZO9>i(p~gfWbuE;kNcNhB7d#GIOW(x+jW2d#>N!lpnj~Un60sy&UTKSCcmpwu$pnC^q$YHbR-FSe^PdnL_; zmfGw~plRIeunwJGcYJo8)Ho9_J?)t;cLp!LeKi(zC^v6_7r_1;juW|k1TZHZ?1h?L&sD{gPd;IScAnEY!6I(SPTPS)YL9Ht3=NBa?+D zg3Q{F&!n1y?;g_viL(}#7U_KXD%~{VStYD)tv2+xPS02vQx}JDJo+h9!b+O20n;q_ z+4(f2FP;Iwp=z}0t1VD8Mc;0T zn{9r&y&FHaJ$@lA`tEGq`2LYh<(n-JbGzx;4;@Wk-J^ZOdZ^YftC`0y6hY%{i(&W^ zQeRvOMjN=kKm^+ZJ~*m*+ce0qreyN&OrdaVFS;p=e>5cG{*ece zghoRO&5tyV&#HW`|0=I~`85JJ{kZ(>X64z_$}_6Uv%AJeMwMF?X44b3O*M~vZH|h! z4k+D41Dgb|o1dy2Jn$e*$WTg~w8bA%C!N9JHQeoEq^8ad*hlmBqPMppzpHGA4aiY> z# z5Y3`?4tsmp@Ix9&$#gb%8c7s%w^a9mlP+)JTw4TeWa<5gaW-4CAso04J_K5D7wn^wdqy!|RnOY=<2UV-CZE{5dR zA{b5QULA-{Art`%k)C}fW5!3uJhB@m~qn=?JMCY3i<{AsOd!P>Rer=HiGX2{HD-#Ela z!TCq#6I%5O207OU9?}{;4&%YAOD4_-NdvEkERsfcOQda@?$A^@u2h<}j4^Q!nt|S^ zhg?zz%zs+^7}@;*%%7>M##btRTgIrk!#*wP$jB6qvE(s(X%svg*DB}FI1#OSHQzg9 z+|7SWZMCXsWinVJfGu#JVP1)UDbBON>k=@AAdSYsMo&Boz%w5_3&yjjcowKZ+K=WJ zv90FSC-h#3qsq$Be$e(uu83&qkbzmkKlxqw=OEzH!cS$p#Y@=cV+LYL6a-7d3<4m*FU8l5jIj)j-$D`C3VzS@JPrGQ zK+}av9#H&wSOzYmBu_p_!TS}6WPX7W=8eeNn1C&DfrBMvFLcq-pCub3qy18~iGb#r zo8%%lhe2EnQz5jgKLz3l7nUkXWH%VNQf?dbfGXy}@a%)6n9&r|gqYFPV;-6l6?;XF zf2kOW4F&8dc>C=R|Ef!@*hj9%|(bPwq+?bcWM${7i`1msCH^h7$Lu zi1(+cI`+~_Jv5bg7Qun=Ux^m81`CF%&h+5m9NDc?4jDj^b7j8Fg_2Y zF~KjpTZ(PrS5V6@BB6CKYgwuoOW5vDeTl`JD3jz!s0pIkkLk*1U2IaZZSOsZ2REtu zlk|Y)Yiv(5F+tj6V|v={Dfx;Hz4~10$ym0hOg$;ZZddGLFp}!|lIqxO=wLRKG7>7j z;&E3)-8$`IhI0n ztHghhEi#6w2&(A}my{}rVmJ7HMc5(crIflz7W7pl)M#JEs2C~@8!v+T!dkV}l~PP! z#010Y2x(3G0{8Y0S<@l*(FVFE))xOLH&dTxglhYMd^}wMN>suu+*nbXQ zVYiJ5wu`xPQ#~dN+8GJu-~FKH52euDddNug%RWY9vQ`oklLWo(cBRhFA&!Pq-7N|L z-SnJX;H9e`&mAaGtZtrHpu1@_$xxVeUAtry#`U&rOiDl}Sr0wLcl=o9=bT^Kt4s3S zJHco*o&>w_{|WB?MIlo#=mtCj@W#);m~C4;6Wm&>@1ex#KAJO_d3){0vMA@llGHb? zmJ{ShfE&62`~e>Nq4oI$`6+_Q&%z+*Ehb2ea zDWZ7YN23utzuzX^4ifTv{Z7B0rc%`fS~o^!EQ(H@d{Rt@_W5H+Or37v7iPZQ-fqLg zeSV`<6W?34_)kpt$M{IR7H!>nG%&7ccFl|^1Plg!Op1rlDgSTH^ zB@Mbyb(q4mVF#V$SF6>IwtYd_&chmcVMK8#1D#}4s}+j2)hln$pOKlYXWO`QB@(ROoT=My zr#gR(w$)B&bD(Zl(zCC;L2?Ej=W&NlX2P4@`qG2P{5;ZXhRr72q2FZS&5x-Ot(sW( z-^%bUZVTvVfA_9M>q4EGdgipCS!4JXF~I2DqZgGvFsGeO0)Pw<5?Gr z|JTV6Ky*7wxbgeKz7Kfg)vQ0k7Sf+R-hH5QgMRiv1UpfGy8)}CNVh=RQwn~1iMfe< zatFnn_2(|6LgMonY~1##E26SaG|*(6x`wFJkqv6n^{e?p=NK z%OVb8O|(J$S_xB2roq1IUHz7qMK28EIii?CAO@q?O5P#h`t>PAEKtoVwfLzhCN#D1 z+I_>($Dd{+m$&l1C$=+%)Rnv=Hylk6Uh8B!sAr<7WeTwq=6EQ~VK2<#D9mw^dNoA7 zf4~bW2IDGL&a9(^5b3=ewHr@7o@9ji+@_*gl@U8qtdv6j`CS!;^A zf~t5U+9;YV$j+#6*xJ;{`2)aTZvtreu;YCP8^GtEolT#L{gh&It#SMp+4A~Z!l8?x z_^*oWW;X!D1E2x`HF)3&0AgCwzU3U!HytZh_-=Zks}-xq(LG~Lhi*W$sB#@NUZ1M} zFZD%XkV{RB*g(FPM-YXT0%~v*>O%=@t&JTLNIU+?qjnUJfdRu!lrZS>0c2zY>az-S zoK#=AiRk|jJM<|1c#20YQ{BG0fx9CX0wZ2c&mV~yxW1yS`mj>z)?!4){nc;sp7IYS zmf;(w$P2Wd6NFarEefwj<;hC;5D9Wt(cFcn}_M@uuJEqvDP_`*0PHPpbYk zLp}dqZ$SRa=@G5S;CJo^`9`wu6e@h~KQsRO=3nI$a7Y3U=H$^%zO-%is?>u@vIK)` z+~H2VfM{rY>-7L>kuRF16aTCHMfhwS8?NB$tCEktvoZf`i;PL+8!dSjb_G_~M_4M` zggDFTwdYA$@-8UF57sATuhC!LF1Q$ zC=4a}U&d}&)1l{JP4OF7sgiIQmq9d)OHIwvRgoYfj!Yj;i)8 zbe--;5B8&ZmdwSyt6qE_AGGAOtqx(ZMPbD$=tW}1Dd2U*ic@G;wCOMjLkS+0 z#|Znlzs9ipxMyJQ__%{Wq>noYNBOveIL8>;$j5yYJ0;h2$ZhpoW#V?Wt3^LLZehiI z(aYz?nrxG!Y;raGi(=Q(h4-#Y3%5RS_4qc!@|Ba<1HkXjHL;r(uqs=6Swpt}(_luPwt8AI1hWs~~lI z!luIvjQ@%`6s7szwQFUnu?ubBQ+35k6#U>>uCMyhB1`7d-c?|1`YbCPs#{ns&s4%3 zz|{;akbE}50%^q@ERa^r!2)T;94wGl%)tU_#hev2y)xwl2^vCML(YcKp%C&16Rlja z(ffw9r)sCJJ^Ru7H7{1FSAXN#L^5E!+!L%nGr0hx<6+%eicO~W-|Os&{&JGK&@p|! z;x+#ybfeKzMmf~7F$WijXMTVc$cp{>p@(l!mR#Y#H_|iq)=V~R%4)y za+qaHj*V>f%nxxMlkKHUyjvgM?bX8f>2ndsKSec$vY}KVi%_5Kk6~O zBHPX|PSCwkO&A=!lUYU{c2V$A8=-AV%%)y{ib~D^9mspd9}C?Y8WFIOqsj{Sd(HOh zH~lt^55?gqCT&8-@?;{xpIoHK5_zhuUQZ^8)K%cn2OK^Y$D<~aMFgL6MGO7BL#0>Z z&6E#czNU~mpdWBGuXsm8PJ15Zlib0$$~6WYwqj8b?KX{ae%qpc1_}MGxU6@c8psDX zkq;!152TQz63Ee;$Wh9y06l>dLP>o+tTsl27*#;em;}2G6?~0wQ$;qUO~zz73)K-z z4kPSY?Td0SgK;PxNDu>K2<%Y)saPb=o+5bYG|Vp#G819`UqZSbo*5-OSJ zf?l?2*tQZ(gfNPD1VZizqwT-Tm6a|lA?{_Xift>xL_YsjBlh9UDahWp8){1$~ zeQArJi6G7)BL7H2t061Q73VYcf|yKbE_|3$*7hk8ZB||i@vv@h1e?%e+cTj>l}g5^ zbCH0G0HvjWkLc6A)gVtuUY!>3gHqNr55uY376H-$pyeOI-DF13IgpUa4JSTA1kaIh z1;DZVLUU~nkOEmk^87TmWdLc}H;?cJTL#QGuu`K&SKczx&oQGAx^3|fA%?m8zf9zG zeNTyzC3h&Z;QkQF-CVKkd6@0h5k#9tMtV5PO^A@OCXWQUC`*~u>kE-cT?WQkL>q<^ z7)W)A1ZU*RvjUb&?2CpFUEoFO@hE#CW<~*B5y88850EeKlxJP@H;~ky2Mz{Z8m~q| zgQ#6{M-Os?t{KEKJ$u1)63$fa#B|bo?PuGye<=NuRQ(dbv-L|f-@rwU+&B$N@-Va| za3PcxJoO1gnO`#yzM&bvpE~)1iJfnk+TdXd23pc9f#Qd!f}2#z)dg7Sn1i)W&uj@& z7oMH_^5z|ZM^DYaFDAbHW+t5OZ6LV(7AXeLwtRW>Gwk70v);w{ly64RbXPUOiD+af z>&?&5hffWA7c)}6Q8@Xo_|15fMYf;#OFe!XgM6I_O&=ae`Nrndzxd3l|6!#Rm=FRg z0+a^m1EBbNFdrPa6(_GQSd$*m$$s-$I>rLD0m(D3803t=ZobsRe@ZC2TxdESB6#si zb}$ml=+wJt2w4EoAUx4O2B`!h!Dcwe*U2`r)Y(WMmYXL(!S@t(f~F$|)CAM>qLHO| zgD=a??4|1uZ%%{FXkzM9z9F5Y)6E841ncw0kga~kQrQcYjyEUFemu+v-zgXvJ#xp8 zEV32Z!4;1G^7cZ{c;fxm0_S);aF-vKt2w32;K^lKW;+zrTBRM=Ww*m zjd3M^LRUu1wkcL;_p9}X0`-T?^|w)GIsKLQ?8ZxPx{hR_S0mBv`!Y^NXb?RtMuQ}b zs;f7mU)^;N5&V3dCFPiDh9+&N=?RPJ!Ugi`VE!I{=Yy<)z@krq@E$#@6Rl*YA(8xn!0J!&#S0`R zea-(~R0$5P34jV@kAukMbW8uwYW1yeef(c{C#1HxdkQ#t2pWh!} z_X7s(du;uvQM*&N=$v;)ev^EA|(#eW#_0Ekv4EHyayB#8}=~p2=Nt`+$6&LqI+oSEGRUFv+5#dT=xw+ zy?;vBar0KaUyPZ2UuB-d_<)ovYZf{o60Nf@BVLRag;f`!U18P5=vl-c6;~O=A2nBg z-Jt+KqROPJ!=6Ny(rM0egyXLfiAsdy-6;W=B&M7?*l0%EO-+b+-u=p!V4}(!^`=1w zB&I=|fGz+nkeCPU0a^!i251@35uj~AI1=--xhR)S_0t^QJOWqV^y5Vm)3cYDM$n@a zWI-<+J%~Wc5{|d>NitN0Ah4nhw^5jf4Y#8)4;pSKVSE~HKgS>&ZYN;e#-=8mH(z#5 zSvK6JtNNksIxFEyorU(uMsH`Kqx+|>I=V~Ne~dD-5HYpT3iJN2Q%~C5Lf!s`xrH`1 zsNs+pb>O_I4dEP`(u3VDLf?bkE=Gf-SrOVD_OTev4fA_H?pKH&3E~lYF@E?4-Qw)d zTmP}ZOunu1qN9@g;W(|NYiu@}zkf=h2FnhCemay5ZlYPwFqEwx?0rZ+~6KS^tRYtHEfsm!Z0<@AMAO6QWeCJD8h zk02VxgCSK(N~rN}(A{8xhO2{K5t>yO5IAP)z8|+%LX1QTd6CkhCUmh>To8|346`h# zLO_Xn!kfJzCUYqP+py|3=x#g0GD00&oZd(jixK)y1F!$^U7_|VPzaLypfV(4e89Xi z1U25O=9)e<_0Z9ss{ZyXvz+eAdrsqy60TC&XtVw)xAv)teVOGq=+CgFLi7o&Rn?Um z9-J`V3aSRGZd|QntGWTLW2(BrU&mEd#a9QZssb^Lswxn}sH)=MZE@KoZ}1zi-_&mS zNLY0dHGcc1YfcvWNB&UA%1?uRkHif(h zsxH*9eCF~RSP64^HS9HWc`dB0`PnOshw~;0D&2XL2{q!pc?DJf9_=k;r~IR_qP1Y@ zJ7tLSy#YKs!m~vLU|2l60PM#(!Y=UPe2IkOhX8{t9y2*%O_l6++Rg zsp#~M{oYgHws?m3UBLT31HQlH%5`68G?tI>r#@NRhri5`Q?&OqEYnT|THLpLc|4L| zfNdCWO9Zy3GXO&Q8kTB{H4;JvZ$vdNl0`Lc0Kx)Vi-~Gn0<;Ba9?&YFzkoIYEdW|C zg<$hJ@98!XLU~e-hiE8v<1jr>RN{nSO1{KunAGEb8VUwIthWrG1`$g9PdGhTIr|W+ zHD8tdMnjQ`w2$YczxoPu3z+3l>`&I7{(j%m=UzX3sGHDQs&(f{?T6c$7zG*A=Q$t) zBXiN3oJ{@4_VZ?&@_F5#+go$uyI?iZclc(Ntk3nnQnfPO@PQyg zIbM>LQR<9qlMs7Ur9_qW-iTY*4??a?>7MO2@iMyU;+S&05}4G0`T}ZWL5Z`|LEe~KMeP~ zf1}**Aj`B5o-HLmK>2IGR{#WSn4_&zD;e&vWB!#<9WY4hWw~O%_*9qEN%FKP zODSgs@o6Gm31)dS!mmT#`E?!u zr2w!10EqHZeq8`SH2{JDunmB)xkAD7!r0e4+B_~Dyg~0M!>|A-2Y@V{^!%X0XjBg# z+$t1QXji{?Oid>+kK#FVHx2x|`-M{PFg&U(K&8V_k#GJnPu7kP9Wo$>Tu@-io79%lfO!K`I@yDW-FTlwqLAjt(9G;wwBUJN6JAiTSs%47-j# zL}go)Nt$UiWsP7R8Kbq1J-6UFNU2H8AHYIK8S^!IXbd`(5tJS;Au}+6X0y8i2`_hy zh3DN3I)1;6@`qDy1A~A-vjzOv2|BN+))5pPbwo+I%?eTCgPkX&?5INL)zvy;qoZED zqvSo9^f((i))o0DF;5-T?TI`l5e$YfDs@4Wen6BuAxeD^r5=dVAf&Yo(%K7Y-RGY_ zmGbaU?(=HDpn7%rp8R^F4ZqhkG0UGYA+Jtw@JMU?OGv%Nukg6(4B`wjnUHkW-*Sk4y_hbS0XH}5=nmby~@5BnB?CICzWAS_6v8NN%x2Qv*%S)x>d zACd4q3?QPvsmMTKmQNAorbp7@sz;)hEJOsIV2nsvMyd23Y4A1gYVa`yyjKa(DGXSq zRJjIDM#k{_;A9jcCag7?0z$`}Kq8BlxLt$9Gs@!V zUwE_yn7e^65JXw5S(zzeZn*=|N=UF?bN2eC!+k?6sJys~S=_gwaqVp~%S7|&Ft#aE z&iv1gfp$U;-l-2bJ;6J1>O*k6vmet?fH!kufQ1dXl+w%(Fn{>nb!g`kjVBf5bQ^QI!m-xd&xKvh3TRa{pec zt-mu=S=XelN-7H*laAF8wp9Dl*mNL$&2dxs%Q!}?wif(9q zO7%G8V3fxkN70Tpyk(Fdx!S@Bn^7Pg3Hk(Y4xuVN+DZvHVnS|D^Y2~?XUznuU^@*Z zT8_Znrpz{Qn-Zm`*S>Gfg&Nca2apxOYhZeV*RG<(^&Uq}T6t^Q{}ldPZ*}uDYyul5 za#|2`Syqf=@5dpR!dUi{b53xtPYOEE2OnK7OXBvjaULW%>UVTAMYwJFbq!ns{JIuS z2Yy`>*9YD_2a~r!esVvQhQ%xPiFhhw;e8zq;TX}I2#fic{yFo5DvsChj1Y9HH7nm zd@aJw!gEz{LWo>794LfT#W^8z)p5&UZh~V)SgPSbp|UE@1YxO;n?RgbpMH(F6t_5^ zRm1V?KF6h&wd3B6tbConb?TvzbeW@Dj9VI63EO|Xue@~YH0<)$DQ^(R#aB=B1042o z6Q?}QrH$b3_s2UpP%a0WgK{}9j`ObwY}Lpd&K6&y%j7viE}EQPo(SjHP1xQP|w{=$sk z;6QOmAr4&V-r&%%@j~1-;ynCvBK=tZ;49&U4f=RC?oy@@_hTiDdhhG2liBWb52s6i z37mX3&Z-~xWC`cA6!u~+O#DEV_jFbdOK^&d#;ERx#bNCB!=fHd%-UXh+{As#!ZFOOgq6sg z^8QV_{HqF9(_;r*FEd-(aiTEm12SAq zYQQuwsABH{0}JXCFv!DZfMGi|V3zYdOa-eB3=F6jzyN0Q2gc_w_Q-G(H!gdEp5q1q zNCM2!GN%PANta*uwQx3)xb!UCw|*ScQkck|a?(kG*l~esNDAuGDK!hY2uhM)3<^ln zT?`8C(wy3;U@i7>+^Fc4Fa}iDN*E>T?MfIZ=3WUSMZH=HV@8dwgwdeT?{U5}wN+w& zU$$0N9{~1b4=}OahSsXBk;fqx(`$GphIoUO~&)Nhyzq zZ)^F4MV`mnp7Cwj7Y!JR|S`sm(d!=Fech!Rz}d=wRm6rb)=*fVoYv z)bQ>&wwRseL zUG#T_Kau606UWMG+EEBel;REp{o#ZEJI1%1Z)bGrTAcJkBXIpUDu$^Q@0+dTrEeHSKSRtm2LlaF8mJ8)7S6Lf&- z#~xu+3n=%Yx-IP1kUYcyFaDB=#~+zY|id^-$|zNI+XaD+!Uvk;!&oWIrl{(r1Je3kqo6LnOY= zV3X#TbTPIc&BeTXhSH}dDrOmij^^`ZF(Vv&7HZC2S6#+qG_xcLx8Wi#fSkEOvIa zxcc~gV$+&VI=c0^o*y=|(^bF?-OUic+@L`D2k$y*!<&~AltHwC*yj8f1BCYkJD8ku zjA0G)i^9nM2U-Zhdm(p7wOaTjhgsPU(l92iVLOtdUH;-949fLii-Q+5W3JfDnF^xJ zT2KE6W8WPVRnY894uXUg5G2Ervm^xs$t*!~&LAo|M+u99LcTjXQoj}iQ^ z!N!`G|%oD&E z6uNJxJa`p>Ip`*qe6nB+odyy8Ngg1gpLPB-_>gt~1QGoanINK{xDiD3L)^egH*^|A z^h>?USPQUR_~svTmmVS{!C~bBYmMH$6F|AfwaeD8Jr~&p=ryS4}j0P-Eroj7hC47izh9u zUsecPzw8jU2Fw9kjcN^80<;Ne0nmCBaPt+dWB~PCunOe>f?OH^FF!x=R%mBjI<$Qs z*9w~aev`0Oja6ifJRn)cu98-x{TWXKJ3pqbYY z^NBvFL)GS|KwWcX*xdSAc=;695;NF@&6vSvUeyDG9rkemGuYvRn8C)iR9E9aB5Y1g zHrXckDPgG|21ekhNA6eQn;x?Q4aS(AWI<-NGSy}ftlVXN=5~FD2Ia4jEM}W5W}jSV zlU!HB=4h`xJQ!Q>ni|H%hY&P^qRZ4|YuVOpm4^pn3$(crq3bEw{0Jc^nO!nz@`HHg z`(PnQgQAUDG=XBGmX+_zFFIRWK7tya?RrfHX0OI%9%jH2hlFMEcr_X zcyidEtiP+3?t{}wy+vA%!IOyIqG&AJ;8;?M-lAf_+{YtYOc8lZo2!Z4efpFPpsN@) zF<=e=LPgIKuYDIMb#KiMUKJzy_)iZVi^~Ejg@h*oQRV~O2(2vaZq7k0+dI$yZ*?(H zA730^uwY4g63hXc=^naH(5wnHak=vxYwCX$S%RzxHuDH2d^zzqvxT(DK0Z9e3K6=^ zSVXX^mA&?h_w``^sCnUUPa~|^(X)GI%fC#Uf{-p!gCujAQnQbdI8qzsGrYM8v-rSV z`B&-tLEhiy)We>Rz}%FqoOiWMz0Hu0(HNr^|5chcSUO^EII?$v)m~D|WEN6Td+*SQ zdG%BunE%Bq&sZy?X)|PJY{Wc#>sRUP!Szw|`rn?$n1|Du9sM$G3qra{ZDSrz_;Kv6 zl(UZen0B8ccCh{mQQf`5+TKH>uRbzNHfIg0&PTV+w0@bU1R*PZWD4_>+T%*{=-#ej z*`__^eEgo{!)=OZuzxrarie2P#0nr&QUIl4pk)lifq^hq;~3}&KuaxB5-vpcUq1^= zxDeYb_R#sdM9btS+}3?QTHWQL!bl&FVSE7d12C-T@lp~!S-O314xXo0vmTSi-_A^{ zo?oD&r5DT&ub$rrOhr~#_53;=1ARp{V9o*KhC%*e7z}a@m~9NQjbXC?ib`yT;s?Lk zmK^H*v91VV&JO=D)(4e+h%1WJhQ&*R3LK-$VJdLk11cdkO&LrD3-%>=W|X3)DGMZ! ziIW4`i9tp{FHQt8AD-Jh6OZfJ6usx>=l)Oa$HV+!{-592np}Ah!TbnG6DYn3)ZH|> z)m|C=)?Yz1HB5vL0WpFWmZ|ldB&XK0mDnij@le62k6_|7FyQ=}KtTdQ9~&lNg9Zgo zfvVI%`c$+sCWC+m1_F{ypx_wDKG`SfPmJ=gvYjLi3PdDTsRh9{zp|}yc|I0E2$?`R z(%9&_+30%N=(^bG9F*_(#y-uWMj0DHXHAkdtJpp~Rlff%_Gtz+ij)sw`bq6yvG%`e zaA%}N51T2QCtcjr$R0y}gg9iPTCF|hOSp0k@KI@CJ*M}P1{IQ(l3zR^Q3e@NJ~XjC zns$e#+z8;L=0>u`aO`yh0i;ApA+N>$0XDj$;Sw;Hjz4NoN8Uf*D-s#1$?cUH2)%c} ze*wVF{|&H9mXV25-d`;?}=VV2)1*+RkgWfR9J|H}?!#w3KlrskiAd#k42MSW8 zfCiJv5eEh5ar_8Uh-kH1joOc1HcTo94NNd5&;$xjhIHc!u;QS@deEIJwOiF{u~llZ zrpc*QY&;IidcCm)S=2CmK7_gv)UZsg)HHdtimik?wjkkr2@C}xWTRRQB(%Ym_ra>0 z9|6ustJTcZT)we6vR5<46};|w!jAwaqrfQT&uisj4LfEUm|P4lm;}`rdf4dLJWT`; zl>7*8jpTUSTnc6dx8Sx90FzY{2LA0qxlDG!5nI1TD9gs@uejT!@Z zGgv*T;xMU9^=j<|-Q$z3qUYn2U6_LJ`B8bj`_Q_%vnr-=#{)$iF2H7SBF|c49@}2K z-Pd{Fk$H>i-_B=T0DZtf5*UaCK>sjMBnA=zkU7RG4FkQyK;k+dI?PE7R(8g5$Ie=I z!q+d+Dj%i78tIA$XI=425Bw?R*Do!fCP{_er7jqprF%7cCT$O-=eP7$c7DkBUt0Q5 zbUF`sO@zo2%~8|i?KV>YqK(*XrUJwO2mWHyK7k8iCctiGN@}gG(gQI!cH{z4rXVR)Er*$Y=;1m^|8&GXGt%=mE{i~LVF>T<&PooNv2pr zl0Gl`%U6X%{rquZ3tiS$AZzC450mF>8(bK_D+|x98W)z5zr>-}a)pW5KBxtj@F))X z;nE=%$Fb*0b?c&PW=C|;@^eyzQ3wGHDf_y>F!z5`>jj*Os4d$nCiQ>Smggm>e{H}d zfnOryLZ%n^hi$jYaLzWf^Q&KY6eA9~G(MAGiOl9qdeo!uU#*sGnfI%5_dMWxyCjSA zEQ|9Li}S*Z^F)jD+>7(Hi}Mc_=Z(XoX1QhVeE$ma`IDkXpo1*rm|Zfa!IUp>84IW< zao`utg-FWwIR zT%Z)61T9uTcu!mxR;LEkitHM9&XSl0?k{cvTy4vJvns6CjV_#BH$0Ng7CL^c9U}lt z0bm;g&;!5<02csY0>DdJ0O$gs4*)rMe4Q`vmEi~pn&8jW+E54Vb@A@z5F$G@h>XHd z*i7)P?5b|HrUTFlAAq-v!qXXmHHQhdmD6r$rv^E}oC6;2?)}%;0j6C zcPC++MAxs$;poneq0wSO-3c(ODpcq8gxg$QXD4)AQj!yLFp;5THVZV!G-Mc(10L}1 zd1x-of9d&J2_f%*$z&x3JmA^$P+yp@>-kC-6IBZ#Ux6|WB4sQn0={tXdcfx9YdXJD zhesiC$S3faDoA851$R9Z=jK0meiaChqGVnF3Q;(oa&#fm^~c%jD&)cQa3sUFP>I44 zv{#@=Us0q;uV#%NAOREyNCVISAU;4yK+b?RSa}8ABYs6iQgkj0K-@vgoumLsJdeOc zu*!oE5JF{1u`OiT_B@H^_QC$LB33$xq9wt$=uJ75!Snd=cp;9W(*bNQ14X5p<}GXs z&Xo5MoG+RjA_7V`kk<(#ig>3_BZziUq?B_h)NGwx(n|254GL`8hpafkUO>4N36$iN za|fJCaBcZ$tlUPRm;oRUsqUOURbF?|1Eol9SoE#jVG3$l2UgzTeno>*gNWkY5>nlH zeQIDJ1q`%+!6Y#7^dr)iiwLYUMBYGN4GdH66cU}1u6MeahiN5T94x%q3hCuWsQ7+horjP~Em&&KX?4CID zqOb+3?&ly%Jz|JI#+xWN5OSA7HV!Jt8k`2R_&tA+oM6mxS_a!SO`kikd^Pk2FIS6L z)*KSdHb}f1Bg56nwVs8={PiKWY_B&?u;vQh>H8s|kS*%u`j@r)Bf`5|^v)phkmds} z8PWOw58xa$2_`=jl-tgl`q$SHdE?v=JP^3UQ2l%-q5NWHmGe5QeJ>}0IpD zcl%&=A%XWVOWO??EYJTwT&8afXpKefzqvDtZ2gFYmKp~JlKB}tk}KLN4&t~Hhpk(u z1iN)4XrM!wt7f5B44Vv}B-2Vxp59}84l~RyZ$EYp-g+_Mys{m*g)W*MDLfdH4R~$* z(7mbO{D^mFl{a$TFm{)>`ei$(aiyNRF^_(wUV?FxO_O?I&^z8{_4=T9_gedv2MpK2 z^NWzdI+c2f#!XL~)L#d^)Ao6%a>T0@@NOUtSyycASct4EF?Q@jT2xyU>bI%iwJbDm z>tFHS74YqPcxAr6;Ce+_28 z)3kXg70ZTc_a%SorjNO0I1|n4(~*8Zl2^!qtWZSmP>As6@?`4f3fXa4n=f~Bm>7DV znR~sS?oN2k;5|3ALVg)|2rnRGJ{`CTz3IGBkX-sCce8S{6}CSzBzLt1NNc=H?&iR{ zhV1%WsIl+*SMJSO$IZyB(&gGve%Se5SS|VWS;r~I1-MJZ=|cbUYN*hI_UE~u(yEZ3 zc2DhtMkW*+hhk4htgi!iXMZyWtPV|TEHPXjbX;+)fF@VRkJ}F*el5?ZC%t<+rXDWN zy3c0qSUla-PGgLn+(5scXwj}bu~`|_y*gsrwi57PacTllFU$>n8`=6f%zqv&@8#%U ze;wqxSv)R3PrQ2>w!?hd;dn@Xb=Gkf79n^8b3bzrtqX2O56JBtd(9-|`0q}DOHY!> zuLe2-{pLE(*R0))d;jD%@Ufr3mv=5J!!EP^SG5CZ&eLzUuETFWaLOMC9S8Njn^@|9 z2mbl%*_+oL)l=7^p~IE?*4F_gr@uNbH!cW63t=CC`dQ1>AXg(`s<-zC``|jRgZtVY zm&okq8&jdUj%&|%5x*=Ot{-Jr9Z<55-nWM_>lnZ2z5iy$H_zZyXskPoy8n5RABo(< z!xPB+(q6W=Ru3$-ZEi6>S96Ezhp?M|x_sh$QfLJRA7wbJ#?y51X1lu8q}Fy9*$1M$3iJAQfyLK6)%R07 zuxA?X4Mkt@EytV5EWQbPdlJyAbrc^YvzQj7f}7)M{wSYv=u3Xi@A&7_hC?rIM{3iG ze>&%iZ`QfG(;I~;`g8`gH6zw@g)n|pUgbN?PeA`#DHxZ&Jv-k2XSIM5iYG~q* z3}STPy`J2;u-Qfy8$qgnrT-LdE1XyXL!;=X+T>6~kkwi$Cq3KXjt1tBz3ZuMtrndLoI*MFVL#Wqv?q zH3QzG4ut1zIf-q%ziL`V01b@v4%4&!`xp%pqk&;G2u#C*|EO=@`NuZg?N=0{epFI; zDD3c$k6knSte;f>d3#Ow=!eQpVc&lnzVD?D-re~;7`*(ZMASOacKwHOD1eSQfhjK$ zFrC5RJKd>66p^-_rAh-4`ku5^rBG56!+Qi}yd;W7@1j_dyxmEL?`*%9mI^;L^rfvS zyVq<>RZ3e;`nibf$sibi83W#?)r?T8_hL zX197quX;w8dWOS0u|8hM9K&H8WP!2~Ors2uX39}srEcc%uCABYG23w1{}V!@I8mY+ z(EyyyDD_yp(r4*?l}QL z98w7cG|S#IAYj}SAh7Pa0pSs3CyMXgAq#^W59ilI^Vq2Q?xa(03t9+s6p7+OiifRQ z@I67&7*7n$58%bGk*s@xUxcBY5kMaY^qQo1-}Izo^t?cy(jCbv^3Z}2qTl_FRYV*x zBi&=HB98zgieW?nQ;T7w0b?D7RJ42YMGE@w4PdM=3_un#Jsi~diDq{eFIqgN6&Vv= z7Jv05_kA#>$T)bc6IJ_Ybl&CvDYAcG@Aj7`m_-E>%}N^^X@9t)tq7BU+;@BalFsJs zuhN)7r*ZR;yu~z+Ytkx-7h_LAHb=SH+85C*IovXOvyjiVL5D_`KGXDrx!B|61g@_@{`_f-G+P|mn31>G zgy|mtMZMZ{fwl5~YXJ{I_b$|C<-bigf{@fw*G!=MhLZYouCAMCf-k$dH(keAcRg*4 z|FE#$Uw|J!x|wahsT*-Kzwe?U4Sq-MGBIpYR!oKAkFM6#Iyr~B{*Tc*C-)Irmmn)? zk?~Q%wSXC}tQ{2Kw)iBlv8pm`uocDmnx_^vvL1X1xQ%+gHst{P!7PkxltAMy>X>?P$ z?5xpE?Z(vW_p(;5al}(>l)?U;ljBwFD;T`CQgNYA5-nMP&riU<@N8msjOdI)H zIp)Ka1B%3}i6WsrrV(tA2ErQ4PwyiX;&c=gVwAZ#K25WwCjYVxv|om9COnfWO0>*6l=;@%51jCN7X}ThnJ-OgWwAMj$=De@uz=qqC(w4yT2^tf$fYUw%+2WvOW$W8migGU79v458Mz-A6ntAHm zScsxbHt|nRhN>#tq3Oi_(#H5cAAaf$9k;CG>{GHsXHP8sQ7da9$p{vKY-}SqtrL2h zTk=rG>|@O>G%>!$E+sAWpaV(v>TNgB13KZUuRj-J42@PZ@;_lSMWYj+`kt@=;41(U z0q_V|oIn9E0szls1@xnlAOTs>cM~TUcwXDDr5^CS^o&bh$c7*+B?WY>C-zVr2wEv4 zrf7#3UYy7leyzc0z9(iW-X`c}Cod*sOR!ex*Y%3WDYG@rFN;u-W!tf0O(w-H5b#X? zMHf0`i*a>e%~dBPI6vPD}DE$-Ad z-m_j|nqqE(u6OdHN4EHBZQJ-xl%OojwwuMzM3F5_TH6l36Gf=2vTXvfGZJKrycUz4 zuVfjDy=+@WJTQI?{e?5k!S_Tfh06r3<+Puw<3Cci&1AmrmNtfFK(+)L`IEG6(*&Xg z_L|NA`8IZMxV@V|->Yo#wep`0M7!=bPc8X2a*SCjQ61?#r5d+P<)qK6+K$g~iS2F{ zb@uWBP&{XDVs~@g*b<4ZiT_>kGd-$tw1ZYVB+}R4Fr`x27Hv7PL=RMw;%7!c<*LC(`BmFFChOadpjQiETFYJ5a>{MwKa_8R2A8ijzoh)O zE+`T^h8_T5WgY;PtD7|v?0nN9Kp`GGW@!fg!8Nl=hpOg*(ItK(|GycR8Do|SoGUfW z10zepM*i+%P4F?axmIZ3dWB9(oC%uR*^3z2qNgQj?`vFv!iX(6I?rn99xny z^4}3Zd#&StRJNUCwq6lFhUPNze_OVl5r7syXm+0Wo#@}FcspS!UD-lr<)7WU-5h}S z1@FU;jSAJ$7OV1!a})HD^F9``MJPk&`?{N2N|Y)3?TdYGWQ&Lv({tYw!<3Hd=FDG9 zFe88c44I$nZYn9yP0>Fc_hpbR_*z!r-6%i}lx-V{1-=_YXK+@M_?~2sS!NY(OPHcv ztDED5ZP3d1Owh`7CTKd+vh8>RBY#d@q;zfgm}MQHc<>ho;v!p20FeOQ-P;Oi%lj`R z9SO=>_%edW;J*G^DV6k9+ioxRwQ$GL0$S~^oy{%zDF53g{wHE*?bsXJ;hb_}Ht0vg zOMRdbSH|Ti-1lw~YKYXte=p;58t$9^5hVnKn2gI|xbKO2N(!kHn#0{WA)znyeC@95 zdZ6$^=W)l~I>*)T*9MEJ?5;Soq z1yVSWLY*l-m(0?QI<70c7S^(S3Z%H(-D18m^6x(jiW7~DIuO`qG*7c=>KoHWIIgQ> zj2wUw5@Ylh7*&WDkBs^f^lT`SSjo{+R2~6V_00r9!h|+M5mE{bT zt_+o143&%wmDvoHmJq8J@dv@r0=9lHS-qK%)!Kd@!1!xPh_v!`hD^_{6nHyHEAumC zdUvHNP}X!-9gh2WUpERkwMAN=IPa5w-6-Q65@{WD-pBd6k;f?{(kke@e+wxRlr;dt zqFz3_owq@7YPHic?xxt1b-g_EnbX}-sA7OpWy^1*n9q4KMt7>evYPPv+qSo5^Y!6@ zT;WPHl<|^d87v7Wjsx85J|6ecNx!~sI2X!cJZ;JKJ3Pv6W1Ywfq~>(oO*XU zL#J7ZTchpvRK+0^+&ULIx*v~2@*i&zs)SFH0|RC{d?z_z5NbzNd^O6gBRvR=KIj(u zwR&1RanUAE3dO5!DY5Ha6ejClyi3-VMtv{Nc6mDbMC86x(LK8Qk@)z@H5HXDQuchm z&pE8HZ4I3wt5Y*rqSq8 z!I32|cD)vb6h5Hf)v5prXR)U7=+TOir4yBcV?wP8Nw#`9C$HC_IV^=9MxZH4k3AwV z8B?y( zosPr@+SH$%1j{e(y!MKZkw0sVk$*T2eSG!`c9HfsLZ`g|m99m!jCtpQ@EJ7$1p18N z5g-(z8i6345j+8eBGd^G2s18!Md*YUpb~+AdF3N?5=NGub4GZza>_}vrrr@dizNP( zRg4nS3Om%5`imF;?M2kJObYk*n<)B~U%05zW`PCcI%Z#@#! zNG+UtXHYwXdIQw6nRrfN!o;H$lEkCeFCfKe8%XgL3aZoc8xX2MujK;=@8U;?(k`FJ zXyxXp^)1eVpeW*!8ApXu{|~fdEf2@!yh~3!N>98>PdrOcyi3VFO2g(2n3oKg7Yvxw zie>X615$NEG*kNM#yXt#B~^}ywQ`;s2bCjg zEh`5D>yag2m7|@E)~@3A5|nxTI^{Fhgzk8Q#Nnr}|L)J5I=hQ$*ob}L5YG4sq0I+m z{{uohl*u97o53M$C9Gl7`Xa3}0L34^S7VB^NL`E9i+n~LKLLy?fzgaGiA}4cQCGkK zKYXvj6l;;O7OS`A8Fu{14;G#>Vhp!caLu6^u>aFMYeo$sGLv(c0k6@K=fj#l(1 zX}l>=yNKd0I)m;Q1jd`5;o0YKu4GcigYRo+yiG6?wey3hS3yhgK09rkcvDZj4gc8E zebHCd7Abr{p{`W{6iD%=-guib@R1dLwb#ZOCd^K|A%2!m*Y)QkDwmd)Uc3o~(~GC& zNYnL)?NzlQo_!`!uODSRBO9%>vanx_%_dFPsVSa)B2ljuWqb%5?S-;1S+vb1TeznV z)?yM~uPt#rJA_u1LpT9u!;s#2YKXHaNgRI%Ld(S=`~+r`mDc%y9qySH?s-zpF3tRy z$R&*AM%t>c`;}35+|-so(^>7*i!)XB>)VI7US>ZJE6=KF2v7N-k=qkfeiOW*_98gt#WHC$~)dxq}eCr zhX-~i4|f#Au8D<4<>n#M9J-H|)x|>pb~rW!|7E6hJ*%0ez!VIixCyRX9=MMCohEc4 zq-x&2+0j3v^0^SFLKW$7U?G%A|9cWo^$>fndRIDEFSPNBvHS|Xio1}K?Jt{r>2^VA zb`kYk{qF|Ja=t%7*OSYe%InD*P1*Cl;wEX`JWCP5dp7w$5lYVUzvP^BMy@+1-+o@3 z+x|fOJ5BfW+ct@Z{hhy=p^kz2mB-F0RBt~8(YX>E*6gO~=bc=A9RYhvKI5E!hdI4F zBusysQ5_{RUHEAY=2W22J6?fu5TrZr)QK1VOTiPrj)ArdN|zAnzwPOKGu};zD?#IP z_M3B+nh+u5>J_hd7nT*VNB*&e^v=i5`U8^%W|OV6l|Kmy5b-Za)M`%P&@ghfTPN@W z7jm^yP`Qz-tw-LwEbdmej%1A#MeR3=ReMM{&AZT86>Kh}_JrqyVoPuCd#ZcM%x?pf<7*~h`l`8} z1X=l44kOH>>^DJ9G`hv-t<+vK@EiUkcUF9%&v6VgH3n z>&s_WoWiwuWS$ zFm)dpW31=#?Q>B_6KCrA$Nbz^DAg)?S*}&mGW{7`W1QyOssxqQ*U4^baeD=opntnh zB8$qR#YWo={Bgyr8GHOgd)zMEK`D|QY8?8BucNq%tD`?H@MGxxyS~1DOyzEvWfTb6EnPK?;n%s`)NPxXX^YY z(Bw{6ZasNTG0EJp%zruMy0+q)8QP}%=y}5A;oDAo2^YK!=CzEM5ApM}gdZ{(2^C?< z7Cw-{S*jDL%@jE)FZ3D9`_+^vQ0PpbS@l`&`GD1kzfQdQsOT=vw_iLx9+VZ9d9=+~ z)(sAKW6@%BvdWW&_jWUcZODVR3$ZJ4+gVBfs5VaXrJ1{RsI9@(*0f@V<->qO*#APN~WYd`iH_{`{#$o_@6Fj|DA(Y5xXtaQz?ZS4{5jIq`Z)`vP6{WEfx;WvL3 zdG>i&_f@F^QPY$2bBX65OC)wdn+h*vBNe1TQ+TVuvYi)6x*ce_Y|dZ9@8hla#gy zo4go1Ma(8f*oG(Q$#1v=eBu^vMLxFqD15`Q^L96!dNq*!_7~#aXSSW{_jW(t^WeK} z!a-{CHmC77dutAK%;RzFmOI3Bh&b{G36pPZ(W76JGNAW5K)k zQh29TmI0C6b)EaSep9yi9mvG`^=+QFDD+|HR&BV`Wa9<*=g3cnXEkh8Qq=#XmfOBm zXgs@1GWh4nIC|29;Avy4!qXhzvYVWyN3Yz5Vu)8M?s!~w z*!YZRfgL=S(-(iT8h_HgD%lBCLmpsb73v;)N%n8<D-Vx=>j}{kfmrcPXVbKPR@oKCS*wmIx{F}xx}}uxz|I&A zLQ0Pm_7)pQ&JHruoQox6k~3eOT_iO-BU=Xy|ZAIqL1 z>0CV)IjLTO>ix@(tm)4g9#V%UJlJ3v9DM#@BrQ9eY-`PfveiP5w;iisAh~{Xb3D^3 zeBN;E@P3jrnFcZZb6e2JhH&erjAN`dZ#ALN!#K%w?7e?p`U@|07GA2&y?lS_rXQRL znYHgZ5#XEkjtf!}m-qiY1>bpaXk56B)6VtcQ1)zD-}@Z<>?r9n19BM@k<650t&Jte z(eW93h$vLRcUUEH-?>Tu#5odBq3Bel;gjIQp^}Swf~ub-7Y>A*9?ve8p&bduGR-I< z^0Q^?%Y9p@+!-|%1GGZ{{`HkL<^D$TKJxu#Y_hdyS0$c`I>MR*u7Rqgn!DdsS-&(P zvTXeDtUl**lI7GeJXr!^or&en*};qLPI)=09?EDOYM5d@^}TZIm9@06-tDvcJG+AW z&XRMFURl3HRo>~Zl-ft;Tsl!)PDUl)LsfEi{#=>}?IgtOFv~js+*zRLdW+w?R0}&@ z-}RPKwv)b1Hed`Mj(vVy4j2X>^^;YjBA-OrrPS@;QQ6&(nt$?4=TBw-Jy~V%329ll zru7WV`Xa~b5e9L|VcXWml~tV1#Sb3jDeLYYH~fQNblkI{)Drh3q{)}qBVFG*%aUMv z>j%q9xP|9BvdY05DXbm(iK}B(anW;g)Y$g4cUHW}f%;kdi>4uqNZ*fBOd^GCcIuUJ zJZ-Fnb=vBV9ZAFC(>#m?sbsq9QIV+$ETy{YYqWFbsl%R~cLrq!=NTVG`P&90dId1= zLYH^3=b{dtxv2m6lg~ic^45(xj_xJfv(1wC)s8&&qI$h)%{8J8Ki-Mj^H~J-8Z{rr zmwP!pcc~}IJQSQPAawCxqw~h{YyOuR!IYl%SZMOhBIxra99QsBrFl*9yOXAEZ@ywEb(c`ceND^RkHdh`mRT?kl7;k`;x!yIIn(uESuk~gc_q*f# zGP=GE$GJYbIl%-;IQ_V63wOHMWD36k_O#sescXmgN$i+J>)1KVV!bPJ|YVE{?2ljEaiqvfW&VM{jZ25P(I@IjbD^|PjCr$SdUK(**8ibfj#jFU zvaSz%i^SYNiW%!c*_ocPZ@A9Kn7V^PEc84d;G`{8>ltrQGA|#-tlUjvI54 zL)QHp;W>%@4#r)|{TuE%`&T!8sGC}KIWE+V{?d)({!RSSjVWnJ!sQNDaL#21)|Zsa z4x&@UT-e>SuqS7;TG!UBHxK(!mh)x@t6k%I7B|>>HwwyG!EsjaMh1%-`$E!_{kRo$Jcu)$7W=)$1vl<{Q7nxei=K=|{~sf}L)O z+Z}{e>6a(Kf4C-;IX_+?Yn-%l@k4HXyW>{h7Vx*c;cnEl)62bZb625?{o<`}A60`G z-pC9u3U4MeMNuXmK8O#B=(DX>4PfY%>0T7xMujqPa%Vp`kka7JE=(PyDy-F1Uom@X z$5>d)up6YlLeNQ_I`|eY85r>s5$#+1G*0!V0WxVAcxajI)Hx+{&eZSMDJ`=;rT*AS z=KLAV#;vv|(|Yx%(D`V6w1GNV(0R>UuhtSnxT{yX-dBY zqUY4zC@t7250i+3V0aT8l+#%3A<$Q$ow(3fnQ%fFo+3PIrOhyYE#ph1-(u<|ac3ip z*B@)vurXGb<>a3sDnyqf<)Oj-S8mMFyn)=MPt^@j?7AF@9Peb34fYN6P<0;>blQUg ze;llA&41+OQCpckd+GRLtI(mrSm*aWA+pJ~=GaX*qm`xdlTN|P3N}fbMB}SZmMm74 zr|0T|R!FG_{yI zPiSY7|_43c~@_*y)Z`6-|SoK%FNt*l=XSw3eu5v$`qti0AWdPpN_80k?kLr2R z@?UMwW1XxvsukaagEv&qnSmbgoE%`f zOD)67d)iPuC!P0dC-zo57td*X!5j?MMGnQ6qoR^o{uy`@sQDp8;X{b}hwOhp28(|< zUCa#xn{CxkN1+LF&*LZ`8=co2cNuSJO?3YHoKb;ecjB=1TKed-AZ@duJ|RV-kTdgi zk>t@=;g1mo}4*KyeWY}JCK848}mV5Y~Zx1%h zeF$sWl67oEb8R(TM4N%%eD_?fFe81q5|6qz|key*gmuJv^clp7$K%8Az>;cu>2-ON* z=X0JxZ@6^CgCYH_bOC1S&kuz3veLN+1rrWxsGds(Hz{~p;RnB6*uCZjb0^iZiI>FYyUCSfp~ zY8dB~g{Odem$jn`Nmsw^)NoPnbU-x8c(gH<-iO6}vXPn1g_V1VOz(nc?QgVXP<8JlN+m%w9&%PV!FHdDv1Z|B& z>dc#mdu<=ctGC=fCF9u=oO^k&zTRGpL@_kqQ*0zxvV274E6%aLo?MLV%d5yyYYyUF zb21wqi!VSHV$WaIJ$_1wkVwjZI7 zWgyl|+~$g4_*M9s+lx-5&K9%CP~RgcAf(lv8#?#vx~FZ zAjU#0M4c32Op_GRD8WMPZuuBu75JE9<{Zw_dK+6lfEo}z{=Dn0C7ItPq(m1JG`4OW zwnoXqNZ9)+llM~`w%b@!;?N)2wME;|A87bVlJE&m1&8f8c7-wGFHV8{k1bh|#ny~Y770UI4PYb({Xdxb0g$wRLq*1$GQqhO77g8C9HPaPkeP<4R1$r9SJ z?d7#L&T!7n2!P}mDXyKmhk10F0*irk-HQmDjL`R?5|--gzZyGs z3;C*Ai4XRYS!5ACs>BDn{lNuL+5Wj{vjuy`Ij;r#M{~D(RPpED_VjUfX2v9{cbenL zHV_=;SDWZCzBR-zMCmi8at<|o6bo!1EJW3^b8F>f0KtzS5>?B`t(k)!Ui!+L6+8Vtey?+TROVvlATvEd!!vU9fkEJ~g3+oSHCp z4~9jD+LQfZ%A0+U>-WY-)&h=Z%_b+eaaUXU3Wf?|RY?9jeTb=v!CVN}?@^_jtMBQP>Rj&W3+klDljV(0lohv@pk&4j)Pg<9LG`<{ zV#I0j8}F&;#4Wf#1+zHZAHv*%wZkcUj5VVXJc?6s2e@dm=hVy>(TOSx`7x(2p2;4I zGJ|k7!d%8%L~--rFq|&Njofx-))Oh20PpnfN~H=2&^R2eKaebq}|A=XgNk>n2*7XJ@X z1%_AYEa870c3!Yu6W^;k%m^?E(2^;O+kOMF5uD{CTQpe>{Xz?v)=SZizDVp&ix(3`C z%5iNDC#)UP=Sk&{{UVPRqt@_i`^c{^yB~-CrtkeKS;j1M zuPrOxf%?3(4JCnzI5Rhj`c~~xul#~#PT0UtMB0glnJkNAlk%_qu4yw_B{Nw~GdFgD z#@e*Rs$UN~e031(NzgG{SSdTg zKo!3vVL5I60nGPRK~N?*Ml1|UFAUN~tcN_cLlm52?J6SHspeFnV}0=4B!gU>9L|zK zEUSATb{bmP@_76m@TirQ(pQ0N!PflI6~0Azv?#!m@IBbfPX#K_!d8=({>j{5X}E@q z&G9ciWtbez|DZaCSRykrRXhPzwpVHSl=1`Ll3fJ)*dCHc-=UoUh{`)c5MA}kD0GM< z{&N4pUT{=TIegw6{qe}_sZ)s3#5q_%QCe=$W4p($nt`2&`+3eGBy;5eGK3@F-AZ}f z5aab%u-_qyJN=%nptN$+CSH1m1qc!H*_SrY5lzi{lgv zvLp*L6G1{#!E%HJ8LNI2OwbiR1rt=oV&-UKzzxn?@&aBSVt}W!=$%CdJd1MK%P4i= zp{^ifp2c9CR})Krp*~_j877zxZSsM6HSwbg&65V+sSVn1IzQVd@M$9DYdS<&RHRtA zC0U%C2ww50hd*q3D#4`5@`-u+Hk+>^<<*=U?XH@V+!uO$8JgeZ+u&VGTqX3s19w+F zeBB}q-^tkhGUg@<{9d4b7$9}U_zyMtI@%}UM3^mmQA@c;yXC(Vk8Qx+r&if1 zR1esBJRA+A=n~br7IoL2%J62ztFE0rdq|f!j0#A7(wpydpJ_Q|k2^PB6=qve{88AS zO3pBD{ni1_v9fRE88%^FbN4H_qOPT_ZD(?EMHTnl0w`(c0>ob4aLTvd279AD<~)08 zY%~4u8|!~eN&PFgSa5yRihkT`h&7|u>Wb6)ApnWj8W{lgAYucM1g&oarpN-KI?c5V z9Jq0PKcJ9n^#mIKW`0T2_`g+ndOf~hoPXF9q*VWaVLFxhJ&*@Kz zI1lN5N)^hLOFqK=T3fot!?KcRT6DN_^!cvMpOOqetR`BZ-WkrcdSlz@6h!>}-HUOa zEAci*a?+QEBgn@UsY>@%^H_V=) z!Ja1fLEU?e=Vzf~hK8t2dK%4$g_)72w_{+nui(#Tp2{MjP^&WIH6&K0;5hP79Paup z=$#wkCighfhs(ye+N!Vx`(^BNamkZE_?vu#?dcbBzB3G^end1It&y?5Gu_{$+>`AV z1o~U4McqYW8XvT3QMki8Il#cpQSBKK1PJUkka;}ZYhcM<{0C`SZL zDYUwB%#^-wbo%E?f6!pL;7Zt9&N}0VGo#$PMbH`*Fh%kuEdYsYq1`HuHKW=pj^hRi zaORxp%Gw53;{VuDc<%k3T$raCA;`NPcww?sMq>Oa(f!xqL-PFy>WYM@^BiH z!}Ed@%7^sf+s(lbeN?wWttO!_SPwNu7d^*-E&?Z zP5vIGSk18>a}PbhN7{HE1V^_cUwG4;E{&lIzcYHG0x-Cl84flsO%wbHg zY%#xH3vgtrY%kn5$S{o-Ors)7;mpNIxukH{e0&zdUasLXUjF1?{GTWCt z%g%YL#O<33eNigHqpcVm zu@%01(n0i#Y-yGF0rLu^GGF0FqF%PMkd?UWE=*5secs($Lv=q#jPmdm zi!)OF%nBvgQR~G$OX|ib=3KZk&O{VL;0F=O)VN)Dyw)iA4>h`_a8&=-3orK5NdMU; z$XC=xsy9zMJ=sR#pk=7z2VJ(!yTdH|K3-d69k8AqU^l1zYr`BY-aFefgR7!AycS0x7Ac?zc+xoTCAGlV~@Rn8)S_y}2E8|H00hZU+(sbvj_ z_%!BkJLBrLDo$+DE;FWzwY;yKIBmd$Sb>`O@`F?{*n_9zr!?3iWkEiS-$RSq-@NfR z&%w42!>6aBfHS{5tSxF6j>A4s#Bb){I&95rS5WZButj3zAMiu;Hx4qFOO!T)zqUc3A&byEm4R~4L@3y?@cPObI?UzznG zE<0HOwh}2uF=vf18{9-Tr&fxZVP(H~-`k4yu2Q_~Lo00jubJ^W&*kXIt_80!36$2( z4}VSeyucM+yzOj{QgZVI5_4!zy++0Zn?h>0C%~WW-!jO%_}ZH=Un*hse%me_{#Bp0 zN|m~bQ4}`%{ss$%k0Zc#8h_V<=Q_T`quywecluu1GD2dzLt0gjKIh3(ix)qmE9c0r zsWwRF-{gl1Jntjb>zn2{E#N?SC_+Vv*S^BRBcb4vGV&`y=40aUznS@*+24iBV@GbK zi7~bLISZ=gfme1-mhIW=BXQ&Jr-L4Nk2>=cd0;qnM7tPml>!l50%wu@5xw+?6l58& z{0!0lhSov-jaS4Yda6h15+ik%?U~FPPHhSwFuc#`e9uRF7)v`U zh2s_cz-Mbl4d)To2AxP;n(XZ9^=mjv7c6q$b6D#={v{O}Q8Ie6F^t<- zPD=Kgm)$h&)BC+w*lug8?W&6He9RA;e$O&0wi~y9>lZ;c8(TC#_;3??B-^gi?EzlC zJvsK4SgT`u+~&G3Mnae_L-t0&zaX=cqV-5yjGOS#ks zIGLJ2RV>fvy_c^e9TmfmVo6q4k;C6+G{C*0*Jm6%7Z- z%@+DlM{7l=#YHzC&&N$;9hs0G87t??DI2Y;zi}2z{ruNSgL)4mcl$lEESn=D=@9b} z>-4KVLxt<}l)^d2_qGJC^Hy$#aturXM+lG|EGn)4@=0V&+>zr%eQ=~&`z0$)mOzw2 z|0QAMNStGc<-6p_k*-T@8WTuV%*P`(YWG;jI?H#Fk(KS2FKI54dkg#Psd}gT>qj8_ ztgF7%PpNyC-Z>tXxwUE??y-sMW?poz9Pk*QE>!YghjjXJUfcI-%>P+lIdOhk5|R?t z|2b}OqSOzu0sCVzwO$%rNhxX}(&OwV$nNSn&@9*d6^TGC)<<3>`>ub4QoQaL zec9n0E&;*a@W&X8z&`f@Lo-`Wv!bYcm|5@zLsrMQaT~5=fv@Q+pZLe-@dgO(K-!v#yn`WPP?u@EhR6@$kczKijDEWU5G7lB6+ zC-Ph!4v@7fI+HEX*xJg@jjVWP@nm+jMcb&+wu+j3s`D6LucYJi>nlQyw~((}l(nIB zsc2)YbCc>;J;h@*#LWG(Fm zc+JFKbj_GYD!*<668dVK=DgAWRB~{Xv{LtQ1J))7YDq+S-Kumb$>uWAC48_1)tmNP zXSII0#8r_Rc_)%+e-}ujbEm~Kr1zCs#kynQB~hF47gimDfxPRV*=G|uNs;3@5NGe% zyZ}`-mT54}+yPy!jO=GKFQ~wPvsd^?3*Z5M3HdkYO6BEFff_{o8-eD_@}3AvagAT; zCJvCwWseVC3{|BmMC=RwvL}y22sR5g4IhbdUB3CvAyY}6FN5~-RX=Qe(1Q3xBYWa! za9nA^{8tU%*A#N~`Ayr~c!~@<(LJcwwVudbAV0!(w zvS&Nh0njqlbo5TvbqS<-(fDRGaf4S?e8~6_-nMP+xJXS~budlQi`=Q0sGHD~J$C2L z*2FuX%~yZpeX)nR7XtHBW~Sq|Pj3UVXkmdSw9x4?Y4-7!+p|(v>z%)$JGBerdw$dl zcWSvXHDcXJ zV`!wWJ-KJEmiuC(h%jeuq0X%2NoD3^3q5<4koESZ2f%EX4Oui9(0#z0vaY$vQBai5 zWc;MD=&-GjiGHZ&HmZ>BBCX_?{n4h(r88)+oaNwg@4`*xMik-2`GsSauvV%bKOsMa zzmQ;Dy&?X3CcO=Nb5QBYmx?Z?iK0 zs`l&(4ooIRmHwM9LQRl$Ae4iGgV#M1C5-#lmGV7j@(p#0U;i)~rddd+BwhFxXA@FG}Tm7hxvotri&4YLAz%>Y1FK|qRykLx=Zk)>?{C7bkPx70~}`CIIH zTXvg~`};a!`@X&3bnwIRO%%js*e!Cslf(A?d$zQ?ww5BX4P@L$oX&7pOJZk=>Bm|E z24eEI7?s9Ef%f&4JIoI$lkY@_FJbXV+21|%Df00QQ!!gZ9DkBldR>S@DV|-9$j1CL z`&m^yI7yLM%1^E1XRg`HCAjj!&^Vs-MQwgi=w7i1aCTey>+s-~I56i{p>ffDJ#vag zmToHdOwa3Y!dPxcmWj;nH$$I}`s)$df4S<6nZ!v$Dn=r-Q6?Ccd3ZC?*dXaw+^C>UrHg=r+rS2Ca?wN`} zThEt9$7RM}*UH)KskyB`iCR-quu_tlrQrL`W!k8Dt2Gdp{s^dd8Gazpc6C_nDi?8{)y;Qi8x zTKhG+b8!v(<-F5{t5kBg%^J5scl^KuMZLNnF&_D1+=0v)-_7y1Y2|+unmLg+I$~u3}ibZUHU)M!SG859OJ0~2a_1<0$o!Z)&ysUAqr(;3U`pi{%Oct?f zq-g9d>8|rS0g^xiVl^CnBjc>7bHFMf*Noe}%GcVWg?BCfE z2BpQHMuwyyAy;0bzdyN!PJMVVr;#$de&Ql?7H~!Epy=l_#A6pTY-L|dtXLWFPhfZ6 zLY4u^gA+|Kmi3Wdj0^tm$4b?N%Vp&Lp&tC5s#Cp`(`9qYO7Usxucr2wArUl!%{qtg zetXOCQzyhUdb;XY_)gbbk$V^w53Vrz4;h(6?j~tXH4~KSc|Vso5L=5*Tlw-0F^Xw7jG~w{9(^eTfn(I2IL=%iGdVUTe9iI zKOB*PG6kZhj@SX7Bd!T z{ceZg0Tz}yMt@+DV|NHE$Q z0znEu5MV(;v>C_@4K*`Iq0ONX5CCWf6cEH*2ND8-L!m%31RTzXMA)DoV3GU>Dhy1l zdxZC56w_f9C_(ZeLu=IyZx0kidj7gh#hZkX7CM%0rjYr0Q)^ngNCCijOz0Vqd0m$9 zDe>a~;O|o3Vaz2vHuh#j6)!QT19hPDK*yt`FCDsaU&ak9Ir5f5J%s#ksa9{Dx~X#q zO@l1y57V7}u9PK3GM7J74H-YAZ&}HIyBlzrSq5&Np(pruu(LpU@TGQNjCOLer@+>o zS3gRKey!*k!r`e`l8l2I zCKM=gHb?q3kb*DPe{};&UsxdC|F|1CJENTL9YPjiX@f%Y3Lp_cGz5$Q0uVr$AQS{a z!65(?3J%34)j+?{)(f{^j`Z%%d<4p-3_RI&XwMzV zm=VQw^nOoA;Kus|{;pHQ;m?y!+b@R8Jtw}XTBfs`N0pSe)^6g*GSk)?W^F**(j~$b zqS z9T5aXCH#Cj6b}&4<58hS>pd%!DVCy;d1L&Y6>mTUqF16)@JXTbWq)Y` zWa+;;fgr1z)MLyo(#L4u|I}C@hq6OCA#DEFK!66qf#yg70U#QNK!Xti0F2cUD2N#V z1cxF(2mzQG$_xrc3J3xLXmdCO3`CkCP#_=z3I{<^<^c46dcZxTd%1^qFIq7}Ru~VE zb>{G!VE)?mTn7DS{4LD?yD|jmnlH1>;v%ZRE(3Tu7Cf?CP}*w$SSiAeFX@1uZ)trg zG%&K2WCrvUCs<7Sg8Wq|@aS4V%9&f0P!cWm&f&u$KRTv{k@+E6$aAwdrb+3uHB^Lu zuL#v-U80_pUG!G;jBbR|ele_Snk*i_S@o(zbu=yRsM=~9)$JxP?Q}`{Se*mtedkHw z)sp42Dqjw!FwVmIk0Xw5B!LA;I?q;%8#=e<4v!5K-RUEi`xS%Zro~MR?>Ie9;bIt~ z)B9dB>^!5a1=eohw9Y@xBe#q#)?A-ZPtw!gtW<|+0ci#rHH8vha*r$PWtVgKRK5^gm_)*G?#Ndpo4%|7RQ!1fqZfFf;;&06_)NARt;$5MT}k2_P^t5E6Yq zCI|{5P$)D2B>+R31JO_j5NZYh0|X&(0VE0r`A;vn2X-&_An(OCMoq0#f}|(K_VezC zA& zMBO>I37^=9b5XbmsXAHD6u}glQe~F`*b^CdQFRu>U$1 zXlK;7#bIty0Y=;ZdwoH{&IRRUhp^$5aY8s_rU3v7f`Gz62s0qsOb`NtKoDk_0RV)V z7zE7BP)GrD7#wXbfC8JtG0Ox2fVlt^ioRcc2x0~Z5F9A*pXR^EaxeF|?giT4gdYeX z$9la=;K&(Q9HctKf_cGh9lqO^oJ+l@q-6fDJ8`4g8pf3;q^Dz<4hz}D8P9lyS;6W2 zU^~ofh!};->RTBOqaT=H+i-g`j70H*Z(ujJY+j4b>fdBOIH`Z-EJSIzu(cc)Nf_X% zvckAZ$>DwZnmE*(W`M=${xtX{kg(su{;R5?0ayXxM7}}rUv2QLSX(FqbDh-~{hzXd zD#As>(~cK~i76l&jD%rsEewP)1H=pohoc0bARrKg0AkiV&_GE+VIx9Y>)GYs=Ok2 zDPwqjVKfV7YJC0hj~;`#z{LMo{E~ldG+cYMS&=NrQlEM73#ioDjaU{(dfl5$hM%MKY%PS@MHXBB@hn$m zu+Lk9F$0U|slS4kr8W~Z6rY;>S99=@FL6r z0&o-vEeHZ20Vsd~7y=W7LCrx3Fa%@<1ES$(a1_`KVuk{s0U#6tiLn~U9Dy*00uh)+ zG7R>gfpL%MUhdJ|i`M@($|?aKaolJ5-aoO_xnTylNp=j^I567J%enF&&JToLw^E+o zzSacC@UsrR_E+pa#vc0`DIbbM^U;A`-fgZ$*j-n_S3G9#(9XBMj^jI-Kv(VY^ws<& z{BUX2kGGin4PNRprv!5p0ADY?N2asFRK_CR(2EQGTY8#vVb?8gl+)6+h1%NCJi*oM z*?7l1Hyg+M;_?TPW7q#zpqN32Lv0ALuzpEnVb%WkT1L~;0p-jK#Y{hfW*{Kuh6^Gf zm@t4r&7f#Z11N|I63h=AA|QxFV=^O5z5;+_(k7T72n`klBhBD20Q&#TQ1=M#n~Ip5dX`Xic%9!cLNEzmyKL2z3ZfPElqD;vNn7z~JM_cw3{-t#B&A~M9k*k04 zu!EM99y=3$n`ce}WmLz3`rn7B6v+t+HSvV&;8=Y4? zi7(OXRPDi4`V`(Ry`m4NcF1{Ib?X=!ym7g*7b8a>P3^K#WU7-TrMYpk+H@_lBHvA4 zXuR%HN4&yWvzWI(CB^e<&{bd4PHkF--h7JxVEZ~$n0Z5xqqKQ(Kd`=LhJR}Amc+LE zokjWUE1v~w$1;WXJ!|aHrWcg(Bm%9`C210OFrL;0jJf+Lbi6^5Go_-NgdyJ4wvkkN zp24xmIq9|3LtSU*r-^};((ElZ2^&ok@RW`o#Jb`1-$>-NBj~ZWhf8@IS>CZ9DQH=e;By4P)vbc?X}f6G~B85J`njkjM$T z8(3-90)y-85PyD@nG-r<_CP-=$`AjNASgWz@GzPJg~G`kzgF~mw~SpHREy>sDaUW1 z)l$<$ljqqL>u?vC0dB3lk{bir-F2T1%{(qbE;JMIJ-C_2Dq6Y{0#3vDbbj+Wi0Gb z?3rDiTPsaSIXI*h&G#b%04~17-giE`%I8s1+Ietrm&f(h#wbcaFnI$LxDCer)Ul#+dCRV0UZYm*|kloUaQtnLN3FDXyWDhc8?1|Ll$ z5IPr)fNp)EO$l?eFrG7l!QC$sLbXXU0q({c-@M9$d&Z0RByPYw=ec53+(VZ1`|UaQ z2hL-mC)?5leQetDdsJC%T`Sv{Vx(A$VMasK(`3(99g4kf@Jo#htoQ9wF31#g6XxD7g|XTy&?$_++wS6)pCYhi zA*jx5EkI<*r*k8J6{?(v3S1*oi*ODCBqw*1LSHq0fG_&ea?Pi@;paEl1zddm;Xba) zwu0Tb;;$rwUHLm#EMLl7naf9L=xcyGLjmPhOxPk`eGkp~ocuu=O|DMrfV!^rx3HRW z<*~^6k9<78^<&#B#;V=TCxL#NJP9`YSv05Ai-Q z45l^AbR;678tW0Ae?bbv6YQGKshZXweMvKg(`@kUo4h@gObUPdjE8B{J}+-?vek?@(;hE-2yN2(^)wrOL0FF- zpEvFbELv#6sbE+kZFKw)BE90vym?6*y;bw!>nzsplf}I@{hTFMQI4F<3%ncD&yy?3 z360b~wnLts>%r{p@L%#zrI)#4JzjlpXl*SqxgHM>%maGFcxdJtlS!8=z1g)0y2Z1) z?Z~#~9m)lI5J3HWMxk;xIU%NpIr1VLYa%8$vA0v1+6>6s4)5;5)Km7IK+&qF##HCM zrcL#%$-F+#hgj4vv2T7)lx;umru5>(w-4>Mel>h?gFQY@_0=iy^pQu5pH@U`h!>Ld z{JTZxVt}ZV_0(A?BxdZh=qNiMA=04oIbZJMt;1INW^QE55;%8!3jEb8@r=mB9Os*| zxY{k4=4O+0U@`pm#AE6_24YTK|KMipx@?c7+x#sFj!Ag;<}1F_n_|xLTWMjWrJ6r1 zT>X1zS;|KZ_t}ySGG_Pl<0;e-$9wsJw?U>V&Pv>CgC@SGU$noM3tLWxqYWD<=CUcO zA5=5jtWZ#Q=|}}1RmIg)mCE#VXW{S?U~7>V?U0AQc|OHOQYcZV9M;cD`&O~S z5a>FIH=v`M0d?M~jUL8JNJvVUyQ~wH{`G*12jq$T5KANZ=zAo)hBBH8wEA87xyCE> zQ&8%lCTJb`^1F0@cfT@6&C?X0{y%bP%9PuFdWj7Q(BoLa@L<UHHMt^~;;*o`TbYIXN!jX5 zSZ2oBjHsx=#>__xmdx^Fo6Z_<@2N?dLns&}I+%F%!^8ZPx^Rm-*K%N?UtICea#6+% z@m=K1Kc0*d_ajj+`!c<-r&YY}I!1&>${%k>CKUW&wdWqS=MP1PY3A0k=A0NrDcu0H z=hHd-iKMl=(zVuHbFNQ~c0RcXH`UH7c!{`xkIts^E1^TjGx6)-;B|1qx<GXdsEWG1-NI%bwvw-+ zEvTj~sG&VaLkCTve{wNT^WTVM)*9-l`5P1J-L`C-S(s=q_?9*5l{L(pzY^ZH^@h>m*6>a}@Y8x+v&$?TCd=ipEU(U^bvkob-88>Md4!^IF?IBXjd- zp6y?ohLG(ir-;+-P3fesAz)cbfNZpBBo8u@zMopJn_4g&_$3)Q-cGHTvzyWQbtIJ+ zR%|}nhgXorWb}t0We5dUq|)f{MwaH%=nPQn4AAKG0V<+VhUKP)u_b{H*4vN_#lt(0*Gn zI{C2xFUushGr01Za_)g+RK+^2Q6F@+8`_v@ed<#&_OxC$tw9#%5M{SUyU-22W}eP-N)Ov|%k z-@o~UTHgSs&!-LNzX1N;@UNKsH_U|{t5#vGVg{R!#B=_#e%`9ST3`frnLSsDT z-o5(wcFmCGi(4F>gE)oPGo(Z9`U#L0c4JtDzGAXZuUNs(9Ar&Jz)r~`Opy(X~}A=HKRKd&=#~(y}^0c{bI~RLy2{fp6_WwU+4Oe){%pf zOmj{#P+Mz}^h*4{X1m|q1OC)wGLTt}{&(h7a`IBVukR~FXmb<_jzpVdk}4pk_#;Gi8_XzIg9?iYT zM2Qk$SU>5Sq$0#K!P+?Pzy#8U$q07sfZ#-JO1FlX_t~5QcSPL(D#v=!9)vM8XOD2x zTd+8Osz`Q{e8~*bQhwgsRZGe`6-!HhlWZ1%&4Q=?x_QBtb7o16s&a- zR{wO8v#)hRbHYj1-0$K{tI2)oY_o?)QE;VkX}4aV&VO>Xc7{*#?dj*@GJpwCus~Vh zMXt&+U!R$3w?B|J6QF(H+B63>p}Q%dGov%z5waqv`5k zgW?4WqA~O<6pcinp$I_%6c7wE6TsL4j_EQ3V@Ns7XCxRXAYg`}gdi{!3J8Q^3VN8* z9s(eU00I7!ez=ElFZXcn#VC46u^T`h>^<>SqZ1IsE#I&BO7e-z+riV3U~w#4f(CggLc(UwYflbinQ+4HE00tPyZCO+L`2{L~xGx?~qCadYNs=od(70L_xDj5>`P!e9=kS_nz z_wnV&H31>&ri;L3Yo)_0RfmbV{E1s`rrwGXjMG(uQsrecxiGwvY zhyUL-4m!IaZ7j`r!F&)tAP_SS0`GZkb1)FY-NDV#Fqi-)#021G0s?3xhEKu};F!v% zAP7?chXWAs``Q=+BnSZ`0T46@X)gGmVRnz;UhdJ{%exGBQ;NQZ^EOeT?7%9?<=RI! z1KkvT{ZUOB-PL5m)$zNhj2sPN@pK$+HcM;0(+W0nnpzLBALQVDB$0pr@TF!LG3m?G zMel=_F4)M){@|aPnv1(&55JL{;Xmm!GnT@0sO7=FM!1^GNa~gDk7~D!x!!cmK3A{# z1X2w@@!w*xQx9r~W0Pjx6=o?E&f8b_S1sS1*=zc%Vr7UNR^2%0$6eQDVDm{(J)PfO zOrw1BuYhxuVp;5|i(^wBTdCh1w*ymq7vt8xn_5>J1NkLk`-G^S*1x^HyPGF>H>kS{ z)!QW<(K_J;;fdCqyA~yX%qDL;zkB^F@S8{38o9?u@IHEzU*XFue4H=+Zv9+KHHMFi zE5p2+sZe%-3G8PXeoJoCg68KwchV0xd;0SHaTRx}k4g1G;pc27jX!PeAQnb%%oo$B zPin$_9tyCI=W@?nsLwSlDeW!_3Z8px<|U0Yv{#26;^BHft7Y#jBp+m+Pkxf6?Ev`Q zDt#Fl(1oYQ+PO(iFXh4fOw4*YO8Qa;Hv;d5jjg?s6CMi?f7beTxk&mlAYeUcmF#yC zMHy>)>XWuSQ=;GL(wEHvU_2_ev#lb+C5;b)5FUjcM;P}Y!#;!M2=}Q}FeNF&wEv>~ z-g0YfV#%@^xy|!GbZ5(LrJ8r5dy=?*C8lJEiVr60nI?+6dCMadh6-@WuEK=P3Cj!a zO9I^KXR1m;ZJG`^zj=uK@|I(gH!8{JrGI^V;+MN@*ZFO%U8@yU;p9$oYy25Uoy+BW zWnn?1iP~=2vfoROPk_>>$5}etq9dnw*~^ZdV_}E3xN2moKM#j~3hbxtR47E>Ds(6X zf#(h%d&$u}k})kJ>uUP(`c>Co0nE$#s>RbDsX6(zdHc=sDqduct7O|5&^>KgZq2sa zUi)S8rXlqD>lul@l*g^gXG>|8xpRr8D|c5q@t}WH-%!W@B+hb7qt9C~-1DAWu$XpH zVo|ki%t&(YW8*ANi0F8JAW*E0`%9y3Oa=a}(r1NOzGCAO$3iPH#@B5c29wzme?Q%P zva&L(c9GlOq4x1J&;Xr5=MY^{nyDt*9`x$S- zZpgmaR9UOTQ+@hwRrL*eoZ9Uax8I&)5`i5cX~eq!nJPB7$~peV!TyrgV!V0X%TJP; zALMa- z+ss-J2k($nNg`X!L>Zso1m6&xVMX+O2q028_T|vEU!d4#IC=56{noz(^K3h_W)}@Y zx=gO*3to8%NK3;r^yvliq`kBt?@bWdWJSu(zQ zQ}93HXJML(b}jumcF_^)Cc6K*%Yg&BLhP;?(7)qfrjOj=c5Qyu&FwPI_~wZHg)xz5 zu3Mdmo@iRUajHtQ0O3;pp~y;xkQWUpe|#LL+$pZQYN}e$jWoyJK)4#V$tz#o6SGSy z;lHBX?R4K`ti_1$R@1*idbm5%zViniSuU5arEoFw%luXEH%Vy8cY-oK{dW}CW_3t$ zI-+*O=ZF8d5MH9qUTQ2muCyB#0j8qVx6}Q; zSl-ewZM--BA-|%q*J9vZv`Xwc&ZzFk8+1ZF-m}*tw?@*(uyS~HB{flNtK?!^#k+JR zJzYoG{MiT^*+ctVD6@EDK90nLW=ndUChTiJ``jn_G~R_cc>^BNOnC!ausd@sU)^az zK_G*BRs07gL_(CviuTE1`d|cS|<)RMCmic;aJofQN7Ps8ccmMt15tH!aymT3#oib>7yv)1LTkya@V%y&|IqFGG@*m_!TeB|dsT!&GwvVit2lh$i ziOY}hb2?^KUh~xA+LiVz2HL$1hLLDl)eYNOB%Wy3+cKw>>Ur0-+!DEc{PyU!R5e-y zv^3S{9`AkM-&Ip?I8!<^6y}^Jc3B{pR?71%_Ai~csC3e_T7H@KM@tp|daus6Ccu|y zs*)M{TIjLOb{WB!KvSK5x5pF058aHcWIr-IQnK&zcP*3%YeOkE=f!Wbb5aU-?sF{^ zxZ)djzNu+{{cOB8KYu3|&#)p;`eZQp@-6@veA2zg7h*q!WGAfkl_ z5u-ft3YPLwm-nm04!;nUGODon;U?aF%b?xw!3SdFU8#veTdXgf{OGPy3w-G7VlIiX zM5R-GyysuP$6-^Fvl5zhlvAoGV9}9ND10k4EV)V1>Uk)t)Oh4pnS8f%moRBd@{8?D zZt88%i(;nG}z%wcxc_Ebvs-NH5KAvMg3eV1!FSNe?dhCH>r5@%>CUrl zreg9YH$>-B>D+ygsP>O4IE~V=t#65&$X(dzsrB@x=9dxR%t~m3ZH)DGnc}$~y5XS< zsn5vU>!4$wfL3~-3VTcTHr1yCs^s;f!}8}XCEzpLgkt-&vAFp_oPE%Q+ul5EywNi^ zxIZr1Ku8wB!=C=Jd~RmAkqa*HcWN(P(V;UG0cguo07CP^qpDvd@?uL z`UrLXKA&)OT+=&^oWFuNb&DvuH)b;_oPJa7qA&QR(3D{B$E52fki$bGx9{IRC9Y!r zKs_zq$Rg^o;fuCpPQS75l@ZPKDSEO>YB0&W0Dm7tVIk`RHt?J1YCUd5rga~h`z_5U z&c5$937XGh)xJ=8YUcd)#ObRy)(C0n?sJiIWsu@^Y?(dRw<@`z%_`C$aVz~)y!#C| zrXeO@bjOv!U7pUfE@?&SWlz%h{`p4e#WUiDxrh=3k9Jtmje^|UYa^Mq0RfknTMx$L znmipROt}=%HM#NQUMVm}Ir!Ordr$R}&a2w}eTnlW(99B}wD>DnfU#V_vl$|87GXN1 z*7ptMmmIzaQ%GlZ3gl2CFa8(GfvLwdee4uS;Q*Tk)Z-=(nRBQX(nP7LFO915g~&_~ zw;z#tHEk^*Z@RJSuQeh?Rmp2Q$kd+hmrj?C#$0`+Qwco*p7Rr>MlUWODNkRPp<F z$6fgW(rv=DnB$7UK4o3XG4o0|KX{H3&TXU7dC=ftP!{SAN0Zf84Rt4KK%%2d*&kGq>u!aG&c zG~YyR&77uJ9-!5upRJ|4*Bx|hRupYbQI_E@LuoZ>F+XzXSr1XgwU*PW`MM)^A=YaZ zwDU>3W2b~8K-*Qb3of#jnk|&8RaG*4Oj`abiD~**=Us|+N68QCVr_%~78hU0P7day zy3H0Xbrr;2h0C7Gz|uFBullD}?0yMd(x6Zktz^ZCoFVf!GX@^mTRoPzc__U)#^i?iy`G)H%oPyFx3s1~1#fwyG><>uu8dg{Z(FV7sUIWy z!#shs89&eMa^u~y8prJxMZdK>Qo+m6a@h>Z2MHJ7+dmo^=}#?nd2pEaPagFO6<;)& zXMFAZiDM+9svl!(CWzwo?sl5hCsz0gu0n1Xm8j9X77FT0|7c12YeQf8AZvYDo5tCG zK8W_^rQq8TfY*?Z^1%xp+^>W6BNG3WJl6ecOot8NJV8>0bQdMyzt`-5=jUgKc)jx&!ZCkzgq8rwfXU?U%*ZM=W)u&3Ji}pthM}7M-GY^pg zL>w4qa&l(MZ}c|vT5EDScWP7D?cCSc(=P6xucx-rRgGn3318y*yI!9Uf(Q1ZHI3e= zd9AbLrXS-*am5#?1sZn=!+AM%;rf_79~B~|CU3_vkxu9AD1*wGq3|naZzTyjHhP*d`v(@f%M4piCQUvk zFL~zpQa53zS=;AL)YR)S+1_H)z3fMz1sw5B&Z0yB3zxFj!1%zb$FzB!v3H))GG)F?!~EjG z&&~42zd*4O{x0?DVeiZlCw2S2lfR7X+&&fhI&mH%XF+77yC@yvMULT zhW`%cgX(HcdxvU&ZU`!|NXOUCr@gLE8n>9NywlsL*7^B&M<&KQR}E5{b%x%c4a4m1 zWbG&(5h6UF+K+y`0D#sR?u@M-ar+F#^o{UTc!~gO>er9j2Ujvp9h6xs(1#6KyN=$9 zc_;s*yaVRTa$UDKMIMW|oH16HOH+w$dNVG?EIyvEjWr@_^r2kk%NZZ>-gs%|gk81L?_@<}ODuUx>gm4P4+aV(jr?ll{F-F1 zuMXNkB-9|aS)Wr6M!ndAsZbXa)6inUjWSaj%hOT_9;gg zfXgAiYKOo$c=EODio?}5*PmB@9jf@J1*kV>Rdqx*KMxOzZClE;>dgSk=UrT`oCSCzvLK>E6zbt>1>`bjT{-^i94$#DBpp*4Gn>Wj``=Wr^_>i+AF*D~Zq50{hv;;;T}7 zDSBRw&!lP#+9jC7o>0aP`?2pIeWwtX=3^7h+1Bqf+WnQ0u%36Db+*Ocx4=5-i~WvU zJ`FnLGOyfccaf&pc&ul#v|uAx#wJ`W8o^*=DrI8-Vr428ElO2)XCd&M?>X6TMYzq}pA)k%m3XpOu!G~Kj1DB|6~y==b*e?#vWnxGf-VDJpg&hcUY7OZ zynie`7&imyUq}*3CrIaB+!7poUNH`T8n!9pVXLcA%8~hxjo+TKRFd#mAWP(AfO%h6 z#`>eu(=J`8fvN~Ip?QK0k)sNAZ^ zQmag~Tc-=4T~175(Lx_x$p^N#opLi=PR4q$+?$S{WUUDARHA&9??}FXhwu@A| zRYH^qw!SZX>TX;4joDQqESd3bo55AQPfvth^-gP5nxt`#rmC91yMWEOk+I*r zNm_->-9xlX?JX`i-gkL*FH`37at-JscsssDW#2dsc{6)e{yzYwKv};Cd$jC{7Li{K zIj~q$_;Skf zcCi`3t`1(Az*BQ3ewa$Sj(OQ*Z@F*r*)akb6-3$XQ8}rq` zROW?I&pmgTVYrR9=XR=|=?%QdmAgk=CN>0hH|6)Xj`^byNy zSccP*DV8;`6w#6^mbI{y&@xag>o{7Vr9dp}+0CM*P%Il*327N6mW}*LX&Eb)O|aC{ zGF~j3VOdB^nOL^K(m=};v22B6?B zoK2$P+Skl}rilAA@6_|${zE$Ym6W$)$D{wt_R4Xu_-UxCY$i8Z;=4K)C+7K$SPm;t@d!0ko|448L#?g?r=4iJZ5L^)YZ&Lru$QA|CVJtkapN9j_8RClCff9>?_D(%KzbW=6Ua7d_Nn_R$8f#C(o1^)kA>|#If#D33cadW~!L~-}9QCDyCuoYj&!bhC{E}O{g>+6~kDQ z{s}RRGltWzc~i2FxR0oxO$MI%QqzNZ<(nsi$kJaAuwwjPgUK?2jBlO{CChm|kQ_$Q z!-xiGH$kyj2|^Z<4Gp?s^gNPi#OvlB84rh1w2psW))w2C)?k8ICfGZ9l2|6fl0i%5 z>w!94Mt`~ML|tzE8^too`nLrACtLqrHlI^q?M>@RiBSPdPg>5t9=H=K!HUQ-ze)8} zn}=MnOtbz2zZvkavi_yt4CJrcmRLp5ug2zbx};JI%Q#x*1S3rcE0KCE>Y1>Nmv$@| zdluLPvfkecM4Am&EUjsf)qzbUD-N=GU}aRP*V2`CGo%BhZU#1*@R?%D;Q-!*;(pS7onu^wx*P ze&4Q0&o?~f3(E3>UEMWGv3FH<`wj0p<-PwqFib#-l}xe!8{YfzUva;Z)9 z!5BZMs{R{hvwGBhiixd6h#P#U7i#*XqEGU%z6!K26Bzr3*;C%meIl(hMi-z{NvqyS z*d)IWw2E}5rgD}CM-(hbsV0e$H-gqklaWX_Sl^Y2bS-Et>DolP#?pG=6x{_>2kZrYW`1b+>jnUt;L%w5_jQgG$@(u08C+qi8 znmfl1wlN!JQ&SJM>C)Cs>KsY?f8CQ_8s14+4*M;mI(11-J<_H-dc?`8NA(}a_=lut zo9A*#?}X{LP5EBj_LPp;3d);`xj82pK4k-3k^rYofU7z{zT@W0=Akn-z_{--z*!rh z^!p5O&IBm?e%$-uoiG$+PlEwAeczjC?1>eVo=)1G7!LBuPQF8X?{VBcGK#v$CoAds z?|TD6ea6ToJ|R1K%{{FDp`P+T@NIlStSg=h(tl9nG)oMptT#E@+a>wm-)b8L#6#FA?*TYuzhW|2bwwx+DEO29l5@P-%$7(>q= z%V5tK`ux}nCkM8uT7+=rQuHDoWW;r17;Fsl#86-i3xDj}u8o>9#D+WnWBck}1WSE* z9bqVVh2&m>4YRrbu~)UWJMMPrFcsS3Ec}TKqKtf`7)BUFkr+lA!?>S#0S`Ft0dJwc ztr;1w`$><7Nl!cOY1CvjAZ*KVZmQ4ReOF|&77mHHLn0Iw`GsOu!W~+8-EprwJY;B$ zUnu4@RrjxDp6qv0o@=B|M{rR~+|> zAX2 ziMYcfl$Tj~naInHe6p7JRLxU_J&#FNK2PM6Z4K4({t>so$fsEOx}VA@uB~Y-$InGZ{0y`_bo2|j~7`{$l_iQ|?y?2pd^olQFA=k^7VIkXJ_xqYpoj+W74 znGLUcd8~+~4wm`y+!RZ_wUmoxE-VXZsr@-ODb8yvd!%KP;yfqy1$Um4_G2{`6_5Gg z3+3n2d7tfIBO|(53qhBV?$uX50xil2d7FAsTl4TctNC!0%y7B(#o$ZHFK9j*{-ecz z33wyleJjWw0#jv#^U3g^EU!nmg11VEXubyiYs7y! z_zLptny-fcYKI3XtOQ?4p8lrydu(>x%~Bq#K-ZEEcr(!78vJI;#7?pQ3T)F|s;*q_ zq&4teN8f@sy*(ncBkt@-;9)&PCp$jkju+R}HrXOgheq6?va4dPEz&V>dIQ($h+8eb z>p(Yj@)|C&tAT6Dn_g!H6UiX!+xj~zu->NH8?D0RFDlH&I2Ej^s;a-~^*nH*7dBe_ z1xtC%(DXND(Ftx_X{pmP` z55+0Pu4-DLR-SX*bJFY_j#G-=RdrG;Z#dZH0OgT5rPx*KwZD>8H(Tbjeihg78CuU*9T_`)CGGvZD)n|TKmEEu7w(ud$d4xI3Fs+)danOi@UI0Bux?A#l z5tfUz?Ekgw-iPI~ek>F@shx2u-@UscM|8X*2NrZnE??j zh`y)QH)wsWH0_@4D}@B4#H0D)mmiBkpDyHVjGj-Eqn+t_Q_+s7>scBsMHAv2{pn9X(EpN1;vZyiROo#9b+g z-Du-WOqs=X-dlm5%A&VYhO~3*~t46Bag9>FD6DF zYaD5F!jlT_P|Nr#p$12^AnQj%! zk#vft|5U#JWG}o1bSdflL|O~Ff^=mfoesK*bf>0&SLw=>&kWGRq*pZkNcn!m6}MSz z8%eYNkU(dHo+BNWNauiFCoR?VLzV7_vIh_}=XP#|()4f2_itQFs|W2*+L%b^@;4$~ zn@Hz@jw0Qu>0ed4e`UJ!K_`%&(ey9M_b(hdF94lLdif8sF%OmsTCOMR7h2lqZP~U5 zOD!##Zzt#%Svo-UOJP|+%iy;I?b5KfQ@X_(lK+=&P9xt=84`^(#^YtlW&%v2H5tRW zx4nnkSKY6s-Hx6;l3vWD8$`U?h?~T)#u!@P_PzOpKE+yix5%ack>U+ToFRsd#*qC- z&l{f%z!+_l^fz_pLmPZxIfwVns(9B^BueGbT2J^-!Y3Avb8Pios?d;#Ck;n`_}&G z{*ig#kCgY*HCKWifaM@9`R{n@|8f6^>K3aW_Kxp;)@~g8U>_RUh})KSaIC3nY?&Gh#at;V~eP#?GIYEhrC>gihjb@%J+%$>CA zI#Hjp>Ur<@?hdF=TlErApRwx3cYNEA`EIXOFBkPWt6rtmIEbEG8LfK5JHBlM+-k<7 zX2=e_pJplcm(*SQu5WSVe!WHEkUT`*^(`{pqt`(%k*yB;T}w88YlG}M z*k!U!I%mIEIrzQZK4Eeu_7~N-_+9To@RoWIyv2#g8;Em-ady4Sg9&bWsp>-x{>^?P2Wqs$9*sDN2=fauJ4q4+^;=j=6=2SuAFQVGk5p3cYOyvLfzY{d;K|3 zLanxha)XSk^w-FS{5kO3^^2L(8}{cw9nFZD-*t45WrAIo79*G*SG#YLO$oAQ%Vzx9 zt76y<(M`Y4LbM?0)@0q9gRH@_b-|Et8MOV+zP%Utjk0kC>347eJs2_vtS8yoV2SOE zn3BF8WCtwEcrUAE} zHtfCU4YqH0Z|B!Pu10nYsYcTFo4aQxpKm~}JMS5JjYAS7MF5&4!h^9M_@VpfI8pq5EToTI=6iR<;uD%!8 zg?r;Yy9<}khJNqcUAQ#lzHfKo(opce-GxiTsQ2wITpGr|Z+GF+Fy(#kce=;Dhr@Crino<47wseO&oI&f`o*gN`Sirs*Tf_YwN?kk?Yud73_~d>@uW zUg29tx=_>Z%D21y28N`&Qq%jD@BK`7416b(uKB?1GK;uR(04586w>t{#GST^>m3JL zLAvRKxYIVc89!!9d8?+6Dc{GKZV6~5=?+aFQoav~FRphg>26IQRK5@LddI_e8tFbw z)0A(T{icSbdr;H+l<$2^w-mlrq(?sR?t@zty(O)y*L2qqp3tru#$prTT1$HF1Fy}; zduQFf$H)w#DgWhrB@JzG8Qf-(RRtOMi;3U#|MC*-q!R4JX0{xjv!&Tdq zP6Zh^c`cHkBgnQ|cIhwnO-CB8{Uv2)YHU?<;K6|5g*gGBiOT+e_3alnCl<8I>xbdbGWq>dw=M8KI=YfPbL^nHu^)~ z$!X?f0%4Or^q#t}yRX}m2^8yKtlf4+CV%MN$yiv@Cl8EbfSDf#zSqQa2a3q%eCSOZ zPRF$2bonAq72ALE`5y*O7FhIQ;QKuKab80Gwh!&XeLF1NqZSWiyH0-ML;Lj~kIN&y zg7{(>#zVb|IQ@UT=(B)lIc&~&a=>)*eqp>Csm~B4mD%V@?j3N8G6KeBk-^ z(?pAjcWYb^T<@5Z0~(31X*>^!&vVRY5L<}*e&prN;#jN(d21yv{>Yxky_%K@AB6oWaXJvgdM( zr8UgqS%X)8WKZUn_GoRG!xs(S@R2>6d#~J(VGcOAU_bG$kL>B(;;=8w;YH(c0Lz7Tw&d>?HFM}_1&{%+6Z&ZK3JPSASD83{SWM>L)aJk{aB-EW!fp4I7DtZF&Z z%cFbxKRjLyUhQypgJ&cRB+m-tli@#@yf%!pcQJ%~ZWwQ}ydjJ?TfQ=kw^+U@jI&=f zjC^+(UvBx4e+14*IPs5^f;O>V`?h}nN0(%`5=($ichz6L%bcGYRsB0Gw#`Io)G*q) zf0!@*MBO|2wSrST+mH0hKfGrRW`oh4m<7+dWO@HI?-h4r?`<~Ne6oTdo5Nlh*@%C7 z{ac-%hRNw4W-HI*v4 zL+Z@OW+V_%BLM_n2-hQ|6`J-?u02>E7lR%rtxKd0pyx@KeQd@>5!E$8BW!bH7Qv7xNK_@^-Z7|q`g1!F7Y!et?ghSiEpU^Jm?Lkxl*7>rMW~{SDbhk99ECTUAfuQ(rF^ zz5GpV-tXo;^=aHa^?*K+L~^}W(+3ol0~Yypo?i6btZ6qzyK#+r3o9$>&QE=lwm9o| zD`$ep&S+1WL_#_50nH`7^r<;@HR8U^25>JQ0HilQ4Sa8DA6Nld*1vqqKsey^09YYe z>A!q$6oMVJ`I+@E-x(7;!gDgVLQq)lH6lQ);fFV-3|+H#zpY)J;Vz4b@dQIrf^N z^@^g9k9)J0ov3^-Vw`IY)fFr`_BE+rimo+OSFq&RSEYU_dfrf7iIQV4OC3_QPcJ7>iIQVq zkUFI3Fh%7lI|#l1L3VIZN?xk?eR9qdc)kpd&v15#f+{1(gCI`?XE_%`LA^;K8-i^6 zZPPjAEr#nY9=ChrfqL?FCIQQ1v1NSzQgF7H)Sq)OEu!gSsLu?(nMlD+&xd1xWi<8f z9hQ&h4DZ)lT;^Dp>`<3y4e#%{uv_0uQ+}um`t_~ydw4GMtjowymluu8IM0PW^%e4~ zg}R`tzKVRZ=fb}F8k(ktx}dYZj(mD=U8GsySq}RZ=lQguU_q#0DFizxI2J0nz-2M{ z)j|bVAUG<&TBzU>1m`Ho>JuioY$N1{3NG?lA%z_(xDLTp3Q9r+J=@_q(uM6$s36@6 zW`_!{A&K4;EDROggdmfG)=J{c(37%I5RzCQ&!ji5gS{YB6VnH);Np-{nc&KXnC zx35&80q_|`Q&!(FpWYA@Q82*s83@x@ng)mZWLUva&nF+IQkq7D`t-GeF`myL8>S@G zr=Jx}@O%ctR7TUJP@g_lFxB%ZfN2seJu)GAugGomtyoHcn23L z%MQ;nw2h8=z_Sc9mJ=owEDv?j3!Il2GCLEy_w@Pl=)!u2JGEg%rhkpQi#Gv$RW2zr zc)jJg-W!DT6W}Dko?Us%Ccw#n{Rp>Y2Fqm%U>4!t1Xux>OL#s3Rs!Y|-pC9Vgd9jj z*efdtr^0hEVO9c^BZ&z6X9Ww0XA+U+8%JTW*fc~fBrHsD<_(LukkG)OwZO3pFF->Pvb2l z%+3y`In$;&JUf_XpO{IrEIXLyHk)R3LYl2M&H8Lz^fE>?>*0%u7Z@xbR74TZLZ+4y zFE#jE%KKY(MZAG{nZe&w-rwZ!Ivd`N#4QHPNZG83FC$)I@GHvu6?)Hsca!{l38hsB zxSVijLTT0W(Li`Sp|rSKyjFg`gwo=)`g+2f*}ZY z&C%gR;~`sk@O6$|oS3FZWe8pg?@Z$A{(7pWv%;PEF7Z!HqczVs%SI98W)+|3bYAG? zW;IO1nVZGN;|}F<2a`L3++@?EIRtanIfr;f|KRPg#@-Iw`Uh`^wf1&6(m!}R?6Xz# zZ2#cx&}46iYYAztvuS4M1=Cz_(=5yjrn%pyIW8}lW`j+0QeN;D+-PsXSqX3xU>-|o zXvX|v^oLq;J}`q8wMmaZ-Ao-4-N=6HE)@sJ!Kq)shfeb0dzb+Ao!!~1{_1!Z(u_2 z2XGwWkb%Kx4maRR3p75#bFcNRNr3wR$IJbY5cYr#yEy^whv!6ko*Jm{B$*mFUxtv! z;AMm5>+Sf=#R2vc>7A3Ghz|l+$S`TH|rG^aBI6KMBPU39#Nj`6gj~U!I$?cnD-)-)B4|6?O<7*N3T7)Z6r{LX-c!0qR zliY<#_UQiJ#DxZzMclH8eUZE$alOIQBJQ+^eJ@<_CWGfj+_`dm!ZyAQOx(9XKU{`I z-Cgpd@|ymlnS$%r}M~f zUZ`ut#fW<`Vva6ZB9*aFJ0Fg?hovfCv{kvr;1x;kiX{8x2dTyqrpAEa!S z8@$DFx5$rv3Bj9*uNZ7kXt)C0N<3t^N!xK($d1ITz$=K04BnjNZcefvkdRfGIR>vw za@QrV-C4A?|G+ThUHucd!c(S-Xjx?;t*4aHE4yX`8QWNU2>Pp>J{h^|kaAyz(f&*GQ@NJz?4- zwQHowt2y_pm-VPOSPoh9e;^GVuaP=6?EUnA&kY?f<0~g3noq$UkalpCWTrP#J4RFH zDD5E!Xz(RIo0$a$%fWO0udXTFWUw5NW7|d>I46Sd?BG}Fi{Ph-3k=SPxEYbS zPsrgXH+W#g9T~pcgEqF4LF-{$CzLd?-}EyY>UL|B7qXrAAhY!VejUw{Mj-7 z(F3Lh=0(i<%T)vMGA?e{1%QVM^Tuk3<7VNx$onV{?*6gGrRG+ILuxq*>So5*-Qj^88)Y?j(PPTxAR^A6Qz zB=BzFq3-I6Fs@;@-pO0>4Lkh3C36{nvPc#{#EfkF{aZ-jRsRv}> zF4CV3n*!-}N-InJcMJ|yCt}Wsep$Ghm7~Pma+14h8}gd4{P8|yqizX*Gk`j_XDdDxxt9B!aKgKru#)GLjZtW=luPt*?~r!QBr zk`b>pzIYiQuo`d(;l9#fnI9_kb`bF6z7!qvOgxBUG)<>UZKWJf%ehh~rPYbmr2H>G zOf3?fL}{-HI>Wc6Cb_qrQ%JYk2ifkQbheok7M8oe37`{M2+R` z)oZx zYH1(h;RZjTydRLydc(Uf@u-RVm%%%R59uYw48jQsu+_q836M))S%k9_AlHj>2p1>7 zW(${3bbK$9KIpB$2-}$8*km1dC%^^^k0wNY%dm43{dXrO5m%#patSXdL|p^epD?>j zLwx>>M*{&C5Z090YCWnAm*2{y!mfvC6h(DqPRi$F8&cx-b6Ai0Wwt&`rC+QZ7DZyM z$-Zsz8&iA_xpDXVdUtp!e-lzGVQQPo>`DlFBsg9c8xTEpQ89IpIFzE1PBc z8AP7QMtt00dHbHT@9dcqpDDAworvn~pdCBO&zcioEsO6NpvSVEZ6I-adHkc9N4KUJ z@7@N>`_P;(2d*N{EVsql_YU50;aC``X!v3<6wGa|)igB=WKi@^?r zvxfUs^SM98Z)&>VyMmvo3;Y@1=sv_=EU#d2x&C2rpg-OkI1IQ>Zg2xXc0cAD9Y;BI zBpg$2_i04jA8;r9F}BNcgBv{FamPEXFUNro5H}bs-!Dgp?Iefba;ui>QRp=6P@X1V zVK@cBc?#Ak!K1#fiQ@rv+QXfE_FHyE)|;ecmp88C3`fX}w9Q;34i?6pb{6mo;ofq) zIEpvz@h0Ut;A_N(jW50whb*24yh(UgLA=UyA%&BTo%Hv*r#NX}-W9oMBFiVdk*kXw zI(KKioMdZOCM|s?$t`}#-r|{)oRpsT#7Z3B9XsCfeMlp>dzZ&jFUM6C)f1Ag>#Lq& zjTRSlSM};jh*+@^U{}N07_e*MY&_WYa8?F(Bb-%$-3({bz|z}?vN_$mbWiQ=VgbwBoM@9)m6fT?GkX*Eo9<2pU-VakY8Z9;mP|95(u z;g%KWwjHJualv=MlpUwq15-|%X+KQ4ai&8s^^Y?hhAA)3RMXwNcaP%UJt~LH4``1$ z1lHudNxJIG%V59*flm_`OtKpUaGD32hJ4`j#3Lr@dk#k*<9;0kc#*Jjl5RKjCoyp9 z#$X7pQZU^FUmtPT%kn}2@J-?d<6i{-B8M|4Lm=q=88%EtPz6DioPIeJf(#1QnFQoL z8MGL~fOCkq82o|4AF$6<2t1H@kHL>C{5ao{7!F)Od}xy0>45`B(4!auTu6Mv;C+s} zPd+>|61a%C&t$td4ln7zdlYanakjzoq`)Y3G;k^LK!XQI+`;nAgd*T_;t`W|!L*`a zTAj2g(e4f#w#>9Yfno5_%_W}J&AnFhygkdrE= z0IwtNIYr}NEB9ZsXITNfkvPNP5fOKUlw&3ER^q`^bn5zp23V4x3c*hKp{E4jF2ZF_ zgJ2&8#YV8vQNzxS4!=`h1;J4YCYv1Wa@<|AZcz<2+KZgX)$!Xg)5S*c4o)MhI ze&@4t^g}HKXDL`{1g8<+vvLb5jxIG1=!Mf^4cuJi#9Chk?KaZ|)? zimB= zu!7~Gf(2HvI#j^jwBsmPA1dJH zTdg2}s=gU7q8u(__#*Qj734!q0Lx>sr6`6<3Pu{i4G3;X^VtBwRH>5T0>>m#6)I@9 zg6W}x7Au$?D&S006$Nuc1)QgwLhY%|#7YJ|I` zy>EnD>vXv<^&@Ri|GwZK8Yw~gyMs-v;07&#`!&(yF5P1R9$)EHy9fjFW9&CoG+mIx;*_;8%(agGHa@H%WiOYIE1 zdW0rKIRRnM2iaCl3*O~b)0~uoXl$K-Ds_FbZz{FlgJw!I>fXiv#7P9qWWbpwway+q z#m)xd+-Y(To^CIPtD^<+GH|B7-g2_avz#@SwVvC#_HqI{_7G3=FR-D5dmgYqlNns) zZH%+YlvGL3izes<&vMCF%00KsHkm0^I+-WbkdA)lvznZ6&CorK&nO!#=&*NjcXs*5?*qwHSDduUgE1P+9W* zmgTVYhvkQQvIv%3zhxaPIeyDVShD?=t*{L8Tb4ek8r8wKs7CcA@4Dp6}garT`0#Pz`KbTR_jOL1RU@+LB?W3A=pPjW3}Cfjqiiw-Wdjbka&5u-O(I% zU*=G;5cml3S`)q!;VYfAuVY38f)f;MHvXlKPJIOMDdL^gzFAh!dnE7~;{7IkKRm$u zIb3}k1;IHAju`)6DgG;t#K!<%AUegcNnU*+sGuOv^O*!wZ-yCUQh8Uoyensy0rw#uQWO8~Br3`j;J(C#2LDFk-*9%Q z0=OUX=o;U|TRTj=VO}j0f@#c9vGJFWk^ol%XV8CQP5gVG2tO4#lXyx^{98q6>Zbu` z5m(gM*9W8Sw>W^R0?sC$S`+{JAZkH1a1L>Gjc-mcia9~FV|fq^pkQ&0Z#APGRx>d0 zsDWT01+md6nvHu#Uq`;Sa} z7QBmzj~V=9<^5xNb7^@z@hO9Uq`ZH`+j2I%ONq~#OB;@BA1>1~b08>UZMk3sg}C-Y zX&CxJP$_qw5%fWXJ`$l0f(cCGni1%a!1Coy2qsc+v&PQkM%@qSKNq-+xL2*l?<@EB znf^TBa^k)Qzp1?6r1yM2i-|q{YJEonM6rH$i#~c6aKcF{Xx0P!jYw-7#_xJhRFJY5PTv99d%2JrBq#I(cHXFr}EoTcg zSiz*);NdHnvuxzkTN;4cgf3id!bXwlwFw(VX4fWc6q#Ea+$gfJ)=4?kS&gNAkNw^L zh4mQ^Da%fO|8UMjUdIPB%cvVmy>IAZ&u*>m72!Jwmv^S3rgR55s27+BKZ2RAMZ_zN|77@k3zJRoZ=hhE@h>;)md(J+h_@L0jwxl=3V4kP3QEtEK zdVVDX)6W_9?BmUH2Tc$5TQEpiiR8CZP!=lSfe||?s0#kBd~uwDMkA;|3KeqP>S}iW zC|GV%P~ES#=zg{H_OhNO-)#6$1Q;sQRM^dWiF~(7!17pZ4HCFa!GY<%=M2Ahjfe1B zD>!Hb3m{nF@R#Jw@ihvLh6>gp!gUHx8^K}-7E9Z;9)cSb^qUbTSjl~}4|ClvRIq~k z7AeRJ6)d-cK{ISe03X)tqGzxB5*LQx4eWXm7taXxSULO6?h^$|LIqqn=ubg&sDKL( z11VS)Dwtsf8)n4!O;hy*_aNdeA$SuqIGA{s!GBix&s;a%3|v5bCIoZFR)Sv$!88%j@QJ_6VsMA$SW13B&~k%O{o) zd@H+;#1lergAG0@1UFk;8G>6Zt`5PR!Y(475rR2^J%)HrSk5h;H&Z_fUQqZ2g=O-3 z9O)wCtf`94o-6S(gTJWo7x|dk4qQUKJp^-3xs-T!2uaa6dD=e9}4bSmhX7cS~ng)crJZSjP zSvnh|!ACnBg3XASYz*^a?n2Jy(KI&HXB*nB`4p7S3ijH{XE`bN#CG43u<227g~FFR zs;-UciQS(%1skSbirYxzn_ph?u#(jLpOQvM_T2J+Ntz!v8^@#WPuPI&#dWUaWlWzH z-yg=B(mr-2i0Ar?A&O!^hj~BeU?^B?1fx(+qomJw0D{BPz8Qgj5+lMv2+m6jH!Juc z#I}_~oOGbz`Yhj>qfwkWiU@}x=*gqB2Fwl~QicdeI0Bcmwu~SL5ppELQ3(1|FlDx{ z6W@MCEhbT z{yB$1*C`J8h>sd9A9^*z?a9QK4YprvJ$|S-i2)0WZ)CC78h;SK#y%ZE0K`|l}ON1+|$rO~< z+2wVj>qS%Io2)!W~NkN7YWFSI@MCb#-SPBLif&RcW zBJ_ozl!D>)!J~fp2}1}bQ82z<7qon;8U7i!nm0oy)!VrVd~cZ_G6b%qca6dF;bSxP zA#aDyu8*H6gm)IatLeR{K7QK9Onk^2qRZ;zr)}V!4euHBUTrR|8rNPe*Pa7GJq4TV zeRF~BFc*kAkPE>g3Jw^-6hxRJ5&A>0On%~e-w3=NhS-RZ2f+#ouGR-fW{5BVf^`)1 zo*NvQ@w|5kwoow82*x79Sc#Ai!7d64jbIESjFAX~AUHrl>DOZ? z<1rPrhG zeS^W(sws%_2RUYMf);}pMcqYF*77mP`9TI>Vep6~cZB48EE`eIFYPz@8Fi05!)b$Y zz=!F5bbK>2U zPqiIq@Cgfb+ps5+8gci?9_sN3f0}|RMsNUv0})O)ltOTpf@+h%&LnqdlKp<$CE~RU z?P-ir_f<~SPT+k^ym?{#aX(T0S(yE@3DMyGNp63+1^G>x^v8IQEz)>S)SV+g${hUS zy@|&w%y0T2y?L=NxA8i*MN^-4w?)%lQ!Se(C<_W+hv3a!k!dD@jZt@_RObaqpf3|R zVsJy$ZHRLBZ8>6P5H~H>ueHpJy7MG^S-7}fF>gzP*t_nEEMKf^z~5D86qmUb!feJ_ zwOA)p8FeeAY95W47IwYC6CG@lZ==7RF@WA%7RR6e(^h{>B%k<@!MSbR+%~*x_>B`e z$^N3jnQh$6HcXozNRd!r-hVH!I10 z_~$5p{rV;Ghq)wkySO<5XSu-_qV5GbNM;H@UnkdXaD{{EMxFry?;_%32A_$#XQK9- zeA3q3SYkUKQPuIlV6_6li|O5GsX4MF$vq%9WhHPq@sOo@PNF&DHb*$&n~K#>(h}o5 zC&`^7&Kb6ut2MYZ$t{&qnQEK4)}{W}lu+PPk??ee-e`OeM%{zbz)rKj$6157Cb?Ua z=zYSrf$0qz%llU5yCyT~J<#A5$8C`k<>yl75RWlBG@p@HHt4c^ z&wY>AQ-G#(Dd7&|J1EH=B!yLu`HB|e;|;oHT#c4-b%d>Y4ZC(y*v9`$Ds6e!xt331 z;>#v&eD)HhF`d0W!rqN`1w87$!TJ0dz#E9O8g>0z@3`x6%O}S^gY^Q^!Hs5PYa}(v zy)0)-4FGdMjK4+Q-zF8%L?4>u4wVXcfoH=K&o=mVmH6vSd?pg#OFZA;T@m+Sgx}Pe z1-zelX`?+F>`n3e?Zh?9%qtMsK)X>k(9VPZ z4GLx%!CDB`MtIfpA-G9F-LkNU=v2o%M3*m1d`ixWn5X2CWr;OufvriGmf7$1NM%Wm z{n6BwjHbk2vpQma%?V91ueQd~2dP&Yhs#m-a+JF-xNg_)v+Tn)1x{jL)nrd%m)&Vi zdirpWlX{oCN2U+4JeSP~$3qg}VqAaX3n6$hu67`CW^*FuqT~?b{N}L4vD#KhKBC$5 zcnpulmT<^S!IaPdSeYy$pKb!Aw^0R}-iEbpDTmY)%x(@2sbl&y8957Zo)X-V66yT^ z&{ExMzFe_HnLjIUp`ex=k8p{tLcWK7TO7Dm-DW10!>DwJrTO96yW^ORmQcZd?x3e& zy(!3Z5%*lge&I^|HyQt9@INM%mZ#}%VyAXnsGt!SG@cRmnvy(;f;fqnn`9b!68SZg zfh$q>N|ZyxWpJNLoYA7=?}z(-DaR{#64p>K$OtB)TqepTG;xqc!I&0(V|=Xm$L3_6 zMWmH2z858;cu@lJo8i8Mf~pqZpF9hhYb{*G=e^DQro+I+Hk95 z3$9`t1(%JW3O`eobn#o+o>S1b)i)j7R)2zuXAB*aa&FbX)1|0;Da!X*_%hEa;<2sL zf_%>$x>(%myM;QSTvEN)d(P5huHjoFscmp>WU_Y>^Lg?mCP!P4$GJRX zE+P-bmS@+ibI5PBCf4p%Y{rQDEKkI%IrT@JwOl8^50f_g=>x0^Tp-yQla)WN)~^aK-C^Z>51%*;x@A>xzY==XBXO~M0eQN6qK=QBgd_x5ARQ#kJ3jZky*)EM zt1+`P%gn5hu+O~&kp)5oA#%@Bea)`^nO+GF4xZ=vsuibCvJ4%DvLhTJm!P z{5&W{Ut{+ZqDQ6Z8xq|H(Gya%jzo7u^o$gJi=haj7o_MryrY2Vl~vj$at}lF#wzU+ zxyK=TdzE&H+*1&(UPZ?~_UUUm_VFs5KcbcD^r@`-&vv;~up6anh4s}&D!Z2&^QSvS zd!*c80h@)KT8&My@&zZGC_34klgy{%@nYU6WbKSyFwJz@G4qCLBvSEgF_TZ4b~2YJ z2Uplz@)Pl3Uv zByESsWg5ARleH81M3(DG7P-O5W}FoNUnY^t7K(|iW5$hm#*91ZtXZ_ORw0*3$BPLb z1J8z2V_}~BIt$NoiVZd;S8x)!f)!8maM^4sZaGQ2Xj{odI-W|!GbyghJLx(DkhRTfZLB2t5O z)k~GZveV$@+POfKvBNrdtTdcj#V)gsIW?y|QgB$*5lP)_0{^d`ADA#9U!PyytIpl? z0|pDt++i9EZ)exJ&j};(xaqL)T<*@g$=BRQjmN?|M1jWID=}H8EaMpK)m>P}gb4*G z>y!q=HJNPegb775H8Y)~Tye=9yXI6_&(MHJ6Z9hd5KQA_c&&m323S;tV%_5JtWv$f zdSqZ&dX)m$Nbit6Je38CERbV8Q-_Dkr99WRz+rvdr*7}&_U%V1`9`(II+P()=Y<$p zNbwGj)F==yL@UpWiT)zc?y3fM@yN=hVq>bw0(lm64F1n#QG0mUsT5dX5$l*%tu$)Y zGV7YLFz*Z_Po2Yd1I5p##GQnlOq+JjDCV+>bRuEra&gm4SavFxa|)?^I#bN294ntG zSjAL4pGqc+@nkxcHSLs{F;dhp_=7I6Ir-|)aH;IneqXSM%6V|xdZSP-<+iQY^1q)x z_4mzEokXsaf0s&aliYgS-=(tKSedQ=UZm`E$C%6ye=PZ8wOpvNz~0pEER<}K7cC1o zFgs;uh-a{D*Xz8c250QaBD_5LpU&-hI6GH%#IuDzUeAh3tUX^|$P|gtO@=NZ*qKMu zEoEfx5aOCLl-=}klXNriIHXYu%S~Y!!bTOfgoM(_lTaBcmyrY*xUaHP8EniKk5?GE zFXSV_YvITcZ&|zo^1nQ}!?kK*B;P=Cm6?l&N|n+O_d#N1Cb7M+DVF8{*SB-EQsq4X zkFg+)C_9u%k1|n7bSX<2)ptl{%{AAgS*ANQyH=po7U(Y&saS}klqCL6)Tsn)=1GxhOL0$AJVEr7u5D9llA_b| zwMc5@-LC!#^vH_yYW4!BR`<2v9!*R=r%B<_{Ze=B>^b68@*)wgMReE7rV7$5%2MuE zX>=uH@o@B6he(?4!JFyekjX*=Lk1GAb@-Z&P4(h)15(o$-Jgm>x%7Gc`zinz^lN1+ zBuslgMX4shr!)(wJSI#i4GmXo4c>9`njho^7aXuyczddNT^RF=HZPvwK!SBltt|R; z$=QorlB{!{Gc;VLnL*d~vciyx6#tiIT_cr(Q!G`Sf{4OdNKwRD?+h4rtQ4#Brm-1F z%MYHRb#7#tH#XQmrwImkLRftWT3p<0Er;p0kio8~EcWSNZHJ zFqws8rAmx-=c|#m!RlS2wW13wqi$dsX8l&?@hRb|r5UMx! zV14LhnvQ(bv}=QG%$$+>{H8QmtjJ)z6D#vHbn&pLS!p(Q`y)9|)@zKA$VxV$ohK@EkFQ^}_@WB$@zNTb zXdxSjO|~kJuV1(LQtig$?KkbcwIn_?*J-fGNCi{-J=m1I#pk>EWFgLs+2pZQ zsXH~^VNk_Kl(Zh;+3F+#A`k9Sr||(byjUvpn$N3uWN^ghQ?MV?h=gvNx_U}hG6(Wb zPWWZLXe@OHzF3g2c!PWn6LBy#=`NItMW=?bGg{zN(uO0}U20-LNg=_Gb-w6ol$<&V zx?CGA!Wva&>G!VwcrwG>dyI{jD=|;{x}}g5j!|nav6vGBd}I5P!>6k;Bu7}#VVS%~ z5e68Dpm}!F ze|}I;+06B(tW!BtTHJ8Kg>@DY>yL<-diONfCKfF#-L(xRL@QPD!%LNUC1s;kRwLQ? zrc6%Lsbd@(pvfg)LbEAO+KJnRBJbb~Ctb+#){`#S@l?`wk_rA_!RFmij;~Gm+?e-) zd|?^S=aPkdF$f4^{u@e^blj#QmQ+lQkValJV-1rw){-Z=^Cz17LV(n*4+~vBa)p zk4H6q^4P2Gy=X2XYm`ON!`k(RsSJkPm04(@;mC((P($$)7Su_J=2}p${k~O~;qCe; zbzT9LuJ2)KapapC)mp-EYsNx6)nUV1s}?GESv+ueow-n(ck|`tu6CtR9ik_O=UL$s z5qgaDS8mQFd56qLlty8o#{IG>xnjXiWQtjvKhaE<|3As+=}9Z&@OGWZCQ^K=o=Ikt zSto88b|Gct3Qo#M<+7PX)<`A`X%^iX>R2RK<*NY}sW+&tY$a15y6uDF>#VqYvV7^%AqJeOEGniV*i@3nDBPy9K#9+H@_Bcm?3qXj z4Q0{*gZgX8;Jl6(%Y1Gf=8Mq+3-EDOOjWky@jH9+9MTp$O_joVTE7PPq(}7lvftlS zZ^iDm%%=LZ#+3&Cn@|4Av40z7FPjg~WSLgwo$lOWT2r_6mS7QHSfeRPvThY$>~Eq6 zlyd`vZQC;4X_UoZ6~r@(3Jvhb@ynJl^9$ZEHupH6&pe9gGIw`#+bOrV4WwewlZu%e zpNe0$cyCR>cx_ta#y{RWYvCg(??1-*rZ?W%jo67CF!Xs5&AGk4#Kc|5)UfR)H)vb@ zAD_PG_}^LcSa8O_v*65m%}pZds|_qRqYgPOeY?-lyCuq8*@O9f=jKu=xgs51GM*7KY7XLpD*pY_VH4p!R&l z-qlaE)tGp!mhEcP9UEnzV`OK0QI7Yzd(C$$JB{%6**P2UEbOw7&8?yjBTw_hLaVPT zJxF^eube^&_crW??kRIe-YLSRPgq>Xt`cv}jrlYwjgpb~Q2a0A=YJ7rmso94=w33q zVPPY$T!VC@0I+o`H(?Tr_!EJiI_u>|rKe7FD;gZ;=1bT$^zL@=GBU&`&3QZ_X`Y+6 z%Z?GpcINcbf>OOiTcBM{BQ+n_rWEwbN5?}B81B5$EP5QwrUSiC<&gv`Y>W|~6nFn~ z+ft)yr3$4%+T=0T=g#M1(#S^u(a6*`y-J&^?6fp1<#()9ux_RCPE5Fswm!7P7TS8S znjUg(JtSCYMvbqm9J~|j#VjFa)2mRvTGc$V;4rTc(jipgmR?iOa-?1w9I{!jr<{V7 z1uRtLRUlO7BT=wYbC(-J^MK(On0|rf7bN_Gq+gJtRHQRWKIb-4R)TDa6wOsq@ob!I zW+IVIXHqH4Ae&((tYjjaO(c=}6raNL1zsFJ`8?0G=)5^wv}-f6@wC-^Iy`Yrw>U4Erk1#AWtv*z2A}n5Pc@lM zX*M&fg^L@Btp5DdN!_N$N6GQj6tB$_E^c~kEfof8)A6(sPo-s%rYtj&OeQT8k1%Ce z$+VHpipP|SXK0(y5DzBB%Pf&GlPOEuEMC0nxb&Ge)7&}X+LBy{X}PvUBAsp8EXy>S zJ}q8xlCI6i6DqI#)9BL2q}HEf9ZA9rHG7gVZ0Y7z0pyfUMwk-G!EO@9N1 z$7b?Ab>Xl((JNO68%BH&rN6D%Wc?;Rd%b6A>#^C{C3zBbCqB7%YD3uEn^&FgHO;L&$TBR5n`7{;cumNAJhb29N{lJ^m;vs^LaaO&rc-Aqo(B;#3=yNGIQG?U1O zgrsJRdu+|9d9;)GAG=m+%pZ!C>M{8eDORnN7sZOzT5Pyl$50WgjL_C-o!&?cvVOx~ zCcVvB5FfhKvbVlxjV5;6(%-h~`Ql_-JT^UwFnLFL;4DjUAj3M;P=!@?v9z$%Si}d% zUD=rC3$8!Yq+nNC`||n3&h$p7TiJFSZn{jb?p0x9m2H|+D%131j1p{LuJW;{Pg!@c z%S9f#U$;YpM>1o9Q(@!X*!WYYLn3yK&#UV5`P7ULKfHI)=MU7C6g+}nI*kDPY?`K?~T*7G0haznYz3X_>jj z$N?=5yb^aW&lSsjjqwQFiIFQsvU@bEOoU#ogtJ`g2!(t>HlxzuZhdY=5*4dcIjh~O z9V?BNx@5FS%od5&B1yDJk}Z-{izMA5$+Sqatut<;Rb;k`tX5H?Rg`QMrCLSlR#B!^ z)S6U2__v5!Q*XAW-fT_1*_wK@HT7m|>dn^Fo2{ugTT^efrrv5zz15m}t2OmjYwE4m z)boi#gtmkDa;;QwDh=MbyMrt$uk@@9(jKwNqP)fJF)}PW9W_`)>SzExVs{$-pRmZz z5D9_Fal?dX@W#VBq?BF;xk0<}4n&wEv#NvKH%@w_^E%m-yv_5S%q97l>l9N(-eo3| zdA=MdW-_^KF>Pm(cD88c(?*K-shNbGD-=_iM7EeQoJ^e0A6dut)f(^rZ59}0J=06| zzsmC-tY`1iuym`lm^8)ejoL`QF;a8*09)llY9lseH}dl*u^xK6fB~G|y6(w3cfn9g z?-q+D>zP)qxSIJ&$sq96B9DVk_VQ^9y~*xuh}{vKq1;_ph}|irkQ{Y=VH^b_sh<82 zAM5Fb?Jm1)Pi}IZI(?VzX&ncbse0H!`iC!BPD#beM z!Zx8($aAB3?MK~QPt9q{H*aqGcs7GJZ+mJgL2qj&OjuyZzs)6KslHvcTGkegQqbbV zqOA)uu}IM_*X3pe1ksvP7|A>2P%0bZ51*6fd8vmfgV+@PtEg0FN+))n=xvLt8NJ$* zh_Np99Ad0jdkWm9m41xgg7OH=hCMito&G0thkqZjW=)OH*n6k!j`fN$f0pPTWNEoE zVIxi1#)u(AS-YEOpZw>btb*q1Xs+%~vUqc6;)JNSXQp(AibblZIAEpToV!kyBI~Y? z0!gI9r(8lOvyQAZodWtP{vtJri? zRuXVxw4&E~LnoPcclTYVfs78TW!heh2T z$T7{vqTVNKTgf<^GOya8Iw@b0^Jl4y+bzPJ+?5I=mxl3zsQoDxi*+tk``cq^zkNHq zY*+G5fzA3gtx}7rwgOt6D46xATg5;gspzn@^l#-MN}$?=ibFgHHo=mn7?uE*>#o zwnOaq&X;z4Xsy(FFYb68c*Yy{9?tIbdDHL*;>A$l9e-Q5I3FzefKt~>#NwR?wMuU$ zcstN-8F@;tK8$f*cS^Ge)kjZJ^3kZ5H*jk6Ezg%vO}l91r^Y_Z5HsQkUkd|6C%SE7+!C(fP-CGHDjFrjtqAPR1~t zq*+X6i}^V3sHfIxENJST;_&4{F_(^Kjf|7FZJYOQ#e5=BNF{75n=R(dTrru;J4HH+ z88>o#t{ivrg+wBgOd2^mli@0{YvnXbdHGFE(a0G2LMmnOId;*qQ*oX$+ezg37?8A0 zJC)8kd{SOWWsP_-pGjCoA!qZ2nVrm-NhcrYRcxkHacXv1=!@3qqb)wZ(iz?29J@iE zV~MGCjAm|p!cE78hxk}fh|y41bLKmhI?c4~Mr^}lo0yNt7xx)@Z*R&^ZRjjO|1iDn zrVTHaB`zGfxk+M_dsr$d3u*75|wJmRO9i_VXh>g!~@r@i_%n6Y=07=4r@r9c9 zif@dPhWJ&dnIE=I76Y1KX<6 za@eBj2gfepEl6V{pJN7j``?s~lu&S89rZD@9DDdb9GlNe77@n`(Qy&(Z5m(dHTb+n zoF3WU$pqHtvCYM_IBzq)DQIS7y#C^!*4lKu&2zBL zZ0E;hzulU4kgFq&da2;Vs>PUtT`nwpep`?6p>)uR{Vu)@3vAs^fyK5E#=p5~`R~aw zo}Jk5@TODx^Bznf(Lg&HuS`-Kvg&H;a=nall+PSRZjB;Oj3Q5tB2SGXPmdzcj3Upv z`<%X`i1E=JjL{s-_9tsn?Zs(K#xXHRCF+;9W)1pza~0dZrjFAOqVmbH_ne9lnXMv= zO(lc3e$+}%A=aqIXhs(MpXB~qs(VE~y=>r_^Nv=lh~j7 zCYwTr^~Fu@h?`!ujTSYu>FfMa+G@Wy^E^rahP=DA`TOR(Lu&FIEa!>w9{z#EWPd;rj3 zNRASSF3okWq8d}mv>u(lC*`@IuZ^kT@QErC-#s+oW`vuS7TVbNMyT1Vv})BNUssLBYFe?3MWxjaP-l86J{QNY~=NCZus$i~>!o z>9ty3Yr<(8tsBZWfm#m8UGbOO-Tz57TV( z=uGkDuBbZVYg=BK!Z6x_7YaR6aZKC25yv zLS*dZ;(1jvbx;cREsgp@H{Sm%6Hd0VQZUe@s zE++fcZab1F#>cafav_$(L|oLb|BP=FGg4bmjPz-;@_Bm?o*|yv-|S59LwSDr5{(ZK6&y04 z*9Uft2c;JyrOF^rUY8q)*S-26>$U6oQfXdwR&_6@c`mFz-xg}4!sJd+=8J62tMdF) z4~89Nd`i>ShuL0iwHGJYgdNo$~*p3CJvA}c|n1NBy^JPTVjk{EzK4aGzm3*~;IYV2q*2wbY$y(b+VH+JLGc_IL z5W@=z1aFhfQ59LNyJ=&gG95!90qvTg{?{#&iY8brLL~c6^#9T-JSbZ#LAEjU@>dHT zk3-}MEvzcEu!+#k8sF4GN78h==BcSwc+z6o)Zd~w|FGxVsi#I*=xtHv!@sYT0 zXQ5&G9)(D+(6Nnt5bZsf>?iI+bt(n%So!>(4;j2K8zqo6iaas8vOkWh zEXjWD2CCEd$i864`ZGXZ{Obbx0^g>cR$iYvcp_k+X^EOz;5+lp_Hctg7MiZ`KfDXabHEs?4kBzs5OZTZSsUXiu`!;K8{;X(A~=J= zwjA9P>!7_$8jm`$$4aI!iFjDC$5?afbgNjHSN}IX_)rQfRPdgx*=YEbt}(l^gHs;H0opE1+>x6x9yb=vIebw?Z-I;S z`j>0Mt2MEr(h}a`EEIQfFt;<4uZ#w;13k4oJl|%4zfewKrYnoMe0piHL^pZV#e|MN zD{=P1T^(E|zWw5c-G(VG<6YJ2)spt@a`R0TeYh_Y=^oV;Zv(X*4f)Eajl^XCB8`({ zjroq;H*v?qyj`K`2JegFbor8Q4(}9AU+K>i%``?gneAQFY-dkx^SeME>yoT&y z)9WrW=MN>NW^<0C=C}08D(%X+t8KDq8=~%eN_zUP#T{q#*O*X6cQkdmFECj|)F)MV z@-3`3vDDvnYSFr4?a4J?+4+QSNsZBjS{!mIfXczemjyQbf`qELcs*hzjmgPv6Y;c9N3{m%#8`@h=f#AKnV_TKH?EO!AUnnZ{vo?sJN!k|rk|B^}kA1Rr? z{D4CJul6~ilKG3>-p!&C_zUaWu2fXF0}c)3N<;D@I%>tVQ72wF@pYp38xal>;jvp> za|IXrMy#nz+~5YkFbe-Pyup;h$62m-`JN9=S%t|?%%D2Sf_y9s zE==%$NqM_anw#lghX2d5&_dc&S!fvS4|*b6j?k>1;tB;MNqj&ilA@&~A!ARm)ISxE z7K5q9U^Zj0WDJz&6pMe)STy~Xrr&DnH>Lgro4C=sHLVFvYoe*upy#(iZJM5>rYDJ7 zBC6BR)~iNSkkS;SJeQKd)qA6d^rnUmoMN)e&8i)oP47i{%?$n7uCeazN`q=@5*4rh z-OH!!x;m&_@%y>Q?&zg?yWYQ_Cz{YGZUeQa19r*Zh z#{&C>=t^tbRNWjm!1YpNZcicLuVlmGbeeq&8nTsd$`CKNKO{7?dyj_DfShU+Uy7ro1Ej!|P1uuMc z6^DK(WL{MT4b!0P4*Nr;a=9e^(ArzKyED?JdxzhN_kbgEH$)oU6YcY>BQr;*iDtU{lx4+o9;TnLP#ET70p##RO78i zGul_9dGvvmSE8;K5}8t03*m@wUfdl;H!s<_x;R6?i?Er?sIX!#C(1U$d$U|!o_1H> z}n)TlDXJ%%A} z#-y=@s#&)jZOGiC#vnMz!HdjeJ>!~IBh}c{quE(7mk@WoG?O6*ME#Bxi~YX!N;#e= zVe*7iu|x(iBI;Vqwpx&*Q53Px0X4w>YfDg70Byqh3$$y%9kE-JAj@v_B&eBze&35I zk(uA8MD*Zh+u-Xhihpeh@I4BkHMR}pdQM@JbC}Oe@QGJJzY2QngbBl@Uts8hPH_+3@KpCoBP_0VmpA3cyz#b4>{i}S zx_qub)$-l`0}#ZCJ+j@!`PEImQObpPe=`~>8KIr``SF>_@(IxemGt z?^!t|!xnL$7*(tw9pO%z8Qi^F$Q#*gCX-0V6WL^@ zP_#09I$ua8vSuNju`&reZkfeGI+@JmtxPcov~?ar?QA1gL~5v@A}=`t)AoWpn=_y0R}cwOnqAt1Qjrro~0~$5LEn{dl6d zc;BUNx^>c53Pt@0$~O8|nbAP{fZzG=D_GYW_4ss`-NqO=B(DlqTgFD(XUo z1}Yx0%Ogs~l!yO@3!Fw(&CfK%q3k|YLoY)^FGE8wLm7$er`!&w-9ctl$~i)z@BUPa zepg3yXzfCpxDuJKR9J}98<-@{0S}o+aQ#IJkABqdTfqXahQe|ijgh9}MU$507(aJNgm1o}6e(c0ZwOs9M z$@POOF}V)#7ohU4?ue$6Q+GdeqyL4u8JGUh>V-ctN};Iq5SGhsT21X1Vl(HxjZdYg zPm{@(ukJb*Ki;E?wpj$GZM3<&aE|u-MB;vQLt7^(S52WKWy`$82V@k=WMA{@;%#X>OZLvwr&UR!yeF$MuWq^b>UsEe3OS7Qy0% zZRUOm?(rPlV~GE2I&ZhgP_vgLThrpY;-<^A6+06Y0-J7=3 zLe{6gI+rIA`svdQkFPo3D~XnwLJ}=AvrRIoB~km_x6LsPlSM0zQ=lc}e0xzHS4oHJ z&M@nzZ8D$jJ(6&J*sd_Uzr)8F7Siby#HYJX^F^-uoSpkgDD&hPOfmYgirCOd9S24RYxE6!E!LQCS7LiR zwdyvp!788IgmYE8v!%WlE!}_ji(hO#Ifl4mjcPSkw&`YuO@-LKZw>DBO)~C!uvjop zXEf&X{xV#rH=gplm^5D<(Y!={(8R5DRRv!riBNs&hSkY2T;SSEMdvO%BVt>!i)T77 zj8kl*Xd4;BvTP@j$>(w@Bb86&jYP&tXPgwbrBb}V$XoGDx)9H1Dy9Gt`k6rS;}b&D*ydVXcpUG&*hC2Xyz#IQ=?amisMLes2OvS8dpWo8 zijxJ>lm4DY{bqr_eAmN8@hgmVQQ3+fm0`Nvm+mAf)2|x#xX1MQ%Dua3QESnup4u-L zkvn^Y9pWaDsVq9Y19z^=ggRPs9nTMB$t>oeS8sz2fcx&q>8*1 z#WTqw{aR!uW!O&IPVhlHUq~65LaLZfq|I!)kj&<+bl%Qovbiju5f<%CJQvT$%>*0U zqB3u0;`zMg6q9kIU|MOXXs2_AVaD^eWf%CICYy5NnXJu~rgFUb<{WOac@nIA){dtW ztlyJa(i!5Fi!pk4>5FVUm$mrr_2hepQD*(*=nv|y1ll>WpA3v1l9?^LNTWr7oh2mg zU)GZ}Pf}>6*rUZx_p-N%&8xk`mWD0Ylk6QvS;&3=(&^Tc_b=r@TgOks!)nD@)_ zY>LN<@9&y|&4va>Ds{W)c>PhA*3i_Ar#{&q=lFIZH`9XQr*`dpW5z;n(Nt@;{zFC)=;m3G$!io-GN_f4{H-_7j7^9MF3FY~S7Ae6C9Um0J#&DVaSfr_4 z8a9^$gufJi)Lw3|hzL~0z z3=d;2ORK8J{8BweuMKE6UoFO_^Z9;k*7od2qv&Ii2EJg8mc%_|o4f7PTS2LYsnK@3 zMRY|OZ=J4AA=#|is#S49+#EG^@p#k{zYx{YCmh3l?rigt;{P1F9b{l&NVBS^EfUcy zAG5GgYng?cDTSSfEo;^&cc#C5CP{a(^{BBB`BUk0fA*6#^XJM!^yx;3KFtWxrxzS) zw>45W=2uJ90<B}jex<~f&?mW5Yx()~lfYfP z`;o@6Ts*g0Y`8J-?qZ;>3wosw(T;khv#@p1Z%$$rM04-FFp+RZrA|F^Cw=%16Xjxd zuI|)$jig*ul^N>s6(C*`i$fs7($)NpysoCS(+%2Fr&I0o`P5c?Ia=_j>JtJt(jNVB z_sXAy5H{IU>D8lKt2zwhda6@@%iOj{ckXH$$7%6NQLl(i+J2(j{FBH2mUQ&_?3?TQ zHTgKHRujcqME>Zy^;+39j`7uHkF7`VJi0Y=zet(Z)AYBru(5u@W}eAU^QpD#@%sFI zfUo}?T`5U zTFIN8Wz(~_x{hm+v+ilM3~7|)(G>ax=0>Sl%9rS)zUVF^;I-`mv-)JzpCUzoCZLXyUhyxR0Fj!G$>?)Xr#+p|wf# z0QaV?XM*X4^7N^bF!|z5S)FQ5ZFqZE1@RnjZWn{e;_7U6UYt*im77Pnx51a+W$&5p zwt6h~6U{ogd3+tA+6GCelkKo-UjQ{vT6;bW)5IqGeGDHjZhWHK>ao~Xy1gaYZ389f zd#$FObqO0C*NzG!>vu0$jEOU(xI(7+>~T(Ecihp(>1nc_zo_^i@3U__p#(0W_eu7WG4LUi>M?gt+1WjcC^JAG@Hm)UcSflupB0hOW zXwQR`%YC^~JtI?>V=goR*&Y#jlt) z--W6p{c1TSS6OP`9*(|fxw|HjmrckI;t*JVErrYD8m`zi&)8FBQeHOa-V36?qHWVL z_#L(T-+Vjv^dlw0b%jOcdp6|%e1OYY=p`z{cQ0-!lNwRPMNha%JQVl@+eESQ)X_AvK%Qh zJ<5{plxnwQH8ST_r8?P?!RTpok&fIu)IkwHlhi~tDv_~YX(V(Xx&-?)mS=T1o@XUXz z4eNT+?euJ9^VRjF+UeQImaFSYw$t-H8ttH`(a!uQ8tu$~qS4O$CmQXbx6#i00gcw9 z&TOadd$-p?Z+o5j1KaDMx4q8%f$eqX_iC>*H@dy{i5)`Kz?FTbJ4DN0L$g?zn=Cph zg})K}W&OKU8?lmlXYLdx;rG(=&aU=ee8F79i(mZVGnNSbaG5E8qHa68vIqUBLzjEV ziiL;qdZ*?zMrwRj?f(7)i&XeDu+F053@=~1%MX;ocxxD1xQM&SAAs-Ee;!Z#V7+EH zS$Mu(F0!%Wr#Y)bL$oPsCu z`j5|AnClH2;*7K0f6%6#ect04!n3f`)gZZ%K|DH`s2&$IzkP%B%9{4}#1Fe|p&xAb zywvFTf2@i3PD5=n6QSP#jj<`2R3Vee6s?4jGz)Z9h?&jC&^{ZwGVNzsX{Ts*b`euW_F1;-~Jo zDDC#=1dSJeJz`@}R*9T7~5U+@wO5M*9=O^l+$^7yhw@nv{?nf{*_biYi|eW{!A zkQB8lA#@80G#N;g;SNh^La#8CEp=r0+us(@y9Y;CmdrUyO<1V$WMHW&HKn zS4M3T{hlqTBSHlxi+QeNT>gHRBPudW{yn#%P6T zv?)aSD<(1-Q2q0^i%9jb_VlU6vyMVzz&7^Y$$JD?>-V%{ zu^HWx)-5UBlGH5;-D0u6S*2Xft}WWRS{NzQov{OaPQ#HPBnc@(n!uyeUKg>bH7PO4 zFt!YL2R8|98R`xMw~Ta$HVH(@48a(JCR@Sg-&|BEm{$Po&JB4Iw^uY@w8O4yT7CyWsG zA}k{8P52w(?}Yy*{4c`)rik|q5cdQ34^V<*i(xqcJP14(JQO?(Tq3+40n1Ww8F&nM z9Jm5J0Xz{r89W6%6+8_*6Y4fO3l_oi!1KWizze~Pz)Qf(!K=Y*z-z%9!25*Khhcdn zuw}G6%q>x}Jtmx#FcB^%; z3GOSZ`H~=64iCb^k|3g8BAk}N=_v4M@SMGMP+MKNFO0jpTXCnj7b{S#IK`p3I{|__ z6ey**(-w+L(P9ZwiWMmC!5xAJziHq1+;h(T&b@zpGfc?rJz<__?Vs&U)>;%E;(v>v z`&$O)|1N}9idYI;idqU?io64%gP;SWgQA1E)z;qMC$0JzXDJ$JinX@|Nr$|GazdS; zRH>cSHPJ^B(3enFs2!9H>IcPxYC`Ft=1>`E2ow=22W5m>@w3wLj*NwkMes$`hP#Kk zN4Q6xgq=j3gja&E!T2D2a0(~|EDT|Tl&#mtOFU94TEx98WUrVsd>@QlgWQeUjeLz+ zY#f#dMg}2+BSDd11&{)`7sLWtgAhV=pepbYB>@P*N7V%bv&-Ofkhsl2I2ULGOf3Ji z_l)4ogUF^VqAc7#atFg~r^l-R!>b0v3(!_K)6)~e zD$*+2D&i{YDzahZNhEz3eMDiTMTAATGq@J)4)TSxK>SMzqO`R@@HTpXt}xcoaB|0j z8&ITZq>xOp=rDPaQ;<^7QV>&6Gb8_P07MK<1Eqm=75mD1ymC}1sQUK!3lR%23s4Ji z3y=%23(yPj%@NFT%(2YT=#cUaK|q#Pq7JMMst&XcvJSk#1T*|$T!;q546+RAgakt# zAjptNhyp|z5<>V<27mzI2Vel$0TiwOV-bJH5cIbIkT>WY^cR_6I5M9Bo@as^h&nJk zP&;Tlh&!-5&^yRG=z$180x1F@1rP;D0>m*zmBEw2ltGojl|h!lmO+=n??ULp=tAkj z=|bwl>O$+n>%x3Q24VxLf#^VTAigPX7k&sr2u27>2u=;6ETSx`EV3+mH$pc`H_~i` zMdWJO0mvPE0ty~SKE^&qKgQ=p!$-hJ!AHVJ!$-tN#V6K9f_gwPJUCi$q_CtU5+p)! zxCeR~2CCm0l)r{nPHg`ncG&AW3jL=nixTMhBtyW8l>w(W4h46rvO&6`~a)79v}qSfE)TTA+$jCI%v(Q$ung zvY?g{6F(ni*c?=(>igB&?C_!(jyy&!?79;$Le3C)`tDdzHoSb;eQbtP9Iqq zRv3{Bf5b_-9V~eY0X=~GA>j}?2pGZ$DTY`KoMP|Eh4ve>D!Sa8meE-Prm4 z{bttlL6}jQ;f7&`5nSL(5L|fSfH(kCgL6T-;6IS~7Z6}{a4bj}Gyu|vY(R)1X%Jn= zBt#J64Z(&;LfRog5GQExA0_s{g>k?rVJtAxRo0Q92;bmcP$%?d(un*q}+X>%^{0r8{E*kpBO)7^pE+0vZTKgT71+fIExWr_*Tn!DX!% z@(l8+M67@yv>@t`14;=Mhkl_G=vj^6z1pI?XkB70{ls)=vOWn# z1F?tm(-0yGk${?L3kPa*fchbba2~)}&>44*Rj=z!VG|pv24?wxpbJ-uR_Y&Q{dG?% z`v2{pC>erCMq-1P2v(PW2_`t$3MJ!m)aud_s4AhSPjEp zhETx7V9YQQm=FvH#tox^Nx_g{OfVwYa~Kwk6NUz3h2cY8J%nM1Fh&?5OrRC*9_60o zp5`9up6DLS6gvbr;fM_TgGsoRhV4ZB#bwxk$mqYkfd`hq%z=yPU+(;W@EHXD zS62TgjQ(ZQH#ncp;fzXzs6mDyT#!nLGvomBM`TSPi*TO(VHq)07b^HS>!|%rJHl`j zYU+*4=)Lv`^$_^WGJ^lNx&0pw3(^Hof+oR&AVKgq&^NF*$os#9u|&A(sey;#k&Fw( z1x|CAd1wG_VXU*-|Et1Tc(>@C6=evrmr$VYb0RLpO|eV@qO z0~7~H!v#UgbT$sPn;j`jmgJjc>g%i%6nAus*n3>4n~Fh#2BOLeJU$ryjSlqJR&!m# zjc7Akx6Oz9$6=R(sbv533(t1KFb%!ADV^Q0XbFm$-VpujB#?F9LRUaht^G+_lV;=fDHUrBt<*TNJ(CV%x0 zVxRxTDj7`VA1yaU4nYgS3qcI|SITF@?=C*=AYG%w-5d$- zP#>uJU$+55<)NA%d;9q#Vev5xNg};KZg0q6Og0;fBj`X)o#1m9{ z6u1|{o$#OB_SX#~#v;(x+W=U%8K9llPs-UD#ddHE65ovM91>vtfVi zu`nDS!cIVe9Yj@=QeQPoZt89)lx5ER&F^$l{BPmoESW;_wF z{pUmHIh4Zam>tj#m>fh7&IDzG4MB$BSNM1bc2EC44!0)Pe}1Bd{40dxRF z03(19AOOGwZ~!O);(-4aX5lzQ>>z|=krJLdNjq@i$rJf+_N4xsKJov_pFoWNmOl~y zO`y-L3E8SGL;i#e%zr}$?w^qHPxv&&g$GZZ8p1Jyyy1wOKX*|7s>;9ahPPxdwg0S@ zzEu|9;-9-Yn~tw?YL>+RQ)(yvkIer6TTyV~nxX+2h44VCA+C@k2rVQZ9^j?_TY&G~ z2&Fs%dI6twH`roYaQv>cHXC^4iQE165AihTqfpCGSg-0=j*pMoGnH>69~fa6YV?f9 z3fa$~%Q%$j+jtsuo7=zQgS!uplHCaHz!WzJAFkdv`a26;T`J$E7nt+Z2;^w~Ufr1# zm?2m0E={}izwjTbQ=e459Z)+jm3hd$xo|(8e0s(cs$IC45^U8nHKfwtnNFs8b62i4 zHrD77{F}y^_?W!Qqq{HdXk1?F{kDa(-G`@_Iic8sxl4SnxSLghm=l=4{^M3Sn?wNsMvxdtUviura5if(YX9I_hgfi9n5-ug?w1OQI%(T zIrk#v=n2(-UOUl|4VJ^rBIz~AT|IMLb+e@8Jn>EF$-D9QgTG_J=xF<^^TO7E8udk+ z?B;;3#uL_~H*WYIcZIO7zpAg+1 z3yqUq93P)r#o2U)6-qfzhV{XjTmzYjB{QpsOYn_a6?r-X%QG8=9`)6Rxkp3ah5gEXg$SgJs}l7c1meE>pYzVcY>Ivf;-tBax+T0 z)y`Lz?pM3A9y9q$pN`6xPeT%inTQ7-;Zw)igltz4I0qu)H`r1psd`9`8$&Iv$284U z^c{w*ZR25PcsS~Z%jNBoLR&>)9z@N^Km9yjyli>#(q9Ec{#5)*f4tT|zQJeL37@S= z8Nl?W!YAhQuV)YSyypXfdM_%ug?WCzRs3AcF@W~kw<95wOFHcfM67$A^!97D{Sk_^ zGh2m`CYGhg^$)KGi#?7&+qs#YJE88AN#rgp5nVN`05RSE5QFx)v8w|~Hb|vi=yibD z81-7=t^EyC#|tHZRXYaXnKGc>VwG1WIFmCtvuV=5&0>`l(UoR`Yx-4af7=lKg%hvk z+wVc&CBG|}(9rF!Higz?Bs=R%)L<8-H#Z;DZQr3ia6Fnx9FEeu=``KEtQZx&Blb;{ z)owvs==+?quFaF;51U@zGxRqEiDEpW>j4y&H}HMkUr^Iskok0TendywrRl`omJ9Ww zJL3uwD@qd9u11yKcL%22_*S1C1WJwD*<4(QX$79kjz7sR0sQniFSSJf#N$3tsq3^GT@bx*Cu=hm@TOBLEvm8az)kGxI zOYi=^l1stST(Vlf8oHC1y_1nkhxWVm;4@9q+DN=p_kWuqUmEnYQJwS`GGevy-uCXU zQs_JC<@#5nk%*yo$=D*D2TsX;Tf=6;Tpx2{FGzfD^a~(p(w>o=S~_2r1ixqKihSLg zDxy2HnH_Kh$IBMNbXjCq(hKP}vs>TTl=xeVlCNs53q~vyJvbC~FUVG165FHN;(PfK zV4M3-fZL~I26o!y<`eVCN!B{d$#(_wirkUy_$UXJn23sZ)-44?a+0D-O-o8z-{>>qMpJcWcmKIqS$R&%qXbml~u6X(G; z24&Y#zo4ZDm&0ms6L5snyz&#EfqNgb973M#7}M%4ULVlYnQN5+yq!BkU8FA`4YV%5 z52mlKSG4iWby>TKZPLoU>4t1*rso-6*0_aATO|4pBg%zVocM0Ex<`7ho}7BN9Bk0d z4j^+%^BwGodq~&IbbyWMPho(s8dWJ3Mup>Vr5)f0yTC?;P?*s)>kONIRmwp*>%6`vdbOs15KxdZXRFz1Xt{GgduRjn)k%&fGGxhwGj zHRntj{Gf?el{@@ki?qz+1nZ2y)Z3%Tnv?-kdabtnp>wygvjoYRuD#@@lfd=w%hNuY z@CBTUWQ?ouG~YpgoyA#tqbt_r+*IkEpvvMn1t0vje3A=)jGX>_w^6iuO*L&FLgZ2( zT;W2Wdh`v__h@l-`do(T>vox(x^K|uT0Y^ejz}D*WwEJhS8ZsM%nu+WKFBO_$36W{ zsQ_?=89#tO%qDjz1(QD}|NZ>%u)@0)yR_=TwJu3^e8 z%5Sd{&gOnbfb$Q(hM{SW>NbfcmgY%gX6J}5TF{e&>P0qB_%E#Dl9!hT9-0p8c4-6t z*JtzO91?=#p=83}ycG(rlxi`6JljC6^w)v~*zA(zfpmLHJbSIJ`Deah5qVMN@t|zw zlP#XWS`5b{hopkJbk2IiXeg1qZeWx0o-og=+RM#zQJp>JW3OP9S?@^Yu_T^AH>|z% z)5(k>fSStH@+|rTardG;aj48q*|Fev{_#*k2{mo)ML!OIfX<}xC(g1vvzNf1Ewv93Y{a3HBE#lbjzJ21cch)` zJRyOCFO)uCzR+zf@tTCa{Mvt2`s(z+=1y59xr)uo9O4$QRrhM&vn)A&cmzGvUlpWpFjyY=pV@dwezT!ah~4-O1DCb4};`FR!J{ZaN(DxGX+ zOd4OVTpkm+#|NvGUpPQ~2nasn$ak0bDs7@s;~rY+@A-UpEAm-Gg%0;>m14JBHFT>^ z%jnx7^-SdPPJKQ_7VL!Be%SgU**XY?g>1W2UoJgjz-*GZW>0nYf+U+vH?AyNmFoyu z&jmeZk#tCI=%%XK)=$MKdYx3*F;xD%mA*vo1W{HWt3pGlMNePL)fj<*_UWLR{pZ$jR6>2QK zx@qMe`P89mQ@89l3txSM8uondNwMfj^%8G;sLp-Pq?~F}-)ePp!%3gKrTC57oMC5; zYtkkM>EPv~ireedrQMWGR_PD952;J*s!J%N2d*KtEwPL0#|HZgCl}~#TvfQ@T$dUC zDGAr+(w(0-Wx7AyNxzv)gB7hFC>(tNL>BZZ-^1@UBd`G4X*QI=B;p$-K@BvWuiC7HA8&Wb7jSsu&Y~uz0_c~(`BitOxkFRG07>l#P;y-e!s>GNv0R!fsF$H_tAPl5U7@VwZ`H;sQ`jyc%>0;g~m>7VrD{&6#q1B)1riQnQ z~6(zXo>b4j_;ZR9Me&N+Rq(jV&D*Yb2`qqrCvFjP2O_K;I}YNKd) zkyntqbNQ|VG8lJ|g4Fpr&z!j|Q@Sm%zc9=2AwFQVaCG)r7x<2R6rJ(>cwPV&Yq4QM z7#y_6(S0uY$lO_JzmWEAIuNs?b}D2%XB7V-6}XwQ`8HH!cK3TX-k!Y=QlNHdDbwvo zm%_VexAKPP8Y;tszMK$45|3(G$Sk3&&cyn6O}@0;`Z+te|~KyXnPsT zsa&p3Lwm`in0(45E>dyr$AcPVs)t8b8+Cs-IiS3FtW`*$TOc`|*{C5K3{Nif*{Ww7fecc^C`?IrD*BwI!2cJ{QN zdyura%xZ0+KW!J#yzLQazFD4f>$b^qJJ_T4Kxjth>RprPc)2P#Zd&dH9qIMCN# zHQ%4s4z6tKzghVD$lnpPGpp=7DheYG{$w$$QSjCHes%piw6d)6zO2qYW#1%^rxTX1 z9UT9A9oQm!rB-qqW0UxF_xIO1vUV_Aeg94I*RHg=_4~N8_sd&iE-3+L^+Sb+1D}q_ z;%_(Cc{}+6UO5IyEd2gmaN1I4|Bx!Zqgqxr)CIY|vu<1tG^#CiJFHc_EPzn8yDW8^n(6YOru-o6n z13@9_&e8?lGs7FSol-L#p{vb25hwS7*L&la$BV_X;ZMcgu>J95@24_zF?4p^%0a)P z2APsix98Da@$9}LBT$caPt|@{L28Kp z)9xm!ntvV$h_wTUlDJrvHb<{7fHn^WP64gTohRAG-Jgej(#ABtzcCVTpxd zt%tG4iLFO{iz8l-JD0lmo0h5kWn8(R=Y1a1Eer8>nl)ZMsnD;CF>Cj8J$3$Onn2Lq z{4*EKe(&)ND+%9b?7oLxuX-P?To2HIQ}u>HC$Yq+(uAfTZHh(P;l)f}rswIFwvAGn z=>8eILg7RhTMlWSHL2WGgP}6z_W`tq^`?viJ;I-!OFwhims}@!eWdX-aA|;VKU!^s zF#Q<6?b4um>rL|Av7awuD$)^GyEJ#En&#`o6<(Vo=?rW_w%>M{P^t9j^<^k49XFJ$ zH8H3pJMAQS=;7rt`jf>~;GM_|HZ*fK4zHL%Aw9*bnx>cKozB`t^JKQ>iP+WibkVcx zSIRes8a=t9y~yrok|d-F5dKs#iZAf@L|f0C`qb2PBkSm}2Ltm|XoTwu7H9jmei#$6m1_F?Qz*yI=3J zMq^WMB$xKjIOW#%PO@nCp&8K605i}9)Xpn%aYC)J5L&*$L(%qYp|<+>k|ZN#fc~Eb*2de0X>Y6~Q&?7KuY^pg1f~z*rjKK`q85{AyS=DU_jH zd2L>DdTF~KD9)gPCvQl2w5ZUZh~IBYnuMP4O2w53)tHt?p@t(O>dv8MY5hByvI?oD zdi)Q@?_@f{OfP2SDvUqRDBp=hOeJJqZmJ1*eBtQvxBcX*m^cw9G1897HbeMqpP@-< ze1+ypAigt!jyx4(&vp z+P;I(;fD{io4n<3S#!DJ+Y^U2EeNxXXoy%8qBLt-XL4V1R8N(CI-R6}|7UQ9?VaA3 zW5tf#rMN-ouC8y$pyNZdNoeX!p-OvIx6wgu_vKFWBT;oX$-&s$hc9nJBdfc84#u1x zaGM?*s=L=_Rxu9vW^bu&ACj6MyKEjh?H_{YZ|^EDuT5qw!Y{AkKez|(?=G)XFRytH zP9$emuitxrFF94<(C;~Y=#zGrohsBkjBV80RX>i^{SDTwo0$rkvz@&4%p|=!=SwlU z3=kgSY5DMbJg>NX^f0#IAVcZ9dsJIcuT7dU{vuOilx_dq?!2X%sw|H%I{QqokENSz z{Om3Wa4xVC5dwg6$LN)75tOIPITfq$FpVUJzoDVAEW9b2Zy*t(qHUHLE=(HLv3-m%zk=~+r|VRbxUm{o4ZWQ4-G zE`iGef7&(XBKzfJ9hxZy^q1{LA}(L!>O(JF3u{LkF;v?BzCB4t409=&s^nM};+Vb` ztTHAPJV7`#6FihA?r|ycvCs{CadwF$!*rx2UHZP9P4T+C^lrrFwX}9lB}eCBtU`I( z)V;I$ygR_Q`^bI{(7q}IC_pg5gU3k zLbUuHqYKxpT?EA!xvj6u=2H53afBD6GfrQbx=^~~vE>ZMZ?{3mTk_v6y>A+~rMXiJ zmza^YGuo(06z%9ov5!}yZZUI+Th@$O-{Q3y!{SzEW98E4Kv9au#_g{Z>Uu>^4KQqn zGDuLTz=r%(b(I_{1T>OfeoLOL^1pEtQG15G>f(k{oWGEJ=gWJ3cm-fQqQ3il%tGR ze#q(p1X&sH`Ku9h2~kRUm`7y7-VmYKg{ovB^L8NnqoYM$ZtN{;&)pQWzBb( ziR5ftps4oA+P!ZKNY7qTLx>k=0t&xUYB67<$i3JW5J(+8-khH7Y2fRv{O)O z@*F9yXitHK%6kE$HNhHwZQU%A$fRxs?mQxem6a4QUOriGJ+|=So?pJ26KIAU~CX?)lN%dA@28xgo7Rz&?+Z(Dmq>|HjC=MJP#*`cjOt?YyF`dk4pC%v{ zWRcc(;utk?^~I-Lfm9Q(lDnua-dC0Us42qK{irFqR0-5_zf=j04u95|Wnf;CRqt&Z#baRC_6l}u zR)yhV!h}SGnz5Ph0L0r!<_9La+Xd%1%P(P_m;)-O6uWg?YxE>gZq6f*#*+xgG>Ivs z{k<>W4>X1GV5*B?+*>&uIzoN9t)WVe(kl59ueN&8lLzQ?un38(z6u`Y;*~LV*{2_Co;nCif`yiCldYzC34xc1?Ir4w~!)7*L5$^)J@+fTp2T z>h?+~)xpRf1QqY9N`raHR)J2Nxe;GV=G-nD^$wv@a^6{=;zk)4W|pI4nJGRg^jxYOyr6eJ)KWim z*=*$dl_(%J!Qj`$;8&45n%UD}*w-+dW9u$7yz+wzHf5vqOG^^k2r#8_=G)kS<=T4A z#l(9qBmwf)7)g(^OC4V6Zm9UPnrlK{Md$p!5L?Pm)mReT7>}Dl?9V4zNlP>VxA+f2K+rn$M+ccDfNb;4;L2+p*C;D`D+lzL zCb=OG29jR69*;PF8nl>w1{~UA3iy&GZ==@4_^YscJf4#%uvv*IQD`Ff#xUU6D+uI0 z!`Z>?MYa5j+cQp@utsFH%Zih^0X#wbjLO

nh)Y}LdFEW*f*BqDB1*NaSBa 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
    sbM8l4!sAD7Pid%5x) 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∨z3VH{^!ejIF!h@Y$l-D%i5x+SuyZdfDRI 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 z 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 pX6rA82SoX=pMb#Sv&)JK){{<|H+j#&0 literal 0 HcmV?d00001 From a13a66304bd3169c964d5472372c3a5742ad018d Mon Sep 17 00:00:00 2001 From: scolear Date: Mon, 14 Sep 2026 17:13:59 +0200 Subject: [PATCH 04/24] build: depend on zip to read the main dalf out of a DAR Pinned DAR uploads pass expected_main_package_id, which needs the sha256 of the main .dalf. The zip crate is already in the graph through utoipa-swagger-ui, so this adds no new code to the build. --- Cargo.lock | 1 + Cargo.toml | 3 +++ crates/decman/Cargo.toml | 1 + 3 files changed, 5 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 432fe84da..5a4c4a852 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1871,6 +1871,7 @@ dependencies = [ "wiremock", "x509-parser", "zeroize", + "zip", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 07a5b160d..9dcbeafcd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,6 +100,9 @@ uuid = { version = "1.11", features = ["v4"] } wiremock = { version = "0.6" } x509-parser = { version = "0.18" } zeroize = { version = "1.8", features = ["derive"] } +# Reads the main .dalf out of a DAR to pin `expected_main_package_id` on +# upload. Already in the graph through utoipa-swagger-ui, so no new code. +zip = { version = "3.0", default-features = false, features = ["deflate"] } canton-common = { package = "common", git = "https://github.com/DLC-link/canton-lib", rev = "651ea5221b4794f55bd152e9f3e762a92af69afd" } canton-proto-rs = { git = "https://github.com/DLC-link/canton-lib", rev = "651ea5221b4794f55bd152e9f3e762a92af69afd" } diff --git a/crates/decman/Cargo.toml b/crates/decman/Cargo.toml index b1f5b2ba2..4738559c3 100644 --- a/crates/decman/Cargo.toml +++ b/crates/decman/Cargo.toml @@ -83,6 +83,7 @@ utoipa-swagger-ui = { workspace = true } uuid = { workspace = true } x509-parser = { workspace = true } zeroize = { workspace = true } +zip = { workspace = true } canton-common = { workspace = true } canton-proto-rs = { workspace = true } From 010a97c01ac528bcc0116b253974a8770fb0e5ed Mon Sep 17 00:00:00 2001 From: scolear Date: Mon, 14 Sep 2026 17:21:55 +0200 Subject: [PATCH 05/24] feat(onledger): add the shared party-key and member-key helpers Every kind driver needs the same key material: the dual-usage {prefix}-key with its self-signed root NamespaceDelegation, the local identity the validator compares against, and the member-key caches that acceptances heal. Keeping them in one module lets the drivers stay thin and lets kick attribution read a single trusted source. --- crates/decman/src/onledger/README.md | 72 +- crates/decman/src/onledger/keys.rs | 901 +++++++++++++++++++++ crates/decman/src/onledger/mod.rs | 4 + crates/decman/src/workflow/signing_keys.rs | 5 +- 4 files changed, 980 insertions(+), 2 deletions(-) create mode 100644 crates/decman/src/onledger/keys.rs diff --git a/crates/decman/src/onledger/README.md b/crates/decman/src/onledger/README.md index 8b712f59e..4f9062ca8 100644 --- a/crates/decman/src/onledger/README.md +++ b/crates/decman/src/onledger/README.md @@ -25,6 +25,7 @@ Every timestamp in this module is an `i64` of **microseconds since the epoch** | `proposals.rs` | `WorkflowProposal` create/accept/decline/cancel/finish, D6 counting, `pending_invitations` projection, archive sweep. | | `topology.rs` | Discovery queries, pending/accepted reads, `propose_mapping`, `cosign_by_hash`, waits, root delegations, canonical mapping builders, unsolicited scan. | | `validation.rs` | `Expectations` and the pure per-kind checks a member runs before it co-signs (design section 5). | +| `keys.rs` | The dual-usage party key and its root delegation, the local identity for validation, and the `dec_party_participant` key caches (design D4). | | `engine/mod.rs` | `StartRequest`, `start_run`, `accept_invitation`, `decline_invitation`, `cancel_run`, `retry_run`, `RunMeta`, `TickCtx`, `KindDriver`, row helpers, quorum arithmetic. | | `engine/{onboarding,add_party,kick,change_threshold,contracts,dars}.rs` | One `KindDriver` per kind with the section-6 step lists. Tick bodies are stubs. | | `observer.rs` | `spawn_observer`: the polling loop, per-run `try_lock`, metrics. | @@ -543,6 +544,75 @@ key to the kicked owner fingerprint on a dual-key party (signing key fingerprints equal the DND owners) and uses elimination alone on a legacy party: exactly one key removed, not this node's, not claimed by a survivor. +## `keys.rs` + +Key material every kind driver shares (design D4). One vault key per member +per new party, named `{prefix}-key`, with usages `[Namespace, Protocol]`. +Its self-signed root `NamespaceDelegation` carries the key bytes into the +synchronizer store; the same key is the member's Daml signing key. Legacy +parties (`{prefix}-namespace` + `{prefix}-daml-transactions`) keep working: +every lookup is chain first, then the local caches, then the legacy vault +names. + +```rust +pub fn party_key_name(prefix: &str) -> String; // "{prefix}-key" +pub fn legacy_namespace_key_name(prefix: &str) -> String; // "{prefix}-namespace" +pub use workflow::signing_keys::party_daml_key_name as legacy_daml_key_name; // "{prefix}-daml-transactions" + +pub struct PartyKey { pub key: SigningPublicKey, pub fingerprint: String, pub key_hex: String } +impl PartyKey { pub fn from_key(key: SigningPublicKey) -> Self; } // fingerprint + lowercase hex of the prost bytes +pub fn decode_key_hex(key_hex: &str) -> Result; +pub fn proposer_key_material(key: &PartyKey) -> engine::ProposerKeyMaterial; // ns fp, key hex, daml fp = ns fp + +pub async fn ensure_party_key(config: &NodeConfig, prefix: &str) -> Result; +pub async fn wait_own_root_delegation(config: &NodeConfig, sync_id: &str, fingerprint: &str, budget: WaitBudget) -> Result<()>; + +pub struct VaultKey { pub name: String, pub key: SigningPublicKey, pub fingerprint: String } +impl VaultKey { pub fn has_usage(&self, usage: SigningKeyUsage) -> bool; } +pub async fn list_vault_keys(config: &NodeConfig) -> Result>; // ListMyKeys, no filter + +pub async fn local_identity_for_party(config: &NodeConfig, db: &SqlitePool, dec_party_id: Option<&CantonId>, + prefix: Option<&str>) -> Result; + +pub async fn kicked_member(config: &NodeConfig, db: &SqlitePool, dec_party_id: &CantonId, + kicked_participant: &CantonId) -> Result>; +pub fn kicked_member_from_rows(rows: &[DecPartyParticipantRow], kicked_uid: &str) -> Option; // pure +pub async fn survivor_key_claims(db: &SqlitePool, dec_party_id: &CantonId) -> Result>; // uid -> daml fp +pub fn key_claims_from_rows(rows: &[DecPartyParticipantRow]) -> BTreeMap; // pure +pub async fn record_member_keys(db: &SqlitePool, dec_party_id: &CantonId, participant: &CantonId, + owner_fp: Option<&str>, signing_fp: Option<&str>) -> Result<()>; +``` + +`ensure_party_key` is idempotent: it reuses the vault key by exact name +(and refuses a same-named key without both usages), then publishes the root +delegation to the Authorized store with `must_fully_authorize = true`, the +same request legacy onboarding sent. The publish is skipped when the +delegation is already in the synchronizer store, or already in the +Authorized store and waiting for dispatch, because Canton rejects a +duplicate mapping. A coordinator calls it in `prepare` before the proposal +exists (D6); a member calls it at its `GenerateKeys` step and then +`wait_own_root_delegation` before it accepts. + +`local_identity_for_party`: `participant_id` from the config. With a party +id, `owner_fingerprints` = Namespace-usage vault keys that own the head DND, +plus the `{prefix}-key` when the vault holds it (an add-party joiner owns +nothing yet); `daml_key_fingerprint` = the one Protocol-usage vault key in +the head P2P `party_signing_keys`, else +`signing_keys::own_signing_key_fingerprint` (`dec_party_identity`, then the +legacy vault name). Without a party id (onboarding), `owner_fingerprints` = +every Namespace-usage vault key and `daml_key_fingerprint` = the +`{prefix}-key`, else the legacy `{prefix}-daml-transactions` key. + +`kicked_member` reads `dec_party_participant.owner_key` / `signing_key` for +the kicked participant from this node's cache only (section 5). `Ok(None)` +means no cached owner key; an error means the cached key is not an owner of +the head DND (stale cache, or the kick already landed). `survivor_key_claims` +returns every cached claim, the kicked member's included; `check_kick_p2p` +skips it by uid. `record_member_keys` writes what a counted acceptance said +about its acceptor (design M6) through `update_participant_owner_key` / +`update_participant_signing_key`; a participant without a cached row is +skipped with a warning until the next parties refresh creates it. + ## `engine/mod.rs` ```rust @@ -733,7 +803,7 @@ pub async fn cleanup_spool(config, party, target) -> Result; ``` DECMAN_SKIP_FRONTEND=1 cargo check -p decman --tests -DECMAN_SKIP_FRONTEND=1 cargo test -p decman --lib onledger +DECMAN_SKIP_FRONTEND=1 cargo test -p decman --lib onledger # onledger::keys for this file alone DECMAN_SKIP_FRONTEND=1 cargo clippy -p decman --all-targets --all-features --no-deps -- -D warnings cargo fmt -p decman -- --check ``` diff --git a/crates/decman/src/onledger/keys.rs b/crates/decman/src/onledger/keys.rs new file mode 100644 index 000000000..b952178a4 --- /dev/null +++ b/crates/decman/src/onledger/keys.rs @@ -0,0 +1,901 @@ +//! Key material shared by every kind driver (design D4). +//! +//! A new decentralized party gets one vault key per member, named +//! `{prefix}-key`, with usages `[Namespace, Protocol]`. Its self-signed root +//! `NamespaceDelegation` carries the public key into the synchronizer store, +//! where the proposer reads it back as the single source of key bytes. The +//! same key is the member's Daml signing key, so the DND owner fingerprint +//! and the party signing-key fingerprint are equal and a kick can attribute +//! keys on-chain. +//! +//! Legacy parties hold two keys (`{prefix}-namespace` and +//! `{prefix}-daml-transactions`). Every lookup here checks the chain first, +//! then the local caches, then the legacy vault names, so both models work. +//! +//! The vault helpers were copied from +//! `workflow/onboarding/steps/generate_keys.rs`, which the Noise removal +//! deletes. + +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Context, Result, bail}; +use canton_proto_rs::com::digitalasset::canton::{ + crypto::{ + admin::v30::{ + GenerateSigningKeyRequest, ListKeysFilters, ListMyKeysRequest, + generate_signing_key_response, private_key_metadata, + vault_service_client::VaultServiceClient, + }, + v30::{SigningKeySpec, SigningKeyUsage, SigningPublicKey, public_key}, + }, + protocol::v30::{ + NamespaceDelegation, TopologyMapping, enums::TopologyChangeOp, namespace_delegation, + topology_mapping, + }, + topology::admin::v30::{ + AuthorizeRequest, BaseQuery, ListNamespaceDelegationRequest, StoreId, authorize_request, + base_query, store_id, + topology_manager_read_service_client::TopologyManagerReadServiceClient, + }, +}; +use common::canton_id::CantonId; +use prost::Message; +use sqlx::SqlitePool; + +use crate::{ + config::NodeConfig, + db::{ + rows::DecPartyParticipantRow, + schema::{Commitable, SchemaRead, SchemaWrite}, + }, + utils, + workflow::{signing_keys, topology::authorize_with_topology_retry}, +}; + +use super::{ + engine::ProposerKeyMaterial, + topology::{self, WaitBudget}, + validation::{KickedMember, LocalIdentity}, +}; + +/// The legacy Daml signing-key name, `{prefix}-daml-transactions`. +pub use crate::workflow::signing_keys::party_daml_key_name as legacy_daml_key_name; + +type Channel = tonic::transport::Channel; + +// --------------------------------------------------------------------------- +// Names +// --------------------------------------------------------------------------- + +/// The vault name of the dual-usage key a member holds for a new party +/// (design D4). Generation is idempotent by this name. +pub fn party_key_name(prefix: &str) -> String { + format!("{prefix}-key") +} + +/// The legacy namespace-key name, `{prefix}-namespace`. Same derivation as +/// `OnboardingConfig::namespace_key_name` and `AddPartyConfig::namespace_key_name`. +pub fn legacy_namespace_key_name(prefix: &str) -> String { + format!("{prefix}-namespace") +} + +// --------------------------------------------------------------------------- +// The dual key +// --------------------------------------------------------------------------- + +/// One member's dual-usage key for one party. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PartyKey { + pub key: SigningPublicKey, + /// `compute_fingerprint(key)`: the DND owner and the Daml key fingerprint. + pub fingerprint: String, + /// Lowercase hex of the prost-encoded `SigningPublicKey`, as the + /// `signingPublicKeyHex` fields of the proposal and acceptance carry it. + pub key_hex: String, +} + +impl PartyKey { + /// Derive the fingerprint and the hex form from the key bytes. + pub fn from_key(key: SigningPublicKey) -> Self { + let fingerprint = utils::compute_fingerprint(&key); + let key_hex = hex::encode(key.encode_to_vec()); + Self { + key, + fingerprint, + key_hex, + } + } +} + +/// Decode a `signingPublicKeyHex` value back into the key it encodes. +/// +/// # Errors +/// Returns an error when the text is not hex or not a `SigningPublicKey`. +pub fn decode_key_hex(key_hex: &str) -> Result { + let bytes = hex::decode(key_hex).context("signingPublicKeyHex is not hex")?; + SigningPublicKey::decode(bytes.as_slice()) + .context("signingPublicKeyHex is not a SigningPublicKey") +} + +/// The proposal fields a coordinator fills from its own key (design D6). +/// The Daml key fingerprint equals the namespace fingerprint because the +/// dual key is also the Daml key. +pub fn proposer_key_material(key: &PartyKey) -> ProposerKeyMaterial { + ProposerKeyMaterial { + namespace_fingerprint: Some(key.fingerprint.clone()), + signing_public_key_hex: Some(key.key_hex.clone()), + daml_key_fingerprint: Some(key.fingerprint.clone()), + } +} + +/// Make sure this node holds the `{prefix}-key` and that its root +/// `NamespaceDelegation` is on its way to the synchronizer store. +/// +/// The key is reused by name when it exists, because a fresh key would mint +/// a new fingerprint and strand the delegation of the old one. The +/// delegation is published to the Authorized store with +/// `must_fully_authorize = true`, exactly as legacy onboarding did; a +/// delegation already in the synchronizer store, or already authorized and +/// waiting for dispatch, is not proposed again (Canton rejects a duplicate +/// mapping). A run interrupted between key generation and the publish is +/// therefore healed on retry (#261). +/// +/// # Errors +/// Returns an error when the vault or the topology manager fails, or when a +/// key of that name exists without both usages. +pub async fn ensure_party_key(config: &NodeConfig, prefix: &str) -> Result { + let name = party_key_name(prefix); + let mut vault = VaultServiceClient::new(config.admin_channel().await?); + let (key, was_existing) = get_or_create_dual_key(&mut vault, &name).await?; + let party_key = PartyKey::from_key(key); + let fingerprint = party_key.fingerprint.as_str(); + + // A freshly minted key cannot have a delegation yet, so the check is + // skipped for it. + let published = was_existing && root_delegation_published(config, fingerprint).await?; + if published { + tracing::info!( + key = %name, + fingerprint, + "reusing the party key; its root NamespaceDelegation is already published" + ); + } else { + propose_root_delegation(config, &party_key).await?; + } + Ok(party_key) +} + +/// Wait until this node's root delegation is effective in the synchronizer +/// store, so a proposer can read the key bytes from it (design D4). +/// +/// # Errors +/// Returns an error when the budget runs out. +pub async fn wait_own_root_delegation( + config: &NodeConfig, + sync_id: &str, + fingerprint: &str, + budget: WaitBudget, +) -> Result<()> { + topology::wait_owner_root_delegations(config, sync_id, &[fingerprint.to_string()], budget) + .await + .map(|_| ()) +} + +/// Look up a signing key by exact vault name, or generate it with usages +/// `[Namespace, Protocol]`. Returns the key and whether it existed before. +/// +/// Copied from `get_or_create_signing_key` in +/// `workflow/onboarding/steps/generate_keys.rs`, with the dual usage set. +async fn get_or_create_dual_key( + vault: &mut VaultServiceClient, + name: &str, +) -> Result<(SigningPublicKey, bool)> { + let usages = [ + SigningKeyUsage::Namespace as i32, + SigningKeyUsage::Protocol as i32, + ]; + + let existing = vault + .list_my_keys(tonic::Request::new(ListMyKeysRequest { + filters: Some(ListKeysFilters { + fingerprint: String::new(), + name: name.to_string(), + purpose: vec![], + usage_v30: vec![], + }), + base_request: None, + })) + .await + .context("ListMyKeys")? + .into_inner(); + + // The name filter is a match on the text, not an exact match, so the + // name is compared again here. + let existing = existing + .private_keys_metadata + .into_iter() + .filter_map(VaultKey::from_metadata) + .find(|k| k.name == name); + if let Some(vault_key) = existing { + if !usages.iter().all(|u| vault_key.key.usage.contains(u)) { + bail!( + "vault key '{name}' exists with usages {:?}, not [Namespace, Protocol]; \ + rename or remove it before this party is created", + vault_key.key.usage + ); + } + tracing::info!(key = %name, "reusing the existing party key"); + return Ok((vault_key.key, true)); + } + + tracing::debug!(key = %name, "generating the party key"); + // The key spec stays UNSPECIFIED so the node picks its own default: + // Ed25519 on the JCE provider, P-256 on a KMS node. A hardcoded spec + // broke party creation on KMS nodes (#264). + let response = vault + .generate_signing_key(tonic::Request::new(GenerateSigningKeyRequest { + key_spec: SigningKeySpec::Unspecified as i32, + name: name.to_string(), + usage_v30: usages.to_vec(), + base_request: None, + })) + .await + .context("GenerateSigningKey")? + .into_inner(); + let Some(generate_signing_key_response::PublicKey::V30(key)) = response.public_key else { + bail!("GenerateSigningKey returned no public key for '{name}'"); + }; + Ok((key, false)) +} + +/// Is the root delegation of `fingerprint` in the synchronizer store, or at +/// least authorized locally and waiting for dispatch? +/// +/// The synchronizer store is what a proposer reads, so it is checked first. +/// The Authorized store is checked second because a delegation that is +/// authorized but not yet dispatched must not be proposed again. +async fn root_delegation_published(config: &NodeConfig, fingerprint: &str) -> Result { + let sync_id = utils::get_synchronizer_id(config).await?; + if topology::read_root_delegation(config, &sync_id, fingerprint) + .await? + .is_some() + { + return Ok(true); + } + root_delegation_authorized(config, fingerprint).await +} + +/// Is the root delegation of `fingerprint` in this participant's Authorized +/// store? Copied from `namespace_delegation_exists` in +/// `workflow/onboarding/steps/generate_keys.rs`. +async fn root_delegation_authorized(config: &NodeConfig, fingerprint: &str) -> Result { + let mut client = TopologyManagerReadServiceClient::new(config.admin_channel().await?); + let response = client + .list_namespace_delegation(tonic::Request::new(ListNamespaceDelegationRequest { + base_query: Some(BaseQuery { + store: Some(authorized_store()), + proposals: false, + operation: 0, + time_query: Some(base_query::TimeQuery::HeadState(())), + filter_signed_key: String::new(), + protocol_version: None, + client_version: None, + }), + filter_namespace: fingerprint.to_string(), + filter_target_key_fingerprint: fingerprint.to_string(), + })) + .await + .context("ListNamespaceDelegation (Authorized store)")? + .into_inner(); + Ok(!response.results.is_empty()) +} + +/// Publish the self-signed root delegation of `key` to the Authorized store. +/// Copied from `propose_namespace_delegation` in +/// `workflow/onboarding/steps/generate_keys.rs`; the request is unchanged. +async fn propose_root_delegation(config: &NodeConfig, key: &PartyKey) -> Result<()> { + tracing::debug!(fingerprint = %key.fingerprint, "proposing the root NamespaceDelegation"); + let delegation = NamespaceDelegation { + namespace: key.fingerprint.clone(), + // target == namespace makes this the root of the namespace. + target_key: Some(key.key.clone()), + #[allow(deprecated)] + is_root_delegation: false, + // With `is_root_delegation = false`, only this restriction lets the + // key sign every mapping type, delegations included. + restriction: Some(namespace_delegation::Restriction::CanSignAllMappings( + namespace_delegation::CanSignAllMappings {}, + )), + }; + let request = AuthorizeRequest { + r#type: Some(authorize_request::Type::Proposal( + authorize_request::Proposal { + change: TopologyChangeOp::AddReplace as i32, + serial: 0, + mapping: Some(authorize_request::proposal::Mapping::V30(TopologyMapping { + mapping: Some(topology_mapping::Mapping::NamespaceDelegation(delegation)), + })), + }, + )), + must_fully_authorize: true, + force_changes: vec![], + signed_by: vec![], + store: Some(authorized_store()), + wait_to_become_effective: None, + }; + authorize_with_topology_retry(config, request, "root NamespaceDelegation").await?; + tracing::info!(fingerprint = %key.fingerprint, "root NamespaceDelegation proposed"); + Ok(()) +} + +fn authorized_store() -> StoreId { + StoreId { + store: Some(store_id::Store::Authorized(store_id::Authorized {})), + } +} + +// --------------------------------------------------------------------------- +// The local vault +// --------------------------------------------------------------------------- + +/// One signing key of this node's vault, with the name the vault knows it by. +#[derive(Clone, Debug, PartialEq)] +pub struct VaultKey { + pub name: String, + pub key: SigningPublicKey, + pub fingerprint: String, +} + +impl VaultKey { + fn from_metadata( + meta: canton_proto_rs::com::digitalasset::canton::crypto::admin::v30::PrivateKeyMetadata, + ) -> Option { + let private_key_metadata::PublicKeyWithName::V30(pkn) = meta.public_key_with_name?; + let public_key::Key::SigningPublicKey(key) = pkn.public_key?.key? else { + return None; + }; + let fingerprint = utils::compute_fingerprint(&key); + Some(Self { + name: pkn.name, + key, + fingerprint, + }) + } + + pub fn has_usage(&self, usage: SigningKeyUsage) -> bool { + self.key.usage.contains(&(usage as i32)) + } +} + +/// Every signing key in this node's vault (`ListMyKeys`, no filter). +/// +/// # Errors +/// Returns an error when the vault call fails. +pub async fn list_vault_keys(config: &NodeConfig) -> Result> { + let mut vault = VaultServiceClient::new(config.admin_channel().await?); + let response = vault + .list_my_keys(tonic::Request::new(ListMyKeysRequest { + filters: None, + base_request: None, + })) + .await + .context("ListMyKeys")? + .into_inner(); + Ok(response + .private_keys_metadata + .into_iter() + .filter_map(VaultKey::from_metadata) + .collect()) +} + +// --------------------------------------------------------------------------- +// Local identity (design section 5 inputs) +// --------------------------------------------------------------------------- + +/// This node as the validation rules must see it, for one party or for a +/// party that does not exist yet. +/// +/// `owner_fingerprints`: with a party id, the Namespace-usage vault keys +/// that own the head DND, plus the `{prefix}-key` when the vault holds it +/// (an add-party joiner owns nothing yet). Without a party id, every +/// Namespace-usage vault key, since onboarding has no DND to compare with. +/// +/// `daml_key_fingerprint`: chain first (`party_signing_keys` of the head +/// P2P intersected with the vault), then the `dec_party_identity` bundle, +/// then the legacy vault name; without a party, the `{prefix}-key` and then +/// the legacy `{prefix}-daml-transactions` key. +/// +/// # Errors +/// Returns an error when the vault or a topology read fails. +pub async fn local_identity_for_party( + config: &NodeConfig, + db: &SqlitePool, + dec_party_id: Option<&CantonId>, + prefix: Option<&str>, +) -> Result { + let participant_id = config.participant_id().clone(); + let vault = list_vault_keys(config).await?; + let prefix = prefix.or(dec_party_id.map(|id| id.prefix.as_str())); + + let Some(party) = dec_party_id else { + return Ok(LocalIdentity { + participant_id, + owner_fingerprints: owner_fingerprints_from(&vault, None, prefix), + daml_key_fingerprint: prefix.and_then(|p| vault_daml_key_for_prefix(&vault, p)), + }); + }; + + let sync_id = utils::get_synchronizer_id(config).await?; + let head_owners: BTreeSet = + topology::read_accepted_dnd(config, &sync_id, &party.namespace.to_string()) + .await? + .map(|dnd| dnd.mapping.owners.into_iter().collect()) + .unwrap_or_default(); + let owner_fingerprints = owner_fingerprints_from(&vault, Some(&head_owners), prefix); + + let head_keys: BTreeSet = topology::read_accepted_p2p(config, &sync_id, party) + .await? + .map(|p2p| super::validation::key_fingerprints(&p2p.mapping)) + .unwrap_or_default(); + let daml_key_fingerprint = match on_chain_daml_key(&vault, &head_keys) { + Some(fp) => Some(fp), + None => signing_keys::own_signing_key_fingerprint(config, db, party).await?, + }; + + Ok(LocalIdentity { + participant_id, + owner_fingerprints, + daml_key_fingerprint, + }) +} + +/// The Namespace-usage vault fingerprints that count as "mine" for a party. +/// See [`local_identity_for_party`] for the two branches. +fn owner_fingerprints_from( + vault: &[VaultKey], + head_owners: Option<&BTreeSet>, + prefix: Option<&str>, +) -> BTreeSet { + let namespace_keys = vault + .iter() + .filter(|k| k.has_usage(SigningKeyUsage::Namespace)); + match head_owners { + None => namespace_keys.map(|k| k.fingerprint.clone()).collect(), + Some(owners) => { + let dual_name = prefix.map(party_key_name); + namespace_keys + .filter(|k| { + owners.contains(&k.fingerprint) || dual_name.as_deref() == Some(&k.name) + }) + .map(|k| k.fingerprint.clone()) + .collect() + } + } +} + +/// The one Protocol-usage vault key that is also a party signing key on the +/// head P2P. `None` when there is no match, or when two keys match: two +/// matches cannot be attributed and the caches decide. +fn on_chain_daml_key(vault: &[VaultKey], head_keys: &BTreeSet) -> Option { + let mut matches = vault + .iter() + .filter(|k| k.has_usage(SigningKeyUsage::Protocol) && head_keys.contains(&k.fingerprint)) + .map(|k| k.fingerprint.clone()); + let first = matches.next()?; + if let Some(second) = matches.next() { + tracing::warn!( + first, + second, + "two vault keys are party signing keys of the same party; falling back to the caches" + ); + return None; + } + Some(first) +} + +/// The Daml key this node would contribute to a party with `prefix` that +/// has no P2P yet: the dual key, or else the legacy Daml key. +fn vault_daml_key_for_prefix(vault: &[VaultKey], prefix: &str) -> Option { + let dual = party_key_name(prefix); + let legacy = legacy_daml_key_name(prefix); + [dual, legacy].iter().find_map(|name| { + vault + .iter() + .find(|k| k.name == *name && k.has_usage(SigningKeyUsage::Protocol)) + .map(|k| k.fingerprint.clone()) + }) +} + +// --------------------------------------------------------------------------- +// Member key caches (`dec_party_participant`) +// --------------------------------------------------------------------------- + +/// The member a kick removes, from this node's own cache and never from the +/// proposer (design section 5). +/// +/// `Ok(None)`: no cached owner key for that participant, so the kick DND +/// cannot be validated. The cached fingerprint is cross-checked against the +/// head DND, because a fingerprint that is not an owner would fail every +/// later check with a less useful message. +/// +/// # Errors +/// Returns an error when the cache and the head DND disagree, or when a read +/// fails. +pub async fn kicked_member( + config: &NodeConfig, + db: &SqlitePool, + dec_party_id: &CantonId, + kicked_participant: &CantonId, +) -> Result> { + let rows = db.get_dec_party_participants(dec_party_id).await?; + let Some(member) = kicked_member_from_rows(&rows, &kicked_participant.to_string()) else { + return Ok(None); + }; + + let sync_id = utils::get_synchronizer_id(config).await?; + let head = topology::read_accepted_dnd(config, &sync_id, &dec_party_id.namespace.to_string()) + .await? + .with_context(|| { + format!("{dec_party_id} has no accepted DecentralizedNamespaceDefinition") + })?; + if !head.mapping.owners.contains(&member.owner_fingerprint) { + bail!( + "cached owner key {} of {kicked_participant} is not an owner of {dec_party_id} at \ + serial {}: the cache is stale, or the kick already landed", + member.owner_fingerprint, + head.serial + ); + } + Ok(Some(member)) +} + +/// The pure half of [`kicked_member`]: the row of `kicked_uid` with a cached +/// owner key, as a [`KickedMember`]. +pub fn kicked_member_from_rows( + rows: &[DecPartyParticipantRow], + kicked_uid: &str, +) -> Option { + rows.iter() + .find(|r| r.participant_uid == kicked_uid) + .and_then(|r| { + r.owner_key.as_ref().map(|owner| KickedMember { + participant_id: r.participant_uid.clone(), + owner_fingerprint: owner.clone(), + signing_key_fingerprint: r.signing_key.clone(), + }) + }) +} + +/// Which Daml signing key each cached member claims: participant uid to +/// fingerprint, from `dec_party_participant.signing_key`. The kicked member's +/// own claim is included when cached; the kick check skips it by uid. +/// +/// # Errors +/// Returns an error when the read fails. +pub async fn survivor_key_claims( + db: &SqlitePool, + dec_party_id: &CantonId, +) -> Result> { + Ok(key_claims_from_rows( + &db.get_dec_party_participants(dec_party_id).await?, + )) +} + +/// The pure half of [`survivor_key_claims`]. +pub fn key_claims_from_rows(rows: &[DecPartyParticipantRow]) -> BTreeMap { + rows.iter() + .filter_map(|r| { + r.signing_key + .as_ref() + .map(|fp| (r.participant_uid.clone(), fp.clone())) + }) + .collect() +} + +/// Record what a member said about its own keys in an acceptance, so the +/// caches a later kick reads are filled by the member itself (design M6). +/// `None` leaves the column alone. A participant without a cached row is +/// skipped with a warning: the row appears with the next parties refresh, +/// and the caller may record again then. +/// +/// # Errors +/// Returns an error when the write fails. +pub async fn record_member_keys( + db: &SqlitePool, + dec_party_id: &CantonId, + participant: &CantonId, + owner_fp: Option<&str>, + signing_fp: Option<&str>, +) -> Result<()> { + if owner_fp.is_none() && signing_fp.is_none() { + return Ok(()); + } + let uid = participant.to_string(); + let known = db + .get_dec_party_participants(dec_party_id) + .await? + .iter() + .any(|r| r.participant_uid == uid); + if !known { + tracing::warn!( + party = %dec_party_id, + participant = %uid, + "no cached participant row; member keys not recorded yet" + ); + return Ok(()); + } + + let mut tx = db.begin_transaction().await?; + if let Some(owner) = owner_fp { + tx.update_participant_owner_key(dec_party_id, &uid, owner) + .await?; + } + if let Some(signing) = signing_fp { + tx.update_participant_signing_key(dec_party_id, &uid, signing) + .await?; + } + Commitable::commit(tx).await?; + tracing::debug!( + party = %dec_party_id, + participant = %uid, + owner = owner_fp.unwrap_or("-"), + signing = signing_fp.unwrap_or("-"), + "member keys recorded" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + db::{MIGRATOR, rows::DecPartyRow}, + onledger::topology::tests::key, + }; + + const NS: &str = "1220aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn vault_key(name: &str, seed: u8, usages: &[SigningKeyUsage]) -> VaultKey { + let mut key = key(seed); + key.usage = usages.iter().map(|u| *u as i32).collect(); + let fingerprint = utils::compute_fingerprint(&key); + VaultKey { + name: name.to_string(), + key, + fingerprint, + } + } + + fn fp(seed: u8) -> String { + utils::compute_fingerprint(&key(seed)) + } + + fn row(uid: &str, owner: Option<&str>, signing: Option<&str>) -> DecPartyParticipantRow { + DecPartyParticipantRow { + dec_party_id: format!("net::{NS}"), + participant_uid: uid.to_string(), + permission: "confirmation".to_string(), + owner_key: owner.map(str::to_string), + signing_key: signing.map(str::to_string), + } + } + + #[test] + fn names_follow_the_prefix() { + assert_eq!(party_key_name("acme"), "acme-key"); + assert_eq!(legacy_namespace_key_name("acme"), "acme-namespace"); + assert_eq!(legacy_daml_key_name("acme"), "acme-daml-transactions"); + } + + #[test] + fn key_hex_round_trips_through_prost() -> Result<()> { + let original = key(7); + let party_key = PartyKey::from_key(original.clone()); + + assert_eq!(party_key.fingerprint, utils::compute_fingerprint(&original)); + assert_eq!(party_key.key_hex, party_key.key_hex.to_lowercase()); + assert_eq!(decode_key_hex(&party_key.key_hex)?, original); + Ok(()) + } + + #[test] + fn key_hex_rejects_garbage() { + assert!(decode_key_hex("zz").is_err()); + } + + #[test] + fn proposer_material_uses_one_fingerprint_twice() { + let party_key = PartyKey::from_key(key(3)); + let material = proposer_key_material(&party_key); + + assert_eq!( + material.namespace_fingerprint.as_deref(), + Some(party_key.fingerprint.as_str()) + ); + assert_eq!( + material.daml_key_fingerprint.as_deref(), + Some(party_key.fingerprint.as_str()) + ); + assert_eq!( + material.signing_public_key_hex.as_deref(), + Some(party_key.key_hex.as_str()) + ); + } + + #[test] + fn owner_fingerprints_without_a_party_are_every_namespace_key() { + let vault = vec![ + vault_key("root", 1, &[SigningKeyUsage::Namespace]), + vault_key( + "acme-key", + 2, + &[SigningKeyUsage::Namespace, SigningKeyUsage::Protocol], + ), + vault_key("acme-daml-transactions", 3, &[SigningKeyUsage::Protocol]), + ]; + + let owners = owner_fingerprints_from(&vault, None, Some("acme")); + + assert_eq!(owners, BTreeSet::from([fp(1), fp(2)])); + } + + /// A member of a legacy party holds several namespace keys; only the one + /// that owns this party's DND counts. A joiner's fresh `{prefix}-key` + /// counts too, because the proposed DND adds it. + #[test] + fn owner_fingerprints_with_a_party_are_head_owners_plus_the_party_key() { + let vault = vec![ + vault_key("root", 1, &[SigningKeyUsage::Namespace]), + vault_key("other-namespace", 2, &[SigningKeyUsage::Namespace]), + vault_key( + "net-key", + 3, + &[SigningKeyUsage::Namespace, SigningKeyUsage::Protocol], + ), + ]; + let head = BTreeSet::from([fp(2), fp(9)]); + + assert_eq!( + owner_fingerprints_from(&vault, Some(&head), Some("net")), + BTreeSet::from([fp(2), fp(3)]) + ); + assert_eq!( + owner_fingerprints_from(&vault, Some(&head), None), + BTreeSet::from([fp(2)]) + ); + } + + #[test] + fn on_chain_daml_key_needs_exactly_one_match() { + let vault = vec![ + vault_key("a", 1, &[SigningKeyUsage::Protocol]), + vault_key("b", 2, &[SigningKeyUsage::Protocol]), + vault_key("ns", 3, &[SigningKeyUsage::Namespace]), + ]; + + assert_eq!( + on_chain_daml_key(&vault, &BTreeSet::from([fp(1), fp(8)])), + Some(fp(1)) + ); + // A Namespace-only key is not a party signing key even when listed. + assert_eq!(on_chain_daml_key(&vault, &BTreeSet::from([fp(3)])), None); + assert_eq!( + on_chain_daml_key(&vault, &BTreeSet::from([fp(1), fp(2)])), + None + ); + assert_eq!(on_chain_daml_key(&vault, &BTreeSet::new()), None); + } + + #[test] + fn vault_daml_key_prefers_the_dual_key() { + let vault = vec![ + vault_key("net-daml-transactions", 1, &[SigningKeyUsage::Protocol]), + vault_key( + "net-key", + 2, + &[SigningKeyUsage::Namespace, SigningKeyUsage::Protocol], + ), + ]; + assert_eq!(vault_daml_key_for_prefix(&vault, "net"), Some(fp(2))); + assert_eq!(vault_daml_key_for_prefix(&vault[..1], "net"), Some(fp(1))); + assert_eq!(vault_daml_key_for_prefix(&vault, "other"), None); + } + + #[test] + fn kicked_member_needs_a_cached_owner_key() { + let rows = vec![ + row("p1::1220aa", Some("owner-1"), Some("daml-1")), + row("p2::1220bb", None, Some("daml-2")), + ]; + + assert_eq!( + kicked_member_from_rows(&rows, "p1::1220aa"), + Some(KickedMember { + participant_id: "p1::1220aa".to_string(), + owner_fingerprint: "owner-1".to_string(), + signing_key_fingerprint: Some("daml-1".to_string()), + }) + ); + assert_eq!(kicked_member_from_rows(&rows, "p2::1220bb"), None); + assert_eq!(kicked_member_from_rows(&rows, "p3::1220cc"), None); + } + + #[test] + fn key_claims_skip_members_without_a_signing_key() { + let rows = vec![ + row("p1::1220aa", Some("owner-1"), Some("daml-1")), + row("p2::1220bb", Some("owner-2"), None), + ]; + + assert_eq!( + key_claims_from_rows(&rows), + BTreeMap::from([("p1::1220aa".to_string(), "daml-1".to_string())]) + ); + } + + async fn seed_party(pool: &SqlitePool, party: &CantonId, uids: &[&str]) -> Result<()> { + let mut tx = pool.begin_transaction().await?; + tx.upsert_dec_party(&DecPartyRow { + party_id: party.to_string(), + prefix: party.prefix.clone(), + threshold: 1, + updated_at: 0, + my_owner_key: None, + }) + .await?; + let rows: Vec = + uids.iter().map(|uid| row(uid, None, None)).collect(); + tx.replace_dec_party_participants(party, &rows).await?; + Commitable::commit(tx).await + } + + #[sqlx::test(migrator = "MIGRATOR")] + async fn record_member_keys_fills_the_cache_a_kick_reads(pool: SqlitePool) -> Result<()> { + let party = CantonId::parse(&format!("net::{NS}"))?; + let p1 = CantonId::parse( + "p1::1220aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + )?; + let p2 = CantonId::parse( + "p2::1220bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + )?; + seed_party(&pool, &party, &[&p1.to_string(), &p2.to_string()]).await?; + + record_member_keys(&pool, &party, &p1, Some("owner-1"), Some("daml-1")).await?; + // A partial record leaves the other column alone. + record_member_keys(&pool, &party, &p2, None, Some("daml-2")).await?; + + let rows = pool.get_dec_party_participants(&party).await?; + assert_eq!( + kicked_member_from_rows(&rows, &p1.to_string()), + Some(KickedMember { + participant_id: p1.to_string(), + owner_fingerprint: "owner-1".to_string(), + signing_key_fingerprint: Some("daml-1".to_string()), + }) + ); + assert_eq!(kicked_member_from_rows(&rows, &p2.to_string()), None); + assert_eq!( + survivor_key_claims(&pool, &party).await?, + BTreeMap::from([ + (p1.to_string(), "daml-1".to_string()), + (p2.to_string(), "daml-2".to_string()), + ]) + ); + Ok(()) + } + + #[sqlx::test(migrator = "MIGRATOR")] + async fn record_member_keys_skips_an_unknown_participant(pool: SqlitePool) -> Result<()> { + let party = CantonId::parse(&format!("net::{NS}"))?; + let p1 = CantonId::parse( + "p1::1220aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + )?; + seed_party(&pool, &party, &[]).await?; + + record_member_keys(&pool, &party, &p1, Some("owner-1"), None).await?; + + assert!(pool.get_dec_party_participants(&party).await?.is_empty()); + assert!(survivor_key_claims(&pool, &party).await?.is_empty()); + Ok(()) + } +} diff --git a/crates/decman/src/onledger/mod.rs b/crates/decman/src/onledger/mod.rs index 47f040e30..45c23efce 100644 --- a/crates/decman/src/onledger/mod.rs +++ b/crates/decman/src/onledger/mod.rs @@ -13,6 +13,8 @@ //! the D6 counting predicates, and the `pending_invitations` projection. //! * [`topology`]: proposal discovery, propose, co-sign by hash, waits, root //! delegations, and the canonical mapping builders. +//! * [`keys`]: the dual-usage party key, its root delegation, the local +//! identity, and the member key caches (design D4). //! * [`validation`]: what a member checks before it co-signs. //! * [`engine`]: start, accept, decline, cancel, retry, and the per-kind //! [`engine::KindDriver`] contract the observer dispatches to. @@ -28,6 +30,7 @@ pub mod daml; pub mod dars; pub mod engine; pub mod identity; +pub mod keys; pub mod observer; pub mod proposals; pub mod registry; @@ -59,6 +62,7 @@ pub use engine::{ StartedRun, TickCtx, accept_invitation, cancel_run, decline_invitation, retry_run, start_run, }; pub use identity::{HostingCheck, NodeIdentity, require_node_identity, verify_hosting}; +pub use keys::{PartyKey, ensure_party_key, party_key_name, proposer_key_material}; pub use observer::spawn_observer; pub use registry::{PeerHealth, PeerHealthSnapshot, PublishOutcome}; pub use topology::UnsolicitedProposal; diff --git a/crates/decman/src/workflow/signing_keys.rs b/crates/decman/src/workflow/signing_keys.rs index f40ad7592..296ff5e4d 100644 --- a/crates/decman/src/workflow/signing_keys.rs +++ b/crates/decman/src/workflow/signing_keys.rs @@ -50,7 +50,10 @@ pub fn party_daml_key_name(party_id_prefix: &str) -> String { /// This node's own Daml signing-key fingerprint for a party: from its /// long-lived identity row, falling back to the vault key named after the /// party. Returns `None` when the node holds neither. -async fn own_signing_key_fingerprint( +/// +/// `pub(crate)` so `onledger::keys::local_identity_for_party` can fall back +/// to it after the on-chain lookup. +pub(crate) async fn own_signing_key_fingerprint( config: &NodeConfig, db: &SqlitePool, dec_party_id: &CantonId, From df4eb28da8d9a9889eeaa4c570768d2ec69ab521 Mon Sep 17 00:00:00 2001 From: scolear Date: Tue, 15 Sep 2026 09:53:42 +0200 Subject: [PATCH 06/24] feat: coordinate every workflow through Canton and remove Noise The six workflow kinds now run on the on-ledger engine. A run starts as a WorkflowProposal; invitees accept on the ledger; topology changes are proposed into the synchronizer store and co-signed by transaction hash; contract deployments collect prepared-transaction signatures in SubmissionRounds; DAR distribution pins hashes and observes vetting; add-party replication publishes an AcsManifest and takes the snapshot through operator-driven export and import endpoints. With that in place the Noise transport has no caller. This removes the listener, the peer client and server, the heartbeat and ping loops, the Noise key file, the peer public keys and ports, the ten Noise CLI flags, and the hyper-noise, tokio-noise and secp256k1 dependencies. Migration 000021 fails any run that was in flight, renames the coordinator column to the participant id, drops the peer address columns, and adds the proposal columns the projection needs. Peer health is the registry heartbeat. The CLI, the integration harness, and the test crate compile against the new shapes; the coordination phases still carry TODO markers for their rewrite. --- .cargo/audit.toml | 15 +- .github/workflows/ci.yml | 2 + .github/workflows/release.yml | 10 +- Cargo.lock | 262 +- Cargo.toml | 7 - Dockerfile | 7 +- crates/common/src/api.rs | 194 +- crates/common/src/coordination.rs | 102 + crates/common/src/types.rs | 168 +- crates/decman-cli/src/api.rs | 166 +- crates/decman-cli/src/app.rs | 116 +- crates/decman-cli/src/ui.rs | 246 +- crates/decman-wallet/src/flow.rs | 4 +- crates/decman/Cargo.toml | 6 +- crates/decman/frontend/src/App.tsx | 18 +- .../src/components/AddPartyDialog.tsx | 3 +- .../frontend/src/components/DarsDialog.tsx | 5 +- .../frontend/src/components/KickDialog.tsx | 6 +- .../src/components/NetworkConfigAccordion.tsx | 207 +- .../src/components/NodeHealthCard.tsx | 67 +- .../src/components/NotificationsView.tsx | 43 +- .../src/components/OnboardingDialog.tsx | 9 +- .../src/components/PeersCsvDialog.test.tsx | 52 +- .../src/components/PeersCsvDialog.tsx | 30 +- .../frontend/src/components/StatusDot.tsx | 35 +- .../components/viz/WorkflowPipeline.test.tsx | 16 +- crates/decman/frontend/src/noiseKey.test.ts | 39 - crates/decman/frontend/src/noiseKey.ts | 48 - crates/decman/frontend/src/peerCsv.test.ts | 184 +- crates/decman/frontend/src/peerCsv.ts | 59 +- crates/decman/frontend/src/peers.test.ts | 70 + crates/decman/frontend/src/peers.ts | 85 + .../decman/frontend/src/workflowSteps.test.ts | 200 +- crates/decman/frontend/src/workflowSteps.ts | 531 +-- .../migrations/000021_noise_sunset.down.sql | 16 + .../migrations/000021_noise_sunset.up.sql | 43 + crates/decman/src/bin/gen_types.rs | 25 +- crates/decman/src/build_info.rs | 6 +- crates/decman/src/cli.rs | 60 +- crates/decman/src/config.rs | 192 +- crates/decman/src/consts.rs | 97 +- crates/decman/src/db/rows.rs | 174 +- crates/decman/src/db/schema.rs | 22 +- crates/decman/src/db/sqlite.rs | 375 +- crates/decman/src/lib.rs | 9 +- crates/decman/src/main.rs | 45 +- crates/decman/src/noise/client.rs | 578 --- crates/decman/src/noise/mod.rs | 1623 -------- crates/decman/src/noise/server.rs | 796 ---- crates/decman/src/onledger/README.md | 86 +- crates/decman/src/onledger/acs.rs | 1978 +++++++++- crates/decman/src/onledger/dars.rs | 1096 +++++- .../decman/src/onledger/engine/add_party.rs | 2166 ++++++++++- .../src/onledger/engine/change_threshold.rs | 1210 +++++- .../decman/src/onledger/engine/contracts.rs | 1189 +++++- crates/decman/src/onledger/engine/dars.rs | 452 ++- crates/decman/src/onledger/engine/kick.rs | 1412 ++++++- crates/decman/src/onledger/engine/mod.rs | 196 +- .../decman/src/onledger/engine/onboarding.rs | 1632 +++++++- crates/decman/src/onledger/keys.rs | 2 +- crates/decman/src/onledger/mod.rs | 26 + crates/decman/src/onledger/observer.rs | 30 +- crates/decman/src/onledger/proposals.rs | 42 +- crates/decman/src/onledger/registry.rs | 27 +- crates/decman/src/onledger/submission.rs | 2201 ++++++++++- crates/decman/src/onledger/topology.rs | 21 +- crates/decman/src/server/chain_audit.rs | 4 +- crates/decman/src/server/handlers/auth.rs | 5 - crates/decman/src/server/handlers/config.rs | 148 +- .../src/server/handlers/coordination.rs | 320 ++ .../decman/src/server/handlers/governance.rs | 135 +- .../decman/src/server/handlers/invitations.rs | 476 +-- crates/decman/src/server/handlers/keys.rs | 29 - crates/decman/src/server/handlers/mod.rs | 8 +- crates/decman/src/server/handlers/parties.rs | 873 ++--- .../src/server/handlers/party_config.rs | 15 - .../decman/src/server/handlers/workflows.rs | 3356 +++-------------- crates/decman/src/server/health.rs | 239 -- crates/decman/src/server/middleware/auth.rs | 5 - crates/decman/src/server/mod.rs | 2072 +--------- crates/decman/src/server/node_health.rs | 5 + crates/decman/src/server/peer_status.rs | 110 - crates/decman/src/server/types.rs | 394 +- crates/decman/src/signing/signer.rs | 2 +- crates/decman/src/utils.rs | 2 +- .../src/workflow/add_party/coordinator.rs | 735 ---- crates/decman/src/workflow/add_party/mod.rs | 80 +- crates/decman/src/workflow/add_party/peer.rs | 107 - .../add_party/steps/clear_onboarding.rs | 208 - .../workflow/add_party/steps/export_state.rs | 102 - .../workflow/add_party/steps/generate_keys.rs | 102 - .../src/workflow/add_party/steps/mod.rs | 9 - .../add_party/steps/proposals/create.rs | 226 -- .../workflow/add_party/steps/proposals/mod.rs | 7 - .../add_party/steps/proposals/sign.rs | 36 - .../add_party/steps/proposals/submit.rs | 176 - .../workflow/change_threshold/coordinator.rs | 176 - .../src/workflow/change_threshold/mod.rs | 37 +- .../src/workflow/change_threshold/peer.rs | 52 - .../workflow/change_threshold/steps/mod.rs | 2 - .../steps/proposals/create.rs | 223 -- .../change_threshold/steps/proposals/mod.rs | 7 - .../change_threshold/steps/proposals/sign.rs | 31 - .../steps/proposals/submit.rs | 210 -- .../src/workflow/contracts/coordinator.rs | 224 -- crates/decman/src/workflow/contracts/mod.rs | 16 +- crates/decman/src/workflow/contracts/peer.rs | 41 - .../decman/src/workflow/dars/coordinator.rs | 131 - crates/decman/src/workflow/dars/mod.rs | 15 +- .../decman/src/workflow/kick/coordinator.rs | 294 -- crates/decman/src/workflow/kick/mod.rs | 23 +- crates/decman/src/workflow/kick/peer.rs | 49 - crates/decman/src/workflow/kick/steps/mod.rs | 2 - .../workflow/kick/steps/proposals/create.rs | 286 -- .../src/workflow/kick/steps/proposals/mod.rs | 7 - .../src/workflow/kick/steps/proposals/sign.rs | 31 - .../workflow/kick/steps/proposals/submit.rs | 163 - crates/decman/src/workflow/mod.rs | 1659 +------- .../src/workflow/onboarding/coordinator.rs | 299 -- crates/decman/src/workflow/onboarding/mod.rs | 25 +- crates/decman/src/workflow/onboarding/peer.rs | 147 - .../onboarding/steps/generate_keys.rs | 302 -- .../src/workflow/onboarding/steps/mod.rs | 8 - .../onboarding/steps/proposals/create.rs | 413 -- .../onboarding/steps/proposals/mod.rs | 7 - .../onboarding/steps/proposals/sign.rs | 122 - .../onboarding/steps/proposals/submit.rs | 593 --- .../src/workflow/party_replication/acs.rs | 4 +- .../src/workflow/party_replication/mod.rs | 8 +- .../src/workflow/party_replication/pipe.rs | 136 +- .../src/workflow/party_replication/relay.rs | 13 +- crates/decman/src/workflow/signing_keys.rs | 35 +- crates/decman/src/workflow/state.rs | 20 +- crates/decman/src/workflow/topology.rs | 281 +- crates/decman/src/workflow/validation.rs | 12 +- crates/decman/tests/common/chaos.rs | 59 +- crates/decman/tests/common/db.rs | 6 +- crates/decman/tests/common/invitations.rs | 22 +- crates/decman/tests/common/mod.rs | 19 +- .../common/phases/add_party_edge_cases.rs | 5 + .../tests/common/phases/cancel_cascades.rs | 7 +- .../tests/common/phases/check_peer_dars.rs | 125 +- .../phases/concurrent_cross_workflows.rs | 5 + .../phases/concurrent_sibling_cancel.rs | 5 + .../phases/concurrent_sibling_decline.rs | 5 + .../tests/common/phases/distribute_dars.rs | 36 +- .../common/phases/generate_keys_idempotent.rs | 5 + .../decman/tests/common/phases/invite_cap.rs | 109 - .../phases/invite_survives_peer_restart.rs | 5 + crates/decman/tests/common/phases/kick.rs | 2 +- crates/decman/tests/common/phases/mod.rs | 4 - .../tests/common/phases/notification_feed.rs | 6 +- .../common/phases/owner_key_resilience.rs | 10 +- .../common/phases/peer_3_strikes_abort.rs | 23 - .../tests/common/phases/peer_health_flip.rs | 97 - .../phases/restart_coordinator_resume.rs | 5 + .../common/phases/restart_peer_resume.rs | 5 + .../phases/restart_with_concurrent_kinds.rs | 5 + .../phases/retry_coordinator_broadcast.rs | 5 + .../common/phases/retry_with_offline_peer.rs | 117 - .../phases/start_handler_conflict_409.rs | 9 +- .../tests/common/phases/two_member_party.rs | 6 + crates/decman/tests/common/processes.rs | 52 +- crates/decman/tests/governance_workflows.rs | 50 +- deny.toml | 13 +- development/Dockerfile | 2 +- development/docker-compose.yml | 6 - development/remote/participant-1/.env.example | 19 +- development/remote/participant-2/.env.example | 19 +- development/remote/participant-3/.env.example | 19 +- development/remote/participant-4/.env.example | 19 +- integration-tests/bring-up.sh | 5 +- integration-tests/common.sh | 256 +- integration-tests/devnet.env.sh | 11 +- integration-tests/env.sh | 40 +- integration-tests/run.sh | 29 +- integration-tests/smoke-noise-errors.sh | 182 - justfile | 14 + 178 files changed, 17204 insertions(+), 21168 deletions(-) delete mode 100644 crates/decman/frontend/src/noiseKey.test.ts delete mode 100644 crates/decman/frontend/src/noiseKey.ts create mode 100644 crates/decman/frontend/src/peers.test.ts create mode 100644 crates/decman/frontend/src/peers.ts create mode 100644 crates/decman/migrations/000021_noise_sunset.down.sql create mode 100644 crates/decman/migrations/000021_noise_sunset.up.sql delete mode 100644 crates/decman/src/noise/client.rs delete mode 100644 crates/decman/src/noise/mod.rs delete mode 100644 crates/decman/src/noise/server.rs create mode 100644 crates/decman/src/server/handlers/coordination.rs delete mode 100644 crates/decman/src/server/handlers/keys.rs delete mode 100644 crates/decman/src/server/health.rs delete mode 100644 crates/decman/src/server/peer_status.rs delete mode 100644 crates/decman/src/workflow/add_party/coordinator.rs delete mode 100644 crates/decman/src/workflow/add_party/peer.rs delete mode 100644 crates/decman/src/workflow/add_party/steps/clear_onboarding.rs delete mode 100644 crates/decman/src/workflow/add_party/steps/export_state.rs delete mode 100644 crates/decman/src/workflow/add_party/steps/generate_keys.rs delete mode 100644 crates/decman/src/workflow/add_party/steps/mod.rs delete mode 100644 crates/decman/src/workflow/add_party/steps/proposals/create.rs delete mode 100644 crates/decman/src/workflow/add_party/steps/proposals/mod.rs delete mode 100644 crates/decman/src/workflow/add_party/steps/proposals/sign.rs delete mode 100644 crates/decman/src/workflow/add_party/steps/proposals/submit.rs delete mode 100644 crates/decman/src/workflow/change_threshold/coordinator.rs delete mode 100644 crates/decman/src/workflow/change_threshold/peer.rs delete mode 100644 crates/decman/src/workflow/change_threshold/steps/proposals/create.rs delete mode 100644 crates/decman/src/workflow/change_threshold/steps/proposals/mod.rs delete mode 100644 crates/decman/src/workflow/change_threshold/steps/proposals/sign.rs delete mode 100644 crates/decman/src/workflow/change_threshold/steps/proposals/submit.rs delete mode 100644 crates/decman/src/workflow/contracts/coordinator.rs delete mode 100644 crates/decman/src/workflow/contracts/peer.rs delete mode 100644 crates/decman/src/workflow/dars/coordinator.rs delete mode 100644 crates/decman/src/workflow/kick/coordinator.rs delete mode 100644 crates/decman/src/workflow/kick/peer.rs delete mode 100644 crates/decman/src/workflow/kick/steps/proposals/create.rs delete mode 100644 crates/decman/src/workflow/kick/steps/proposals/mod.rs delete mode 100644 crates/decman/src/workflow/kick/steps/proposals/sign.rs delete mode 100644 crates/decman/src/workflow/kick/steps/proposals/submit.rs delete mode 100644 crates/decman/src/workflow/onboarding/coordinator.rs delete mode 100644 crates/decman/src/workflow/onboarding/peer.rs delete mode 100644 crates/decman/src/workflow/onboarding/steps/generate_keys.rs delete mode 100644 crates/decman/src/workflow/onboarding/steps/mod.rs delete mode 100644 crates/decman/src/workflow/onboarding/steps/proposals/create.rs delete mode 100644 crates/decman/src/workflow/onboarding/steps/proposals/mod.rs delete mode 100644 crates/decman/src/workflow/onboarding/steps/proposals/sign.rs delete mode 100644 crates/decman/src/workflow/onboarding/steps/proposals/submit.rs delete mode 100644 crates/decman/tests/common/phases/invite_cap.rs delete mode 100644 crates/decman/tests/common/phases/peer_3_strikes_abort.rs delete mode 100644 crates/decman/tests/common/phases/peer_health_flip.rs delete mode 100644 crates/decman/tests/common/phases/retry_with_offline_peer.rs delete mode 100755 integration-tests/smoke-noise-errors.sh diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 934f45138..46572264c 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -32,17 +32,16 @@ ignore = [ # the 0.3 line — 0.3.27 is the last release ever published, and the patch # landed only in 0.4.16 — so this cannot be resolved by updating. # - # Two paths pull it, neither removable from here: - # * actix-http (actix-web 4), whose `http2` is a default feature; - # * hyper 0.14, required by hyper-noise 0.0.3, which declares - # `hyper = { features = ["full"] }` itself. Narrowing our own hyper - # features changes nothing, because feature unification keeps h2 in the - # graph regardless. + # Two paths pull it: + # * actix-http (actix-web 4), whose `http2` is a default feature. Not + # removable from here. + # * decman's own `hyper = "0.14"`, which has no caller left after the move + # to on-ledger coordination and is kept only until a follow-up drops it. + # Removing it does NOT clear the advisory: actix-http still pulls h2 0.3. # # The h2 0.4 instance (AWS SDK / hyper 1.x) IS fixed, by the 0.4.16 bump in # the same change as this ignore. # - # Drop this ignore when hyper-noise moves off hyper 0.14, or actix-web - # ships an h2 0.4 release. Tracked in #351. + # Drop this ignore when actix-web ships an h2 0.4 release. Tracked in #351. "RUSTSEC-2026-0258", ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e2dc4acf..cdbc1d9ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -295,6 +295,8 @@ jobs: # id, and the DAR-verification step above already pins those identical. - name: Verify Daml upgrade lineage run: ~/.dpm/bin/dpm upgrade-check --both */.daml/dist/*.dar ../releases/v1/*.dar + - name: Test decman-coordination + run: cd decman-coordination-test && ~/.dpm/bin/dpm test - name: Test governance-core run: cd governance-core-test && ~/.dpm/bin/dpm test - name: Test governance-token-custody diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ffe3ed56..9c71eb7d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,10 +11,12 @@ jobs: uses: ./.github/workflows/ci.yml secrets: inherit - # Guard the peer-compatibility semver: the version peers gate on - # (MIN_PEER_VERSION) is decman's Cargo.toml version, so a release tag must - # match it. Runs in parallel with `ci` and gates `binary`, so a mismatched - # tag fails fast before the expensive compile. + # Guard the version a node publishes about itself: each node writes its + # Cargo.toml version into its `DecmanNode` registry entry, and operators read + # it to decide what a peer runs. A tag that disagrees with the crate would + # therefore ship a node that misreports itself. Runs in parallel with `ci` + # and gates `binary`, so a mismatched tag fails fast before the expensive + # compile. verify-tag: name: Verify tag matches crate version runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 5a4c4a852..72b503033 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -205,16 +205,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common 0.1.7", - "generic-array", -] - [[package]] name = "aead" version = "0.6.1" @@ -222,18 +212,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ "crypto-common 0.2.2", - "inout 0.2.2", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher 0.4.4", - "cpufeatures 0.2.17", + "inout", ] [[package]] @@ -242,37 +221,23 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" dependencies = [ - "cipher 0.5.2", + "cipher", "cpubits", "cpufeatures 0.3.0", ] -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead 0.5.2", - "aes 0.8.4", - "cipher 0.4.4", - "ctr 0.9.2", - "ghash 0.5.1", - "subtle", -] - [[package]] name = "aes-gcm" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" dependencies = [ - "aead 0.6.1", - "aes 0.9.1", - "cipher 0.5.2", - "ctr 0.10.1", + "aead", + "aes", + "cipher", + "ctr", "ctutils", - "ghash 0.6.0", + "ghash", ] [[package]] @@ -1059,15 +1024,6 @@ dependencies = [ "wyz", ] -[[package]] -name = "blake2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest 0.10.7", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -1283,17 +1239,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "chacha20" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" -dependencies = [ - "cfg-if", - "cipher 0.4.4", - "cpufeatures 0.2.17", -] - [[package]] name = "chacha20" version = "0.10.0" @@ -1305,19 +1250,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "chacha20poly1305" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" -dependencies = [ - "aead 0.5.2", - "chacha20 0.9.1", - "cipher 0.4.4", - "poly1305", - "zeroize", -] - [[package]] name = "chrono" version = "0.4.45" @@ -1332,17 +1264,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout 0.1.4", - "zeroize", -] - [[package]] name = "cipher" version = "0.5.2" @@ -1351,7 +1272,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "block-buffer 0.12.0", "crypto-common 0.2.2", - "inout 0.2.2", + "inout", ] [[package]] @@ -1657,7 +1578,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] @@ -1682,22 +1602,13 @@ dependencies = [ "phf", ] -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher 0.4.4", -] - [[package]] name = "ctr" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher 0.5.2", + "cipher", ] [[package]] @@ -1709,21 +1620,6 @@ dependencies = [ "cmov", ] -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "fiat-crypto 0.2.9", - "rustc_version", - "subtle", - "zeroize", -] - [[package]] name = "curve25519-dalek" version = "5.0.0" @@ -1734,7 +1630,7 @@ dependencies = [ "cpufeatures 0.3.0", "curve25519-dalek-derive", "digest 0.11.3", - "fiat-crypto 0.3.0", + "fiat-crypto", "rustc_version", "subtle", "zeroize", @@ -1811,11 +1707,11 @@ checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" [[package]] name = "decman" -version = "1.8.0" +version = "2.0.0" dependencies = [ "actix-cors", "actix-web", - "aes-gcm 0.11.1", + "aes-gcm", "anyhow", "async-trait", "aws-config", @@ -1835,7 +1731,6 @@ dependencies = [ "hex", "http 0.2.12", "hyper 0.14.32", - "hyper-noise", "insta", "jsonwebtoken", "keycloak", @@ -1848,17 +1743,14 @@ dependencies = [ "registry", "reqwest 0.13.3", "rust-embed", - "secp256k1", "semver", "serde", "serde_json", "sha2 0.11.0", "sqlx", - "static_assertions", "tempfile", "thiserror 2.0.20", "tokio", - "tokio-noise", "tokio-rustls", "tonic", "tracing", @@ -2107,7 +1999,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ - "curve25519-dalek 5.0.0", + "curve25519-dalek", "ed25519", "sha2 0.11.0", "subtle", @@ -2236,12 +2128,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - [[package]] name = "fiat-crypto" version = "0.3.0" @@ -2510,23 +2396,13 @@ dependencies = [ "wasip3", ] -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval 0.6.2", -] - [[package]] name = "ghash" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" dependencies = [ - "polyval 0.7.1", + "polyval", ] [[package]] @@ -2805,18 +2681,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-noise" -version = "0.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d559beceeeec56ccc0f7bd2f3ca30d3903ceb6bc07a76634c769058ea578bf2a" -dependencies = [ - "hyper 0.14.32", - "log", - "tokio", - "tokio-noise", -] - [[package]] name = "hyper-rustls" version = "0.27.9" @@ -3064,15 +2928,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - [[package]] name = "inout" version = "0.2.2" @@ -3699,12 +3554,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "openssl" version = "0.10.80" @@ -4040,29 +3889,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" -[[package]] -name = "poly1305" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" -dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash 0.5.1", -] - -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash 0.5.1", -] - [[package]] name = "polyval" version = "0.7.1" @@ -4071,7 +3897,7 @@ checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" dependencies = [ "cpubits", "cpufeatures 0.3.0", - "universal-hash 0.6.1", + "universal-hash", ] [[package]] @@ -4358,7 +4184,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.0", + "chacha20", "getrandom 0.4.2", "rand_core 0.10.1", ] @@ -5054,25 +4880,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "secp256k1" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" -dependencies = [ - "rand 0.8.6", - "secp256k1-sys", -] - -[[package]] -name = "secp256k1-sys" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" -dependencies = [ - "cc", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -5385,23 +5192,6 @@ dependencies = [ "serde", ] -[[package]] -name = "snow" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "850948bee068e713b8ab860fe1adc4d109676ab4c3b621fd8147f06b261f2f85" -dependencies = [ - "aes-gcm 0.10.3", - "blake2", - "chacha20poly1305", - "curve25519-dalek 4.1.3", - "rand_core 0.6.4", - "ring", - "rustc_version", - "sha2 0.10.9", - "subtle", -] - [[package]] name = "socket2" version = "0.5.10" @@ -6007,16 +5797,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-noise" -version = "0.0.5" -source = "git+https://github.com/conduition/tokio-noise?rev=a7dc4e543e4d117ee5f02125b0c898e1984973f2#a7dc4e543e4d117ee5f02125b0c898e1984973f2" -dependencies = [ - "log", - "snow", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -6398,16 +6178,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common 0.1.7", - "subtle", -] - [[package]] name = "universal-hash" version = "0.6.1" diff --git a/Cargo.toml b/Cargo.toml index 9dcbeafcd..e098bb3e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,6 @@ futures = { version = "0.3" } hex = { version = "0.4" } http = { version = "0.2" } hyper = { version = "0.14", features = ["full"] } -hyper-noise = { version = "0.0.3" } insta = { version = "1", features = ["json"] } jsonwebtoken = { version = "11.0", features = ["aws_lc_rs"] } mime_guess = { version = "2.0" } @@ -55,7 +54,6 @@ ratatui = { version = "0.30.2" } rcgen = { version = "0.14" } rfd = { version = "0.17.2", default-features = false, features = ["xdg-portal"] } rust-embed = { version = "8.5" } -secp256k1 = { version = "0.29.1", features = ["rand-std"] } semver = { version = "1.0" } serde = { version = "1.0", features = ["derive"] } serde-big-array = { version = "0.5.1" } @@ -67,7 +65,6 @@ sqlx = { version = "0.8", default-features = false, features = [ "runtime-tokio-rustls", "sqlite", ] } -static_assertions = { version = "1.1" } tempfile = { version = "3.14" } thiserror = { version = "2.0" } tokio = { version = "1.48.0", features = [ @@ -79,7 +76,6 @@ tokio = { version = "1.48.0", features = [ "signal", "sync", ] } -tokio-noise = { version = "0.0.5" } tokio-rustls = { version = "0.26", default-features = false, features = [ "aws-lc-rs", ] } @@ -112,9 +108,6 @@ decman-lib = { path = "crates/decman-lib" } decman-wallet = { path = "crates/decman-wallet" } keycloak = { git = "https://github.com/DLC-link/canton-lib", rev = "651ea5221b4794f55bd152e9f3e762a92af69afd" } -[patch.crates-io] -tokio-noise = { git = "https://github.com/conduition/tokio-noise", rev = "a7dc4e543e4d117ee5f02125b0c898e1984973f2" } - [profile.release] opt-level = 3 strip = "debuginfo" diff --git a/Dockerfile b/Dockerfile index 6d84646fe..c4bfd229c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,12 +23,13 @@ FROM gcr.io/distroless/cc-debian12:${BASE_TAG} COPY --chmod=0755 dec-party-manager /usr/local/bin/dec-party-manager # Stated explicitly rather than inherited from the base, so the runtime -# identity is visible here. The binary needs no root either way: it binds 8080 -# and 9000, both above 1024, and writes only under its data directory. +# identity is visible here. The binary needs no root either way: it binds +# 8080 (and the metrics port), above 1024, and writes only under its data +# directory. ARG RUNTIME_UID=0 USER ${RUNTIME_UID}:${RUNTIME_UID} -EXPOSE 8080 9000 +EXPOSE 8080 # Build identity, stamped by CI (build.yml / release.yml). DECPM_BUILD_VERSION # is the pushed image tag (releases) or short commit SHA (per-commit images); diff --git a/crates/common/src/api.rs b/crates/common/src/api.rs index 935f7591b..9f89cee2d 100644 --- a/crates/common/src/api.rs +++ b/crates/common/src/api.rs @@ -16,8 +16,8 @@ use serde::{Deserialize, Serialize}; use crate::{ canton_id::CantonId, types::{ - AuditLogEntry, DecentralizedParty, PendingInvitation, Permission, WorkflowKind, - WorkflowProgress, WorkflowRun, + AuditLogEntry, DecentralizedParty, PendingInvitation, Permission, WorkflowProgress, + WorkflowRun, }, }; @@ -262,43 +262,6 @@ pub struct OnboardingRequest { pub threshold: Option, } -/// Why a directed edge was reported missing. The frontend renders different -/// remediation hints depending on which kind it sees: `MeshHole` is a true -/// peer↔peer config gap ("on `from`, add `to` to the network config"), while -/// `UnreachableFromCoordinator` is a coordinator-side reachability problem -/// (the peer is unknown, has no public key, didn't answer, or replied with -/// a malformed payload — fix the coordinator's view of `to`, or `to` itself). -#[derive(Clone, Copy, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "snake_case")] -#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] -pub enum MissingEdgeKind { - UnreachableFromCoordinator, - MeshHole, -} - -/// One directed missing edge in the peer mesh: `from` does not have `to` -/// configured as a peer (`MeshHole`), or the coordinator could not query -/// `to` at all (`UnreachableFromCoordinator`, `from` is the coordinator). -#[derive(Clone, Debug, Serialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] -pub struct MissingPeerEdge { - pub from: String, - pub to: String, - pub kind: MissingEdgeKind, -} - -/// Returned when onboarding pre-flight detects that selected peers are not -/// fully meshed. The workflow is not started. -#[derive(Clone, Debug, Serialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] -pub struct OnboardingMeshErrorResponse { - pub error: String, - pub missing_edges: Vec, -} - /// Request to deploy contracts for a decentralized party #[derive(Clone, Debug, Deserialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] @@ -327,6 +290,11 @@ pub struct DarsRequest { /// Peer IDs to distribute to (required non-empty for /dars/distribute, ignored by /dars/upload) #[serde(default)] pub peer_ids: Vec, + /// `/dars/upload` only: the `runId` of the `WorkflowProposal(kind = Dars)` + /// whose pins the uploaded files must match (design D8). Absent for a + /// plain local upload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pin_instance: Option, } /// Response for workflow initiation (kick, onboarding, etc.) @@ -589,8 +557,7 @@ pub struct TenantAddHostsOnboardResponse { /// The wallet is the transport: it reads block N here and hands block N to the /// joining host, which feeds it straight into its open `ImportPartyAcs` stream. /// Nothing lands on disk at either end, so the size of the party stops deciding -/// whether the transfer is possible — the same property the add-party path gets -/// from its node-to-node pipe. +/// whether the transfer is possible. /// /// Forward-only. The export stream cannot rewind, so a broken transfer restarts /// from block 1 with a fresh export rather than resuming. @@ -779,151 +746,6 @@ pub struct TenantPartyStateResponse { pub has_signing_key: bool, } -/// Response for key status check -#[derive(Serialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] -pub struct KeyStatusResponse { - pub has_keys: bool, - pub public_key: Option, -} - -/// Payload sent inside an `InviteOnboarding` Noise message. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] -pub struct OnboardingInvitePayload { - pub prefix: String, - pub participants: Vec, - /// Initial signing threshold the coordinator will use, so the invitation - /// and run cards can show it before the proposals are built. `None` from - /// an older coordinator that predates the field (the card then omits it). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub threshold: Option, - /// The coordinator's `workflow_runs` instance name for this run. Echoed - /// back in `DeclineInvitationPayload` so the coordinator can tell a - /// decline of THIS run apart from a stale invite of an earlier run. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workflow_instance: Option, -} - -/// Payload sent inside a `DeclineInvitation` Noise message — peer telling -/// the coordinator that it has rejected an outstanding invitation so the -/// coordinator can fail its matching in-progress run with a clear error. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] -pub struct DeclineInvitationPayload { - pub kind: WorkflowKind, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option, - /// The coordinator run this decline targets (from the invite payload). - /// `None` when the invite predates this field — the coordinator then - /// falls back to kind + membership checks only. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workflow_instance: Option, -} - -/// Payload sent inside an `InviteDars` Noise message. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] -pub struct DarsInvitePayload { - pub dar_filenames: Vec, - /// SHA-256 of each DAR's content, hex encoded and index-aligned with - /// `dar_filenames`. A peer pins the accepted content with these, so a - /// coordinator cannot get a different DAR vetted under an accepted name. - /// Empty from a coordinator that predates the field; the peer then falls - /// back to checking filenames only. - #[serde(default)] - pub dar_hashes: Vec, - /// The member set (selected peers) this distribution targets, so the peer - /// card can render the same participant list the coordinator shows. - #[serde(default)] - pub participants: Vec, - /// The coordinator's run instance name (see `OnboardingInvitePayload`). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workflow_instance: Option, -} - -/// Payload sent inside an `InviteKick` Noise message — gives the peer enough -/// context to show "kicking X from dec party Y, threshold a→b" before the kick -/// proposals arrive later in the workflow. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] -pub struct KickInvitePayload { - pub dec_party_id: CantonId, - pub kicked_participant: CantonId, - pub new_threshold: i32, - pub previous_threshold: i32, - /// The surviving member set the kick targets, so the peer card renders - /// the same participant list the coordinator shows. - #[serde(default)] - pub participants: Vec, - /// The coordinator's run instance name (see `OnboardingInvitePayload`). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workflow_instance: Option, -} - -/// Payload sent inside an `InviteAddParty` Noise message — gives the peer -/// enough context to show "adding X to dec party Y, threshold a→b" before -/// the proposals arrive. Sent both to the existing members and to the new -/// member itself (which recognises its own id in `new_participant`). -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] -pub struct AddPartyInvitePayload { - pub dec_party_id: CantonId, - pub new_participant: CantonId, - pub new_threshold: i32, - pub previous_threshold: i32, - /// The full post-add member set, so the peer card renders the same - /// participant list the coordinator shows. - #[serde(default)] - pub participants: Vec, - /// The coordinator's run instance name (see `OnboardingInvitePayload`). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workflow_instance: Option, -} - -/// Payload sent inside an `InviteChangeThreshold` Noise message — gives the -/// peer enough context to show "changing dec party Y threshold a→b" before the -/// proposals arrive. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] -pub struct ChangeThresholdInvitePayload { - pub dec_party_id: CantonId, - pub new_threshold: i32, - pub previous_threshold: i32, - /// The party's member set, so the peer card renders the same participant - /// list the coordinator shows. - #[serde(default)] - pub participants: Vec, - /// The coordinator's run instance name (see `OnboardingInvitePayload`). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workflow_instance: Option, -} - -/// Payload sent inside an `InviteContracts` Noise message — mirrors the rich -/// Kick payload so the peer card can show the dec party, member set, and the -/// package/contract names being deployed before the proposals arrive. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] -pub struct ContractsInvitePayload { - pub dec_party_id: CantonId, - #[serde(default)] - pub participants: Vec, - /// Human-readable contract/package names (from `ContractDefinition.name`). - #[serde(default)] - pub package_names: Vec, - /// The coordinator's run instance name (see `OnboardingInvitePayload`). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workflow_instance: Option, -} - /// Response for pending invitations endpoint #[derive(Deserialize, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] diff --git a/crates/common/src/coordination.rs b/crates/common/src/coordination.rs index 8828fb356..578d6219d 100644 --- a/crates/common/src/coordination.rs +++ b/crates/common/src/coordination.rs @@ -140,3 +140,105 @@ pub struct RegistryResponse { /// another operator added this node before this operator added them. pub inbound: Vec, } + +/// One pending topology proposal in the synchronizer store that no +/// `WorkflowProposal` this node accepted explains (`GET /proposals/unsolicited`). +/// UI only: the observer never signs from this list. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] +pub struct UnsolicitedProposal { + /// `DecentralizedNamespaceDefinition` or `PartyToParticipant`. + pub mapping: String, + /// The namespace or the party id. + pub key: String, + /// Canton hex of the topology transaction hash. + pub hash_hex: String, + pub serial: u32, + /// Fingerprints that already signed the proposal. + pub signed_by: Vec, + /// Micros since the epoch, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sequenced_at: Option, +} + +/// Response of `GET /proposals/unsolicited`. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] +pub struct UnsolicitedProposalsResponse { + pub proposals: Vec, +} + +/// One `AcsManifest` as `GET /acs-manifests/{party}` reports it (design D9). +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] +pub struct AcsManifestView { + pub contract_id: String, + /// The node party that exported the snapshot. + pub exporter: CantonId, + pub exporter_participant: String, + pub dec_party_id: String, + pub target_participant: String, + pub activation_serial: i64, + pub size_bytes: i64, + pub sha256_hex: String, + pub package_ids: Vec, + /// Unix seconds. + pub exported_at: i64, +} + +/// Response of `GET /acs-manifests/{party}`. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] +pub struct AcsManifestsResponse { + pub manifests: Vec, +} + +/// Response of `POST /acs-import/{party}`. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] +pub struct AcsImportResponse { + /// Bytes received and fed to the import. + pub size_bytes: i64, + /// Lowercase hex SHA-256 of the received bytes. + pub sha256_hex: String, + /// The manifest the upload matched. + pub manifest_contract_id: String, +} + +/// Where the startup coordination-DAR upload is (design D8). +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] +#[serde(rename_all = "snake_case")] +pub enum CoordinationDarPhase { + /// The task has not run yet. + Pending, + /// `DECPM_AUTO_UPLOAD_COORDINATION_DAR` is off; the operator uploads. + Disabled, + /// Trying; `attempts` and `last_error` say how it goes. + Uploading, + /// The coordination package is vetted on this participant. + Ready, +} + +/// The startup coordination-DAR task as `GET /node-health` reports it. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] +pub struct CoordinationDarStatus { + pub phase: CoordinationDarPhase, + pub filename: String, + pub main_package_id: String, + pub uploaded: bool, + pub vetted: bool, + pub attempts: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_error: Option, + /// Unix seconds of the last change. + pub updated_at: i64, +} diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs index 31b9f2f3a..26b0eca45 100644 --- a/crates/common/src/types.rs +++ b/crates/common/src/types.rs @@ -5,7 +5,7 @@ //! the `openapi` feature so dependency-light clients don't inherit them — see //! the `cfg_attr` pattern used throughout and in [`crate::canton_id`]. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use serde::{Deserialize, Serialize}; @@ -97,32 +97,24 @@ pub struct PackageInfo { /// Reason a peer was reported `reachable: false` in `PeerPackageResult`. /// -/// Mirrors `NoiseError` variants at a coarser granularity that's stable on -/// the wire — added so the UI / future tooling can distinguish failure -/// modes without having to scan logs. Layered transport-side: TCP connect -/// (timeout/failed), then post-connect request budget (`RequestTimeout`), -/// then mid-stream IO/HTTP (`Transport`); then handshake/decode/status. +/// The vetted packages of a peer come from the synchronizer topology store, +/// so "reachable" means the topology read for that participant succeeded. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] #[serde(rename_all = "snake_case")] pub enum PeerErrorKind { - TcpConnectTimeout, - TcpConnectFailed, - RequestTimeout, - Transport, - HandshakeFailed, - BadStatus, - DecodeFailed, - InvalidPublicKey, + /// `ListVettedPackages` for the participant failed. + TopologyReadFailed, + /// The read succeeded but the participant has vetted no package. + NoVettedPackages, Other, } -/// Result of querying packages from a single peer. +/// Result of reading the vetted packages of a single peer. /// /// `error_kind` is `None` when `reachable: true`. Always `Some(_)` when -/// `reachable: false`. (Decode failures on `reachable: true` responses are -/// not yet surfaced — see Future work item 5 in the spec.) +/// `reachable: false`. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] @@ -170,20 +162,27 @@ pub struct DecentralizedParty { pub local_metadata: Option, } -/// Connection status for a participant +/// Health of a peer, read from its on-ledger registry entry (design D3). +/// +/// The value describes the age of the peer's last heartbeat, not liveness: +/// the UI labels it "last heartbeat N ago". #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] #[serde(rename_all = "PascalCase")] pub enum ConnectionStatus { - /// Current node (always reachable) + /// This node. CurrentNode, - /// Successfully connected via Noise protocol - Connected, - /// Failed to establish TCP connection (peer not reachable) - Unreachable, - /// Noise handshake/decryption failed (likely wrong public key configured) - HandshakeFailed, + /// A registry entry is visible and its last heartbeat is recent. + Active, + /// A registry entry is visible but its last heartbeat is older than the + /// stale factor times the peer's heartbeat interval. + Stale, + /// No registry entry signed by the peer's node party is visible, or this + /// node has no node identity yet. + Unknown, + /// The peer's participant has not vetted the coordination package. + Unvetted, } /// Status of a single participant @@ -193,22 +192,24 @@ pub enum ConnectionStatus { pub struct ParticipantStatus { pub id: String, pub status: ConnectionStatus, - /// Round-trip latency of the health probe, in milliseconds. `None` when the - /// peer is the current node or was unreachable. + /// The peer's node party from the peers table, when the operator entered + /// one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub node_party: Option, + /// Unix seconds of the peer's last heartbeat. `None` without a visible + /// registry entry. #[serde(default, skip_serializing_if = "Option::is_none")] - pub latency_ms: Option, - /// The workflow this peer is currently participating in, if any and if it - /// reported one (peers on older code report `None`). + pub last_seen_at: Option, + /// Seconds since `last_seen_at` when the snapshot was taken. #[serde(default, skip_serializing_if = "Option::is_none")] - pub workflow: Option, + pub heartbeat_age_secs: Option, /// dec-party-manager semver: this node's own version for the current - /// node, or the version a peer reported in its health response. `None` for - /// unreachable peers and peers on older code that don't report one. + /// node, or the version the peer's registry entry carries. #[serde(default, skip_serializing_if = "Option::is_none")] pub version: Option, /// Display build identity (image tag / short SHA / `-dev`) for this - /// node or the one a peer reported. `None` for unreachable peers and peers - /// on code that predates the field. This is what the peers table shows. + /// node or the one the peer's registry entry carries. This is what the + /// peers table shows. #[serde(default, skip_serializing_if = "Option::is_none")] pub build_version: Option, } @@ -353,11 +354,49 @@ impl std::str::FromStr for WorkflowRole { } } -/// `current_step` of the first step of every workflow kind, where the -/// coordinator waits for its invitees to join. Progress on this step is -/// [`WorkflowRun::connected_peers`], not `completed_peers` — nothing completes -/// a step that carries no command. -pub const WAITING_FOR_PEERS_STEP: &str = "WaitingForPeers"; +/// `current_step` of the coordinator step that waits for invitees to accept +/// the `WorkflowProposal`. Progress on this step is +/// [`WorkflowRun::connected_peers`], not `completed_peers`. +pub const WAITING_FOR_ACCEPTANCES_STEP: &str = "WaitingForAcceptances"; + +/// Which member step list a peer row follows (design section 6). +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] +#[serde(rename_all = "PascalCase")] +pub enum MemberVariant { + /// The add-party participant being added. + Joiner, + /// Any other invitee. + Member, +} + +impl MemberVariant { + /// The stored column value; matches the serde representation. + pub fn as_str(self) -> &'static str { + match self { + Self::Joiner => "Joiner", + Self::Member => "Member", + } + } +} + +impl std::fmt::Display for MemberVariant { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for MemberVariant { + type Err = anyhow::Error; + fn from_str(s: &str) -> std::result::Result { + match s { + "Joiner" => Ok(Self::Joiner), + "Member" => Ok(Self::Member), + other => Err(anyhow::anyhow!("unknown member variant: {other}")), + } + } +} /// Which end of an ACS transfer this node is on. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -418,14 +457,27 @@ pub struct WorkflowRun { /// JSON-encoded copy of the original *Config struct that started the /// workflow — the resume path round-trips it back through serde. pub config_json: String, - /// Hex pubkey of the coordinator. None for coordinator-side rows. + /// Participant id of the coordinator. Legacy rows from before the 2.0 + /// upgrade may still carry a transport pubkey here when no peer matched. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coordinator_participant: Option, + /// Node party of the coordinator (the `WorkflowProposal` signatory). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coordinator_party: Option, + /// The `WorkflowProposal` contract id this run follows. `None` on legacy + /// rows from before the 2.0 upgrade; the observer ignores those. #[serde(default, skip_serializing_if = "Option::is_none")] - pub coordinator_pubkey: Option, - /// The coordinator's own run `instance_name` this peer-side row belongs to - /// (the invite's `workflow_instance`). Lets instance-scoped CancelInvite / - /// RetryWorkflow target exactly one of several concurrent runs from the - /// same coordinator, and gives peer resume its routing key. None for - /// coordinator-side rows and for rows that predate instance routing. + pub proposal_cid: Option, + /// Which member step list a peer row follows. `None` on coordinator rows + /// and on kinds with one member step list. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub member_variant: Option, + /// Topology transaction hashes this run pinned, by mapping (`dnd`, `p2p`, + /// `clear`), as Canton hex. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub topology_hashes: BTreeMap, + /// The coordinator's own run `instance_name` (the proposal `runId`) this + /// peer-side row belongs to. None for coordinator-side rows. #[serde(default, skip_serializing_if = "Option::is_none")] pub coordinator_instance: Option, /// Resolved coordinator name from the peers table (server-side join, @@ -434,11 +486,10 @@ pub struct WorkflowRun { pub coordinator_name: Option, pub expected_peers: Vec, pub completed_peers: Vec, - /// Peers that have joined this run — the set the `WaitingForPeers` gate - /// counts, and the only progress a run has to show before its first - /// peer-gated step. Live in-memory state merged in by the API layer from - /// the workflow registry (not a DB column), so it is empty for a run this - /// node does not currently coordinate. + /// Invitees that accepted the `WorkflowProposal`: the set the + /// `WaitingForAcceptances` step counts. Merged in by the API layer from + /// the observer's proposal snapshot (not a DB column), so it is empty for + /// a run this node does not coordinate. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub connected_peers: Vec, /// How far this run's ACS transfer has got, when one is moving. Merged in @@ -541,12 +592,23 @@ impl std::str::FromStr for InvitationType { #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] pub struct PendingInvitation { + /// The `WorkflowProposal` contract id. pub id: String, pub invitation_type: InvitationType, - pub coordinator_pubkey: String, + /// Participant id the proposal claims for the coordinator. + pub coordinator_participant: String, + /// Node party that signed the proposal. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coordinator_party: Option, + /// The `WorkflowProposal` contract id, same as `id`. + #[serde(default)] + pub proposal_cid: String, #[serde(default)] pub coordinator_name: Option, pub received_at: i64, + /// Unix seconds when the proposal expires (`expiresAt`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, /// Onboarding-only: party ID prefix the coordinator chose. #[serde(default, skip_serializing_if = "Option::is_none")] pub prefix: Option, diff --git a/crates/decman-cli/src/api.rs b/crates/decman-cli/src/api.rs index af1882325..f24189af2 100644 --- a/crates/decman-cli/src/api.rs +++ b/crates/decman-cli/src/api.rs @@ -1,11 +1,11 @@ use std::collections::HashMap; -use std::time::{Duration, Instant}; +use std::time::Duration; use anyhow::{Context, Result, bail}; use common::types::{ AuditLogEntry, AuthConfigResponse, ConnectionStatus, DecentralizedParty, ParticipantStatus, - ParticipantsStatusResponse, PeerPackageComparison, PendingInvitation, VettedPackageInfo, - WorkflowInfo, WorkflowKind, WorkflowRun, + ParticipantsStatusResponse, PeerPackageComparison, PendingInvitation, Permission, + VettedPackageInfo, WorkflowKind, WorkflowRun, }; use reqwest::StatusCode; use reqwest::blocking::{Client, Response}; @@ -57,14 +57,16 @@ fn sort_vetted(packages: &mut [VettedPackageInfo]) { pub struct PeerView { pub participant_id: String, pub name: String, - pub address: String, - pub port: u16, + /// The peer's node party, when the operator configured one. A peer without + /// a node party cannot be invited to a workflow. + pub party: Option, /// Live connectivity status, or `None` when no status was reported for this /// peer (rendered as "Unknown"). pub status: Option, - pub latency_ms: Option, + /// Seconds since the peer's last registry heartbeat. Nodes no longer probe + /// each other, so the age of the heartbeat is the freshness signal. + pub heartbeat_age_secs: Option, pub version: Option, - pub workflow: Option, pub is_self: bool, } @@ -75,16 +77,14 @@ struct NetworkConfigResponse { peers: Vec, } -/// A configured peer (name, address) from `/network-config`. +/// A configured peer (name, node party) from `/network-config`. #[derive(Debug, Deserialize)] struct PeerConfig { participant_id: String, #[serde(default)] name: String, #[serde(default)] - address: String, - #[serde(default)] - port: u16, + party: Option, } /// `/node-config` response: this node's own identity and version. The backend @@ -100,12 +100,6 @@ struct NodeConfigResponse { #[derive(Debug, Deserialize)] struct NodeIdentity { participant_id: String, - #[serde(default)] - public_address: Option, - #[serde(default)] - listen_address: String, - #[serde(default)] - port: u16, } /// Display name for a peer: its configured name, else the id prefix. @@ -121,19 +115,6 @@ fn display_name(name: &str, participant_id: &str) -> String { } } -/// Format a peer's active workflow as `Kind (step)` (or just `Kind`). -fn format_workflow(workflow: &WorkflowInfo) -> String { - if workflow.step.is_empty() { - workflow.kind.to_string() - } else { - format!( - "{kind} ({step})", - kind = workflow.kind, - step = workflow.step - ) - } -} - /// Merge this node and the configured peers with their live statuses, putting /// this node first. If this node is not in the peer list, it is synthesized /// from `/node-config` so it is always shown — exactly like the web frontend. @@ -141,7 +122,6 @@ fn merge_peers( node: &NodeConfigResponse, peers: &[PeerConfig], statuses: &[ParticipantStatus], - self_latency_ms: Option, ) -> Vec { let by_id: HashMap<&str, &ParticipantStatus> = statuses.iter().map(|s| (s.id.as_str(), s)).collect(); @@ -162,41 +142,35 @@ fn merge_peers( PeerView { participant_id: peer.participant_id.clone(), name: display_name(&peer.name, &peer.participant_id), - address: peer.address.clone(), - port: peer.port, + // The configured party wins: it is what this node invites. The + // status snapshot only echoes it back. + party: peer + .party + .clone() + .or_else(|| live.and_then(|s| s.node_party.as_ref().map(ToString::to_string))), status, - // Peers report a measured round-trip; self uses our own - // API round-trip instead (a peer never probes itself). - latency_ms: if is_self { - self_latency_ms + // This node never heartbeats to itself, so its own row has no age. + heartbeat_age_secs: if is_self { + None } else { - live.and_then(|s| s.latency_ms) + live.and_then(|s| s.heartbeat_age_secs) }, version: live .and_then(|s| s.version.clone()) .or_else(|| if is_self { node.version.clone() } else { None }), - workflow: live.and_then(|s| s.workflow.as_ref()).map(format_workflow), is_self, } }) .collect(); if !self_seen { - let address = node - .node - .public_address - .clone() - .filter(|a| !a.is_empty()) - .unwrap_or_else(|| node.node.listen_address.clone()); views.push(PeerView { participant_id: self_id.to_owned(), name: display_name("", self_id), - address, - port: node.node.port, + party: None, status: Some(ConnectionStatus::CurrentNode), - latency_ms: self_latency_ms, + heartbeat_age_secs: None, version: node.version.clone(), - workflow: None, is_self: true, }); } @@ -354,14 +328,20 @@ struct OperatorInfoResponse { party_id: String, } -/// `/keys/status` response: whether this node's Noise keypair exists and, if so, -/// its public key (the value peers configure to reach this node). +/// `/node-identity` response: the node party this node signs its coordination +/// contracts with, and how this participant hosts it (design D1). #[derive(Clone, Debug, Deserialize)] -pub struct KeyStatus { +pub struct NodeIdentityView { + #[serde(default)] + pub configured: bool, + #[serde(default)] + pub node_party_id: Option, + #[serde(default)] + pub participant_id: String, #[serde(default)] - pub has_keys: bool, + pub user_id: Option, #[serde(default)] - pub public_key: Option, + pub hosting_permission: Option, } /// `/network-info` response: the DSO identity this node is bound to. The @@ -410,8 +390,8 @@ pub struct DiscoverResult { pub primary_party: Option, } -/// A fully-detailed configured peer, for the network-config editor (the -/// merged [`PeerView`] drops `public_key` / `party`, which editing needs). +/// A configured peer as the network-config editor holds it (design D2): the +/// three values operators exchange out of band. #[derive(Clone, Debug, Deserialize)] pub struct PeerEntry { #[serde(default)] @@ -419,12 +399,6 @@ pub struct PeerEntry { #[serde(default)] pub name: String, #[serde(default)] - pub address: String, - #[serde(default)] - pub port: u16, - #[serde(default)] - pub public_key: String, - #[serde(default)] pub party: Option, } @@ -771,19 +745,10 @@ impl DecmanClient { /// Returns an error if login or either request fails, the API returns a /// non-success status, or a response cannot be parsed. pub fn fetch_peers(&mut self) -> Result> { - // Time one round-trip to our own API as the self-node latency (the web - // frontend shows the same "you" latency), so this node's row is not blank. - let start = Instant::now(); let node: NodeConfigResponse = self.get_json("/node-config")?; - let self_latency_ms = u64::try_from(start.elapsed().as_millis()).ok(); let config: NetworkConfigResponse = self.get_json("/network-config")?; let statuses: ParticipantsStatusResponse = self.get_json("/participants-status")?; - Ok(merge_peers( - &node, - &config.peers, - &statuses.statuses, - self_latency_ms, - )) + Ok(merge_peers(&node, &config.peers, &statuses.statuses)) } /// Fetch the Daml packages (DARs) vetted on this node, sorted like the @@ -1192,14 +1157,14 @@ impl DecmanClient { Ok(response.party_id) } - /// Fetch this node's Noise key status (existence + public key). + /// Fetch this node's node-party identity (design D1). /// /// # Errors /// /// Returns an error if login or the request fails, the API returns a /// non-success status, or the response cannot be parsed. - pub fn fetch_key_status(&mut self) -> Result { - self.get_json("/keys/status") + pub fn fetch_node_identity(&mut self) -> Result { + self.get_json("/node-identity") } /// Fetch the DSO network identity this node is bound to. @@ -1767,22 +1732,20 @@ mod tests { NodeConfigResponse { node: NodeIdentity { participant_id: participant_id.to_owned(), - public_address: Some("node.local".to_owned()), - listen_address: "0.0.0.0".to_owned(), - port: 9000, }, version: Some("0.9.1".to_owned()), } } - fn status(id: &str, workflow: Option) -> ParticipantStatus { + fn status(id: &str, heartbeat_age_secs: Option) -> ParticipantStatus { ParticipantStatus { id: id.to_owned(), - status: ConnectionStatus::Connected, - latency_ms: Some(12), + status: ConnectionStatus::Active, + node_party: None, + last_seen_at: Some(1_700_000_000), + heartbeat_age_secs, version: Some("1.2.3".to_owned()), build_version: Some("v1.2.3".to_owned()), - workflow, } } @@ -1793,41 +1756,30 @@ mod tests { PeerConfig { participant_id: "peer1::1220".to_owned(), name: "alpha".to_owned(), - address: "10.0.0.1".to_owned(), - port: 9001, + party: Some("alpha-node::1220".to_owned()), }, PeerConfig { participant_id: "self::1220".to_owned(), name: "me".to_owned(), - address: "127.0.0.1".to_owned(), - port: 9000, + party: None, }, ]; - let statuses = [status( - "peer1::1220", - Some(WorkflowInfo { - kind: common::types::WorkflowKind::Onboarding, - role: common::types::WorkflowRole::Peer, - step: "Sign".to_owned(), - step_index: 0, - step_total: 0, - }), - )]; + let statuses = [status("peer1::1220", Some(12))]; // Act - let views = merge_peers(&node("self::1220"), &peers, &statuses, Some(7)); + let views = merge_peers(&node("self::1220"), &peers, &statuses); - // Assert — self first, peer status/latency/workflow mapped. + // Assert — self first, peer status and heartbeat age mapped. assert_eq!(views.len(), 2); assert_eq!(views[0].name, "me"); assert!(views[0].is_self); assert_eq!(views[0].status, Some(ConnectionStatus::CurrentNode)); - // Self shows our own API round-trip latency, not a peer probe. - assert_eq!(views[0].latency_ms, Some(7)); + // This node never heartbeats to itself. + assert_eq!(views[0].heartbeat_age_secs, None); assert_eq!(views[1].name, "alpha"); - assert_eq!(views[1].status, Some(ConnectionStatus::Connected)); - assert_eq!(views[1].latency_ms, Some(12)); - assert_eq!(views[1].workflow.as_deref(), Some("Onboarding (Sign)")); + assert_eq!(views[1].status, Some(ConnectionStatus::Active)); + assert_eq!(views[1].heartbeat_age_secs, Some(12)); + assert_eq!(views[1].party.as_deref(), Some("alpha-node::1220")); } #[test] @@ -1836,22 +1788,22 @@ mod tests { let peers = [PeerConfig { participant_id: "ghost::1220".to_owned(), name: "ghost".to_owned(), - address: "10.0.0.9".to_owned(), - port: 9009, + party: None, }]; // Act - let views = merge_peers(&node("self::1220"), &peers, &[], Some(9)); + let views = merge_peers(&node("self::1220"), &peers, &[]); // Assert — self synthesized and listed first; missing status → None (Unknown). assert_eq!(views.len(), 2); assert!(views[0].is_self); assert_eq!(views[0].status, Some(ConnectionStatus::CurrentNode)); assert_eq!(views[0].version.as_deref(), Some("0.9.1")); - assert_eq!(views[0].latency_ms, Some(9)); + assert_eq!(views[0].heartbeat_age_secs, None); assert!(!views[1].is_self); assert_eq!(views[1].name, "ghost"); assert_eq!(views[1].status, None); + assert_eq!(views[1].party, None); } #[test] diff --git a/crates/decman-cli/src/app.rs b/crates/decman-cli/src/app.rs index 6008dc479..49bd4c67a 100644 --- a/crates/decman-cli/src/app.rs +++ b/crates/decman-cli/src/app.rs @@ -21,8 +21,9 @@ use common::types::{ use crate::api::{ AuthSettings, ChainAuditEntry, DecmanClient, DiscoverResult, DomainGovAction, ExecuteParams, - FeedItem, GovAction, GovConfirmation, GovState, GovernanceConfirmations, Holding, KeyStatus, - KnownMember, NetworkInfo, PartyAuthStatus, PartyConfigView, PeerEntry, PeerView, party_name, + FeedItem, GovAction, GovConfirmation, GovState, GovernanceConfirmations, Holding, KnownMember, + NetworkInfo, NodeIdentityView, PartyAuthStatus, PartyConfigView, PeerEntry, PeerView, + party_name, }; use crate::composer::{ self, Composer, ComposerContext, ComposerSubmit, FieldKind, PickerOption, PickerSource, @@ -91,11 +92,12 @@ pub fn run_login(terminal: &mut DefaultTerminal, profiles: &[Profile]) -> Result } } -/// How often the background thread re-probes peers (matches the web frontend). +/// How often the background thread re-reads the peer status (matches the web +/// frontend). pub const PEER_POLL_INTERVAL: Duration = Duration::from_secs(2); /// Longest the input loop blocks before redrawing, so background updates -/// (loaded data, live peer latency, spinner) appear without a keypress. +/// (loaded data, fresh peer status, spinner) appear without a keypress. const TICK: Duration = Duration::from_millis(200); /// A request to the background fetcher. @@ -195,10 +197,11 @@ pub enum Update { PickerOptions(Vec<(PickerSource, Result, String>)>), } -/// This node's key status and DSO network identity, each fetched independently -/// so one failing (e.g. the DSO API is unreachable) does not hide the other. +/// This node's coordination identity and DSO network identity, each fetched +/// independently so one failing (e.g. the DSO API is unreachable) does not hide +/// the other. pub struct NodeInfoData { - pub key: Result, + pub identity: Result, pub network: Result, } @@ -381,11 +384,8 @@ pub struct GovActionRequest { pub struct PeerForm { pub participant_id: String, pub name: String, - pub address: String, - pub port: String, - pub public_key: String, pub party: String, - /// 0..=5 select a field; 6 is the submit row. + /// 0..=2 select a field; 3 is the submit row. pub cursor: usize, } @@ -394,9 +394,6 @@ impl PeerForm { Self { participant_id: String::new(), name: String::new(), - address: String::new(), - port: String::new(), - public_key: String::new(), party: String::new(), cursor: 0, } @@ -899,7 +896,7 @@ fn handle_request(client: &mut DecmanClient, request: Request) -> Update { Update::Discovered(client.discover_member_party(*body).map_err(err)) } Request::NodeInfo => Update::NodeInfo(Box::new(NodeInfoData { - key: client.fetch_key_status().map_err(err), + identity: client.fetch_node_identity().map_err(err), network: client.fetch_network_info().map_err(err), })), Request::PickerOptions { party_id, sources } => { @@ -1074,30 +1071,20 @@ fn confirmation_cids(confirmations: &[GovConfirmation]) -> Vec { /// Validate the add-peer form into a [`PeerEntry`], or return the index of the /// first invalid field so the cursor can land on it. Required: participant id -/// (0), address (2), a non-zero `u16` port (3) and public key (4); name (1) and -/// party (5) are optional. +/// (0) and node party (2); a peer without a node party cannot be invited to a +/// workflow (design D2). The name (1) is optional. fn validate_peer_form(form: &PeerForm) -> Result { if form.participant_id.trim().is_empty() { return Err(0); } - if form.address.trim().is_empty() { + let party = form.party.trim(); + if party.is_empty() { return Err(2); } - let port: u16 = match form.port.trim().parse() { - Ok(port) if port > 0 => port, - _ => return Err(3), - }; - if form.public_key.trim().is_empty() { - return Err(4); - } - let party = form.party.trim(); Ok(PeerEntry { participant_id: form.participant_id.trim().to_owned(), name: form.name.trim().to_owned(), - address: form.address.trim().to_owned(), - port, - public_key: form.public_key.trim().to_owned(), - party: (!party.is_empty()).then(|| party.to_owned()), + party: Some(party.to_owned()), }) } @@ -1111,9 +1098,6 @@ fn peers_to_json(peers: &[PeerEntry]) -> Value { json!({ "participant_id": peer.participant_id, "name": peer.name, - "address": peer.address, - "port": peer.port, - "public_key": peer.public_key, "party": peer.party, }) }) @@ -2243,28 +2227,22 @@ impl App { Some(form) => match key.code { KeyCode::Esc => state.adding = None, KeyCode::Up => form.cursor = form.cursor.saturating_sub(1), - KeyCode::Down | KeyCode::Tab if form.cursor < 6 => form.cursor += 1, - KeyCode::Enter if form.cursor >= 6 => match validate_peer_form(form) { + KeyCode::Down | KeyCode::Tab if form.cursor < 3 => form.cursor += 1, + KeyCode::Enter if form.cursor >= 3 => match validate_peer_form(form) { // Only add a valid peer; otherwise land the cursor on - // the first invalid field rather than writing port 0. + // the first invalid field rather than writing a peer + // that no workflow can invite. Ok(peer) => { state.peers.push(peer); state.adding = None; } Err(invalid_field) => form.cursor = invalid_field, }, - KeyCode::Enter if form.cursor < 6 => form.cursor += 1, + KeyCode::Enter if form.cursor < 3 => form.cursor += 1, KeyCode::Char(c) => match form.cursor { 0 => form.participant_id.push(c), 1 => form.name.push(c), - 2 => form.address.push(c), - 3 => { - if c.is_ascii_digit() { - form.port.push(c); - } - } - 4 => form.public_key.push(c), - 5 => form.party.push(c), + 2 => form.party.push(c), _ => {} }, KeyCode::Backspace => match form.cursor { @@ -2275,15 +2253,6 @@ impl App { form.name.pop(); } 2 => { - form.address.pop(); - } - 3 => { - form.port.pop(); - } - 4 => { - form.public_key.pop(); - } - 5 => { form.party.pop(); } _ => {} @@ -3607,34 +3576,47 @@ mod tests { } #[test] - fn validate_peer_form_rejects_blank_and_zero_port() { + fn validate_peer_form_requires_id_and_party() { let full = PeerForm { participant_id: "alpha::1220".to_owned(), name: "alpha".to_owned(), - address: "10.0.0.1".to_owned(), - port: "9001".to_owned(), - public_key: "abcd".to_owned(), - party: String::new(), + party: "alpha-node::1220".to_owned(), cursor: 0, }; match validate_peer_form(&full) { - Ok(peer) => assert_eq!(peer.port, 9001), + Ok(peer) => { + assert_eq!(peer.participant_id, "alpha::1220"); + assert_eq!(peer.party.as_deref(), Some("alpha-node::1220")); + } Err(field) => panic!("expected a valid peer, got invalid field {field}"), } - // Blank port → cursor should land on the port field (index 3). - let mut blank_port = full; - blank_port.port = String::new(); - assert!(matches!(validate_peer_form(&blank_port), Err(3))); + // Blank party → cursor lands on the party field (index 2). + let mut no_party = full; + no_party.party = String::new(); + assert!(matches!(validate_peer_form(&no_party), Err(2))); // Missing participant id → field 0. let mut no_id = PeerForm::blank(); - no_id.address = "10.0.0.1".to_owned(); - no_id.port = "9001".to_owned(); - no_id.public_key = "abcd".to_owned(); + no_id.party = "alpha-node::1220".to_owned(); assert!(matches!(validate_peer_form(&no_id), Err(0))); } + #[test] + fn peers_to_json_carries_the_three_exchanged_values() { + let peers = [PeerEntry { + participant_id: "alpha::1220".to_owned(), + name: "alpha".to_owned(), + party: Some("alpha-node::1220".to_owned()), + }]; + let json = peers_to_json(&peers); + let entry = &json.as_array().expect("array")[0]; + assert_eq!(entry["participant_id"], "alpha::1220"); + assert_eq!(entry["name"], "alpha"); + assert_eq!(entry["party"], "alpha-node::1220"); + assert!(entry.get("address").is_none()); + } + fn party_config_form() -> PartyConfigForm { PartyConfigForm { dec_party_id: "dec::1220".to_owned(), diff --git a/crates/decman-cli/src/ui.rs b/crates/decman-cli/src/ui.rs index e5e0cc459..09d89e56f 100644 --- a/crates/decman-cli/src/ui.rs +++ b/crates/decman-cli/src/ui.rs @@ -10,8 +10,8 @@ use ratatui::widgets::{ use common::types::{ ConnectionStatus, DecentralizedParty, PackageInfo, PeerErrorKind, PeerPackageComparison, - PendingInvitation, Permission, VettedPackageInfo, WAITING_FOR_PEERS_STEP, WorkflowProgress, - WorkflowRole, WorkflowRun, + PendingInvitation, Permission, VettedPackageInfo, WAITING_FOR_ACCEPTANCES_STEP, + WorkflowProgress, WorkflowRole, WorkflowRun, }; use crate::api::{ @@ -887,12 +887,27 @@ fn parties_table<'a>(parties: &[&DecentralizedParty], block: Block<'a>) -> Table .row_highlight_style(highlight_style()) } -/// Build the peers table, mirroring the frontend's network panel. The active -/// workflow (if any) is shown inline next to the peer name, as the frontend -/// does, so the name column keeps its width on narrow terminals. +/// Format a heartbeat age in seconds as a short "time ago" label. Nodes no +/// longer probe each other, so this is the age of a registry heartbeat, not a +/// liveness measurement (design D3). +fn heartbeat_age(secs: Option) -> String { + match secs { + None => "—".to_owned(), + // A clock skew between the two nodes can date a heartbeat in the future. + Some(age) if age <= 0 => "now".to_owned(), + Some(age) if age < 60 => format!("{age}s ago"), + Some(age) if age < 3600 => format!("{m}m ago", m = age / 60), + Some(age) if age < 86400 => format!("{h}h ago", h = age / 3600), + Some(age) => format!("{d}d ago", d = age / 86400), + } +} + +/// Build the peers table, mirroring the frontend's network panel. A peer is +/// identified by its participant and its node party, the two values operators +/// exchange out of band (design D2). fn peers_table<'a>(peers: &'a [PeerView], block: Block<'a>) -> Table<'a> { let header = - Row::new(["STATUS", "PEER", "ADDRESS", "LATENCY", "VERSION"]).style(header_style()); + Row::new(["STATUS", "PEER", "NODE PARTY", "HEARTBEAT", "VERSION"]).style(header_style()); let rows = peers.iter().map(|peer| { let (color, label) = status_display(peer.status); @@ -901,37 +916,33 @@ fn peers_table<'a>(peers: &'a [PeerView], block: Block<'a>) -> Table<'a> { Span::styled(label, Style::default().fg(color)), ])); - let mut name = vec![Span::raw(peer.name.clone())]; - if let Some(workflow) = &peer.workflow { - name.push(Span::styled( - format!(" ▸ {workflow}"), - Style::default().fg(Color::DarkGray), - )); - } + let party = peer.party.as_deref().map_or_else( + // A peer without a node party cannot be invited; say so in place. + || { + Span::styled( + "(not configured)".to_owned(), + Style::default().fg(Color::DarkGray), + ) + }, + |party| Span::raw(party.to_owned()), + ); Row::new(vec![ status, - Cell::from(Line::from(name)), - Cell::from(format!( - "{addr}:{port}", - addr = peer.address, - port = peer.port - )), - Cell::from( - peer.latency_ms - .map_or_else(|| "—".to_owned(), |ms| format!("{ms} ms")), - ), + Cell::from(peer.name.clone()), + Cell::from(Line::from(party)), + Cell::from(heartbeat_age(peer.heartbeat_age_secs)), Cell::from(peer.version.clone().unwrap_or_else(|| "—".to_owned())), ]) }); - // ADDRESS flexes to fill the width (host:port strings are long); the peer - // name column is fixed and comfortably fits a name plus its workflow note. + // NODE PARTY flexes to fill the width (party ids are long); the peer name + // column is fixed. let widths = [ Constraint::Length(14), Constraint::Length(30), Constraint::Fill(1), - Constraint::Length(9), + Constraint::Length(10), Constraint::Length(9), ]; @@ -1063,27 +1074,22 @@ fn truncate(value: &str, max: usize) -> String { /// the string the package-checker popup previously rendered verbatim. fn peer_error_label(kind: PeerErrorKind) -> &'static str { match kind { - PeerErrorKind::TcpConnectTimeout => "tcp_connect_timeout", - PeerErrorKind::TcpConnectFailed => "tcp_connect_failed", - PeerErrorKind::RequestTimeout => "request_timeout", - PeerErrorKind::Transport => "transport", - PeerErrorKind::HandshakeFailed => "handshake_failed", - PeerErrorKind::BadStatus => "bad_status", - PeerErrorKind::DecodeFailed => "decode_failed", - PeerErrorKind::InvalidPublicKey => "invalid_public_key", + PeerErrorKind::TopologyReadFailed => "topology_read_failed", + PeerErrorKind::NoVettedPackages => "no_vetted_packages", PeerErrorKind::Other => "other", } } -/// Map a peer status to its display color and label. `None` means no status was -/// reported for the peer, rendered as "Unknown". +/// Map a peer status to its display color and label (design D3). `None` means +/// the status endpoint reported nothing for the peer, rendered as "Unknown" — +/// the same label the endpoint uses for a peer with no registry entry. fn status_display(status: Option) -> (Color, &'static str) { match status { Some(ConnectionStatus::CurrentNode) => (logo::ORANGE, "This node"), - Some(ConnectionStatus::Connected) => (Color::Green, "Connected"), - Some(ConnectionStatus::Unreachable) => (Color::Red, "Unreachable"), - Some(ConnectionStatus::HandshakeFailed) => (Color::Red, "Handshake"), - None => (Color::DarkGray, "Unknown"), + Some(ConnectionStatus::Active) => (Color::Green, "Active"), + Some(ConnectionStatus::Stale) => (Color::Yellow, "Stale"), + Some(ConnectionStatus::Unvetted) => (Color::Red, "Unvetted"), + Some(ConnectionStatus::Unknown) | None => (Color::DarkGray, "Unknown"), } } @@ -1399,7 +1405,8 @@ fn peer_list_popup(frame: &mut Frame, area: Rect, state: &NetworkEditState) { style, ), Span::styled( - format!(" {}:{}", peer.address, peer.port), + // The node party is what an invitation names, so show it here. + format!(" {}", peer.party.as_deref().unwrap_or("(no node party)")), Style::default().fg(Color::DarkGray), ), ])); @@ -1442,10 +1449,7 @@ fn peer_form_popup(frame: &mut Frame, area: Rect, form: &PeerForm) { let fields = [ ("Participant id", form.participant_id.as_str()), ("Name", form.name.as_str()), - ("Address", form.address.as_str()), - ("Port", form.port.as_str()), - ("Public key", form.public_key.as_str()), - ("Party (optional)", form.party.as_str()), + ("Node party", form.party.as_str()), ]; let mut lines: Vec = fields .iter() @@ -2377,25 +2381,32 @@ fn change_threshold_popup(frame: &mut Frame, area: Rect, form: &ChangeThresholdF frame.render_widget(paragraph, rect); } -/// This node's Noise key status and DSO network identity. Both sections render -/// independently, so an unreachable DSO API still shows the local key status. +/// This node's coordination identity and DSO network identity. Both sections +/// render independently, so an unreachable DSO API still shows the identity. fn node_info_popup(frame: &mut Frame, area: Rect, data: &NodeInfoData) { let error_line = |error: &str| Line::styled(format!("Error: {error}"), Style::default().fg(Color::Red)); - let mut lines = vec![dim_line("Node keys")]; - match &data.key { - Ok(status) => { - lines.push(detail_kv( - "Noise keys", - if status.has_keys { - "present".to_owned() - } else { - "not generated".to_owned() - }, - )); - if let Some(key) = status.public_key.as_deref().filter(|key| !key.is_empty()) { - lines.push(detail_kv("Public key", key.to_owned())); + let mut lines = vec![dim_line("Node identity")]; + match &data.identity { + Ok(identity) => { + lines.push(detail_kv("Participant", identity.participant_id.clone())); + match identity.node_party_id.as_deref() { + Some(party) if identity.configured => { + lines.push(detail_kv("Node party", party.to_owned())); + } + // Without a node party this node coordinates nothing, so name + // the fix rather than showing an empty row. + _ => lines.push(detail_kv( + "Node party", + "not configured (PUT /node-identity)".to_owned(), + )), + } + if let Some(user) = identity.user_id.as_deref().filter(|u| !u.is_empty()) { + lines.push(detail_kv("Ledger API user", user.to_owned())); + } + if let Some(permission) = identity.hosting_permission { + lines.push(detail_kv("Hosting", permission.to_string())); } } Err(error) => lines.push(error_line(error)), @@ -2467,7 +2478,7 @@ fn peers_progress(run: &WorkflowRun) -> Option { if run.role != WorkflowRole::Coordinator || run.expected_peers.is_empty() { return None; } - let (done, label) = if run.current_step == WAITING_FOR_PEERS_STEP { + let (done, label) = if run.current_step == WAITING_FOR_ACCEPTANCES_STEP { (run.connected_peers.len(), "joined") } else { (run.completed_peers.len(), "done") @@ -2561,7 +2572,7 @@ fn invitation_detail_lines(invitation: &PendingInvitation) -> Vec> } lines.push(detail_kv( "Coordinator", - invitation.coordinator_pubkey.clone(), + invitation.coordinator_participant.clone(), )); if let Some(prefix) = invitation.prefix.as_deref().filter(|p| !p.is_empty()) { lines.push(detail_kv("Prefix", prefix.to_owned())); @@ -2612,6 +2623,8 @@ fn invitation_detail_lines(invitation: &PendingInvitation) -> Vec> #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use common::canton_id::CantonId; use common::types::{ AuditLogEntry, ContractInfo, InvitationType, ParticipantInfo, PeerPackageResult, @@ -2707,12 +2720,10 @@ mod tests { PeerView { participant_id: "alpha::1220".to_owned(), name: "alpha".to_owned(), - address: "10.0.0.1".to_owned(), - port: 9001, - status: Some(ConnectionStatus::Connected), - latency_ms: Some(12), + party: Some("alpha-node::1220".to_owned()), + status: Some(ConnectionStatus::Active), + heartbeat_age_secs: Some(12), version: Some("1.2.3".to_owned()), - workflow: None, is_self: false, } } @@ -2756,9 +2767,19 @@ mod tests { fn peers_table_renders_status_and_columns() { let rendered = render_peers(&[sample_peer()]); assert!(rendered.contains("alpha")); - assert!(rendered.contains("Connected")); - assert!(rendered.contains("10.0.0.1:9001")); - assert!(rendered.contains("12 ms")); + assert!(rendered.contains("Active")); + assert!(rendered.contains("alpha-node::1220")); + assert!(rendered.contains("12s ago")); + } + + #[test] + fn peers_table_names_a_peer_without_a_node_party() { + let mut peer = sample_peer(); + peer.party = None; + peer.status = Some(ConnectionStatus::Unknown); + let rendered = render_peers(&[peer]); + assert!(rendered.contains("not configured")); + assert!(rendered.contains("Unknown")); } #[test] @@ -2786,7 +2807,11 @@ mod tests { step_index: 2, step_total: 3, config_json: String::new(), - coordinator_pubkey: None, + coordinator_participant: None, + coordinator_party: None, + proposal_cid: None, + member_variant: None, + topology_hashes: BTreeMap::new(), coordinator_instance: None, coordinator_name: None, expected_peers: Vec::new(), @@ -2816,7 +2841,11 @@ mod tests { step_index: 5, step_total: 6, config_json: String::new(), - coordinator_pubkey: None, + coordinator_participant: None, + coordinator_party: None, + proposal_cid: None, + member_variant: None, + topology_hashes: BTreeMap::new(), coordinator_instance: None, coordinator_name: None, expected_peers: Vec::new(), @@ -2840,9 +2869,12 @@ mod tests { FeedItem::Invitation(PendingInvitation { id: "inv-1".to_owned(), invitation_type: InvitationType::Onboarding, - coordinator_pubkey: "1220deadbeef".to_owned(), + coordinator_participant: "alpha::1220".to_owned(), + coordinator_party: None, + proposal_cid: "inv-1".to_owned(), coordinator_name: Some("alice".to_owned()), received_at: 0, + expires_at: None, prefix: Some("treasury-rc5".to_owned()), participants: Vec::new(), dar_filenames: Vec::new(), @@ -3188,13 +3220,16 @@ mod tests { } #[test] - fn node_info_popup_renders_keys_and_network() { - use crate::api::{KeyStatus, NetworkInfo}; + fn node_info_popup_renders_identity_and_network() { + use crate::api::{NetworkInfo, NodeIdentityView}; let overlay = Overlay::NodeInfo(Box::new(NodeInfoData { - key: Ok(KeyStatus { - has_keys: true, - public_key: Some("deadbeefcafe".to_owned()), + identity: Ok(NodeIdentityView { + configured: true, + node_party_id: Some("decman-node::1220".to_owned()), + participant_id: "alpha::1220".to_owned(), + user_id: Some("decman-node".to_owned()), + hosting_permission: Some(Permission::Submission), }), network: Ok(NetworkInfo { dso_party_id: "DSO::1220".to_owned(), @@ -3203,27 +3238,40 @@ mod tests { })); let rendered = render(|frame, area| draw_overlay(frame, area, &overlay, "⠋")); assert!(rendered.contains("Node info")); - assert!(rendered.contains("present")); - assert!(rendered.contains("deadbeefcafe")); + assert!(rendered.contains("decman-node::1220")); + assert!(rendered.contains("submission")); assert!(rendered.contains("DSO")); } #[test] - fn node_info_popup_shows_network_error_but_keeps_keys() { - use crate::api::KeyStatus; + fn node_info_popup_shows_network_error_but_keeps_identity() { + use crate::api::NodeIdentityView; let overlay = Overlay::NodeInfo(Box::new(NodeInfoData { - key: Ok(KeyStatus { - has_keys: false, - public_key: None, + identity: Ok(NodeIdentityView { + configured: false, + node_party_id: None, + participant_id: "alpha::1220".to_owned(), + user_id: None, + hosting_permission: None, }), network: Err("DSO API unreachable".to_owned()), })); let rendered = render(|frame, area| draw_overlay(frame, area, &overlay, "⠋")); - assert!(rendered.contains("not generated")); + assert!(rendered.contains("not configured")); assert!(rendered.contains("Error")); } + #[test] + fn heartbeat_age_reads_as_a_time_ago() { + assert_eq!(heartbeat_age(None), "—"); + assert_eq!(heartbeat_age(Some(-2)), "now"); + assert_eq!(heartbeat_age(Some(12)), "12s ago"); + assert_eq!(heartbeat_age(Some(3_000)), "50m ago"); + assert_eq!(heartbeat_age(Some(7_200)), "2h ago"); + assert_eq!(heartbeat_age(Some(200_000)), "2d ago"); + } + #[test] fn composer_field_display_picker_states() { use crate::composer::{ComposerField, PickerOption, PickerSource}; @@ -3284,11 +3332,15 @@ mod tests { kind: WorkflowKind::Onboarding, role: WorkflowRole::Coordinator, status: WorkflowProgress::InProgress, - current_step: WAITING_FOR_PEERS_STEP.to_owned(), + current_step: WAITING_FOR_ACCEPTANCES_STEP.to_owned(), step_index: 0, step_total: 7, config_json: String::new(), - coordinator_pubkey: None, + coordinator_participant: None, + coordinator_party: None, + proposal_cid: None, + member_variant: None, + topology_hashes: BTreeMap::new(), coordinator_instance: None, coordinator_name: None, expected_peers: vec![canton_id("p1"), canton_id("p2"), canton_id("p3")], @@ -3334,7 +3386,11 @@ mod tests { step_index: 1, step_total: 7, config_json: String::new(), - coordinator_pubkey: None, + coordinator_participant: None, + coordinator_party: None, + proposal_cid: None, + member_variant: None, + topology_hashes: BTreeMap::new(), coordinator_instance: None, coordinator_name: None, expected_peers: Vec::new(), @@ -3553,10 +3609,7 @@ mod tests { peers: vec![PeerEntry { participant_id: "alpha::1220".to_owned(), name: "alpha".to_owned(), - address: "10.0.0.1".to_owned(), - port: 9001, - public_key: "abcd".to_owned(), - party: None, + party: Some("alpha-node::1220".to_owned()), }], selected: 0, adding: None, @@ -3565,7 +3618,7 @@ mod tests { let rendered = render(|frame, area| draw_overlay(frame, area, &overlay, "⠋")); assert!(rendered.contains("Network peers")); assert!(rendered.contains("alpha")); - assert!(rendered.contains("10.0.0.1:9001")); + assert!(rendered.contains("alpha-node::1220")); } #[test] @@ -3576,9 +3629,6 @@ mod tests { adding: Some(PeerForm { participant_id: String::new(), name: String::new(), - address: String::new(), - port: String::new(), - public_key: String::new(), party: String::new(), cursor: 0, }), @@ -3587,7 +3637,7 @@ mod tests { let rendered = render(|frame, area| draw_overlay(frame, area, &overlay, "⠋")); assert!(rendered.contains("Add peer")); assert!(rendered.contains("Participant id")); - assert!(rendered.contains("Public key")); + assert!(rendered.contains("Node party")); } #[test] diff --git a/crates/decman-wallet/src/flow.rs b/crates/decman-wallet/src/flow.rs index d3263b489..ccf35eb1d 100644 --- a/crates/decman-wallet/src/flow.rs +++ b/crates/decman-wallet/src/flow.rs @@ -499,8 +499,8 @@ pub async fn add_hosts( // stream open, the joiner holds its import stream open, and this loop // carries one block between them — so neither node buffers the snapshot // and the size of the party never decides whether the transfer is - // possible. The same shape the add-party path runs over Noise; here the - // wallet stands in for the channel those two nodes do not have. + // possible. The wallet stands in for a channel the two hosts do not + // have. // // Forward-only, so there is no resume. A break restarts from block 1 // with a fresh export, which is what the export stream's inability to diff --git a/crates/decman/Cargo.toml b/crates/decman/Cargo.toml index 4738559c3..2dd814b45 100644 --- a/crates/decman/Cargo.toml +++ b/crates/decman/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "decman" -version = "1.8.0" +version = "2.0.0" edition = "2024" license = "Apache-2.0" @@ -55,7 +55,6 @@ futures = { workspace = true } hex = { workspace = true } http = { workspace = true } hyper = { workspace = true } -hyper-noise = { workspace = true } jsonwebtoken = { workspace = true } mime_guess = { workspace = true } p256 = { workspace = true } @@ -64,7 +63,6 @@ prost = { workspace = true } prost-types = { workspace = true } reqwest = { version = "0.13", features = ["form", "json"] } rust-embed = { workspace = true } -secp256k1 = { workspace = true } semver = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -72,7 +70,6 @@ sha2 = { workspace = true } sqlx = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } -tokio-noise = { workspace = true } tonic = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } @@ -102,7 +99,6 @@ insta = { workspace = true } # (tests/common/tls_proxy.rs), which puts a Canton admin API behind TLS so the # TLS client path has coverage on a plaintext localnet. Test-only. rcgen = { workspace = true } -static_assertions = { workspace = true } tempfile = { workspace = true } tokio-rustls = { workspace = true } wiremock = { workspace = true } diff --git a/crates/decman/frontend/src/App.tsx b/crates/decman/frontend/src/App.tsx index 9558e8512..0b328ec3a 100644 --- a/crates/decman/frontend/src/App.tsx +++ b/crates/decman/frontend/src/App.tsx @@ -47,7 +47,7 @@ import type { NodeConfig, NetworkConfig, ParticipantStatus, - KeyStatusResponse, + NodeIdentityResponse, Peer, PendingInvitation, PartyAuthStatus, @@ -113,14 +113,14 @@ const App = () => { ParticipantStatus[] >([]); // Our own round-trip latency to the backend (ms). The peers table fills the - // peer rows from Noise health probes but never the "you" row; this fills it. + // peer rows from the registry snapshot but never the "you" row; this fills it. // // `seq` advances on every measurement, so the node health card's sparkline // still records a sample when the latency reads the same twice in a row. const [selfLatency, setSelfLatency] = useState<{ ms?: number; seq: number }>({ seq: 0, }); - const [keyStatus, setKeyStatus] = useState(null); + const [nodeIdentity, setNodeIdentity] = useState(null); const [authStatuses, setAuthStatuses] = useState([]); const [packageCount, setPackageCount] = useState(0); const [onboardingDialogOpen, setOnboardingDialogOpen] = useState(false); @@ -423,21 +423,21 @@ const App = () => { // Lazy-load config tab data when first opened useEffect(() => { if (activeTab !== 2) return; - if (networkConfig && keyStatus) return; // already loaded + if (networkConfig && nodeIdentity) return; // already loaded const fetchConfigData = async () => { try { - const [networkRes, keyStatusRes] = await Promise.all([ + const [networkRes, identityRes] = await Promise.all([ authenticatedFetch(`${API_BASE}/network-config`), - authenticatedFetch(`${API_BASE}/keys/status`), + authenticatedFetch(`${API_BASE}/node-identity`), ]); if (networkRes.ok) setNetworkConfig(await networkRes.json()); - if (keyStatusRes.ok) setKeyStatus(await keyStatusRes.json()); + if (identityRes.ok) setNodeIdentity(await identityRes.json()); } catch { // Ignore — will show empty state } }; fetchConfigData(); - }, [activeTab, networkConfig, keyStatus]); + }, [activeTab, networkConfig, nodeIdentity]); // Poll participant statuses every 2 seconds, and on the same cadence measure // our own round-trip to the backend so the "you" row of the peers table shows @@ -1177,7 +1177,7 @@ const App = () => { - {peer.address}:{peer.port} + {peerSubtitle(peer)} diff --git a/crates/decman/frontend/src/components/DarsDialog.tsx b/crates/decman/frontend/src/components/DarsDialog.tsx index 6fd54a214..e22655449 100644 --- a/crates/decman/frontend/src/components/DarsDialog.tsx +++ b/crates/decman/frontend/src/components/DarsDialog.tsx @@ -22,6 +22,7 @@ import { API_BASE } from "../constants"; import { authenticatedFetch } from "../api"; import { useSnackbar } from "../contexts"; import { TextHelp } from "./FieldHelp"; +import { peerSubtitle } from "../peers"; import type { DarsStatusResponse, DarFile, Peer, NodeConfig } from "../types"; interface DarsDialogProps { @@ -308,7 +309,7 @@ export const DarsDialog = ({ {mode === "upload" ? "Upload Daml Archive (DAR) files to this node only." - : "Distribute Daml Archive (DAR) files to selected peers. This will coordinate with the chosen nodes via Noise protocol."} + : "Distribute Daml Archive (DAR) files to selected peers. Each peer uploads and vets the pinned packages on its own participant."} @@ -411,7 +412,7 @@ export const DarsDialog = ({ {peer.name || peer.participant_id} - {peer.address}:{peer.port} + {peerSubtitle(peer)} } diff --git a/crates/decman/frontend/src/components/KickDialog.tsx b/crates/decman/frontend/src/components/KickDialog.tsx index da054d1b8..5fd22d722 100644 --- a/crates/decman/frontend/src/components/KickDialog.tsx +++ b/crates/decman/frontend/src/components/KickDialog.tsx @@ -67,8 +67,8 @@ export const KickDialog = ({ // On the first poll, force a server-side refresh — the cached // `/decentralized-parties` response can be missing the owner_key if // the previous resolve happened while the participant being kicked - // was offline. Force=true triggers a fresh peer-Noise round-trip plus - // the topology-derived fallback so the next poll usually has the key. + // was offline. Force=true triggers a fresh topology read plus + // the cached fallback so the next poll usually has the key. let firstFetch = true; const fetchOwnerKey = async () => { try { @@ -272,7 +272,7 @@ export const KickDialog = ({ slotProps={{ input: { endAdornment: fieldHelpAdornment( - "The participant's namespace fingerprint, looked up automatically from the participant via Noise or from Canton's topology. This is the key that gets removed from the decentralized namespace.", + "The participant's namespace fingerprint, read automatically from Canton's topology store. This is the key that gets removed from the decentralized namespace.", "Help for Namespace Fingerprint", ), }, diff --git a/crates/decman/frontend/src/components/NetworkConfigAccordion.tsx b/crates/decman/frontend/src/components/NetworkConfigAccordion.tsx index 71075314d..8126b8625 100644 --- a/crates/decman/frontend/src/components/NetworkConfigAccordion.tsx +++ b/crates/decman/frontend/src/components/NetworkConfigAccordion.tsx @@ -32,22 +32,23 @@ import { zebraRow } from "../styles"; import { copyToClipboard } from "../clipboard"; import { fieldHelpAdornment } from "./FieldHelp"; import { StatusDot } from "./StatusDot"; -import { currentStepLabel, workflowKindLabel } from "../workflowSteps"; import { PeersCsvDialog, type PeersCsvMode } from "./PeersCsvDialog"; import { parsePeersCsv, peerToCsvRow } from "../peerCsv"; +import { NO_NODE_PARTY, heartbeatAge, toneForPeer } from "../peers"; import type { NetworkConfig, Peer, ParticipantStatus, NodeConfig, - KeyStatusResponse, + NodeIdentityResponse, ConnectionStatus, } from "../types"; interface NetworkConfigAccordionProps { config: NetworkConfig; nodeConfig?: NodeConfig; - keyStatus?: KeyStatusResponse; + /** This node's own identity, for the "you" row and the share button. */ + nodeIdentity?: NodeIdentityResponse; participantStatuses?: ParticipantStatus[]; /** Our own round-trip latency to the backend (ms), shown on the "you" row. */ selfLatencyMs?: number; @@ -57,15 +58,13 @@ interface NetworkConfigAccordionProps { const emptyPeer: Peer = { participant_id: "", name: "", - address: "localhost", - port: 9000, - public_key: "", + party: "", }; export const NetworkConfigAccordion = ({ config, nodeConfig, - keyStatus, + nodeIdentity, participantStatuses, selfLatencyMs, onSave, @@ -80,13 +79,12 @@ export const NetworkConfigAccordion = ({ const isMedium = useMediaQuery(theme.breakpoints.down("md")); const selfNodeId = nodeConfig?.node.participant_id; - const selfPublicKey = keyStatus?.public_key || ""; - const selfPort = nodeConfig?.node.port ?? 9000; + const selfNodeParty = nodeIdentity?.node_party_id ?? ""; - const truncateKey = (key: string): string => { - if (!key) return "-"; - const len = isSmall ? 6 : isMedium ? 8 : 12; - return `${key.slice(0, len)}...${key.slice(-4)}`; + const truncateParty = (party?: string): string => { + if (!party) return NO_NODE_PARTY; + const len = isSmall ? 8 : isMedium ? 14 : 24; + return party.length > len + 6 ? `${party.slice(0, len)}...${party.slice(-6)}` : party; }; // Truncate participant ID: prefix::1220...last4 @@ -102,31 +100,29 @@ export const NetworkConfigAccordion = ({ const getStat = (id: string): ParticipantStatus | undefined => participantStatuses?.find((s) => s.id === id); + // A peer's status is the age of its registry heartbeat, not a connection: + // nodes never dial each other (design D3). const getStatusTooltip = (status: ConnectionStatus | undefined): string => { switch (status) { - case "Connected": - return "Connected via Noise protocol"; case "CurrentNode": return "This is the current node"; - case "Unreachable": - return "Cannot reach peer (TCP connection failed)"; - case "HandshakeFailed": - return "Noise handshake failed - check if the public key is correct"; + case "Active": + return "The peer published a recent heartbeat"; + case "Stale": + return "No recent heartbeat. The peer may be down, or it may have stopped publishing"; + case "Unvetted": + return "The peer's participant has not vetted the coordination package, so it cannot be invited"; default: - return "Status unknown"; + return "No registry entry visible. The peer may not have added you yet"; } }; - // Tooltip enriched with round-trip latency and the peer's active workflow. + // Tooltip enriched with the heartbeat age. const statusTooltip = (st: ParticipantStatus | undefined): string => { - let title = getStatusTooltip(st?.status); - if (st?.latency_ms != null) title += ` — ${st.latency_ms} ms`; - if (st?.workflow) { - const w = st.workflow; - const step = currentStepLabel({ ...w, current_step: w.step }); - title += ` — in ${workflowKindLabel(w.kind)} (${step})`; - } - return title; + const title = getStatusTooltip(st?.status); + return st?.heartbeat_age_secs == null + ? title + : `${title} — last heartbeat ${heartbeatAge(st.heartbeat_age_secs)}`; }; // Build display list: self first, then other peers @@ -135,13 +131,11 @@ export const NetworkConfigAccordion = ({ // Create self entry if not in peers list const selfEntry: Peer | null = selfNodeId - ? selfPeer || { + ? (selfPeer ?? { participant_id: selfNodeId, name: selfNodeId, - address: nodeConfig?.node.public_address || nodeConfig?.node.listen_address || "localhost", - port: selfPort, - public_key: selfPublicKey, - } + party: selfNodeParty, + }) : null; const exportablePeers: Peer[] = @@ -198,7 +192,7 @@ export const NetworkConfigAccordion = ({ .join("; "); if (rows.length === 0) { showSnackbar( - skipped || "Expected: participant_id,name,address,port,public_key", + skipped || "Expected: participant_id,node_party_id,name", "error", ); return; @@ -230,7 +224,7 @@ export const NetworkConfigAccordion = ({ key={index} sx={{ display: "grid", - gridTemplateColumns: "1fr 1fr 1fr 100px 1fr auto", + gridTemplateColumns: "1fr 1fr 1.5fr auto", gap: 1, alignItems: "center", }} @@ -265,47 +259,15 @@ export const NetworkConfigAccordion = ({ /> updatePeer(index, "address", e.target.value)} + label="Node Party" + value={peer.party ?? ""} + error={!peer.party?.trim()} + onChange={(e) => updatePeer(index, "party", e.target.value)} slotProps={{ input: { endAdornment: fieldHelpAdornment( - "The hostname or IP address where your local node's Noise client will connect to this peer.", - "Help for Address", - ), - }, - }} - /> - - updatePeer(index, "port", parseInt(e.target.value) || 0) - } - slotProps={{ - input: { - endAdornment: fieldHelpAdornment( - "The TCP port the peer's Noise server is listening on. Combined with Address to dial the peer.", - "Help for Port", - ), - }, - }} - /> - - updatePeer(index, "public_key", e.target.value) - } - slotProps={{ - input: { - endAdornment: fieldHelpAdornment( - "The peer's Noise public key (hex-encoded). Used to derive the pre-shared key that secures the encrypted channel.", - "Help for Public Key", + "The peer's node party, like \"node1::1220abc...\". Your node names it as an observer of every contract it writes for this peer, so a peer without one cannot be invited to a workflow.", + "Help for Node Party", ), }, }} @@ -374,22 +336,33 @@ export const NetworkConfigAccordion = ({ Peers: {selfEntry && ( - + {/* The span keeps the tooltip alive over a disabled button. */} + + + + )}