From c8f3279e4dacee48fce4f4f88aeedb7b604fa966 Mon Sep 17 00:00:00 2001 From: Sadiqur Rahman Date: Fri, 28 Aug 2026 13:54:15 +0600 Subject: [PATCH] chore: sync main with upstream splice 0.7.5 (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Prevent too many alerts from Cloud NAT (#6478) [static] Signed-off-by: Oriol Muñoz * bump ACS commitments checkpoint delay from 20 to 40 minutes to reduce noise on CILR (#6482) [static] Signed-off-by: Mateusz Błażejewski * Remove cometbft preflights from non devnet (#6480) [ci] Signed-off-by: Nicu Reut * Log last exception when opening circuit breaker (#6486) [ci] Signed-off-by: Nicu Reut * add details about CI and automatic upgrade tests to the instructions on bumpting Canton (#6488) [static] Signed-off-by: Mateusz Błażejewski * bump canton to 3.5.10-snapshot.20260721.19104.0.vaba417c1 (#6487) [ci] Signed-off-by: Mateusz Błażejewski * Bump nix (#6102) * [ci] bump nix alone Signed-off-by: krzysztofczyz-da * [ci] fix all issues Signed-off-by: krzysztofczyz-da * [ci] add package-lock Signed-off-by: krzysztofczyz-da * [ci] chart timeout expanded Signed-off-by: krzysztofczyz-da * [ci] update expected files Signed-off-by: krzysztofczyz-da --------- Signed-off-by: krzysztofczyz-da * Double timeout for `/api/sv/v0/onboard/sv/sequencer` (#6494) On CILR we now need more than 5 minutes for `com.digitalasset.canton.sequencer.admin.v30.SequencerAdministrationService/OnboardingStateV2`. The timing matches the switch to CantonBFT sequencers. Not fully clear how this can matter (CantonBFT only adds a ~constant amount of stuff to these, see https://github.com/DACH-NY/canton/blob/main/community/synchronizer/src/main/protobuf/com/digitalasset/canton/sequencer/admin/v30/sequencer_bft_additional_snapshot_info.proto). I'm choosing to believe that we'll revisit the custom timeouts in https://github.com/DACH-NY/canton-network-internal/issues/2125 some day so not bothering with deeper analysis. * Add ACS size panel from snapshot metric (#6493) Follow-up to #6453: the ACS growth alert now fires on splice_history_acs_snapshots_snapshot_size, but no dashboard showed it. Also fixes the dashboard's template variables, which still queried the removed splice_store_acs_size metric. [static] Signed-off-by: Martin Florian * Prevent NAT alerts from triggering on temporary spikes (#6504) [static] Signed-off-by: Oriol Muñoz * fix: narrow down alerts to just the right GW (#6508) Signed-off-by: krzysztofczyz-da * Upgrade to Canton 3.5.10-snapshot.20260722.19106.0.v8bb2addb (#6507) [ci] Contains some cantonbft fixes (not yet enough for the next lsu). Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Specify duration in seconds in GCP alert (#6513) To make GCP happy [static] Signed-off-by: Oriol Muñoz * Parse p2p url as uri instead of string (#6506) Should avoid people forgetting an https:// in front. fixes #6492 [ci] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Monitor BFT in catchup test (#6518) Signed-off-by: Julien Tinguely * Disable compat test for unvetting integration test (#6484) Signed-off-by: Julien Tinguely * Switch daml compat tests to pv 35 (#6521) And along the way fix some buggy scalatest clue strings. fixes https://github.com/DACH-NY/cn-test-failures/issues/9354 [ci] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * cantonbft - use the sequencer id for reconciling peers (#6510) * cantonbft - Adapt fork changes to include sequencer id in configured peers [ci] Signed-off-by: Nicu Reut * Grafana: fix acknowledgements dashboard for CILR (#6519) Confirmed fix by clicking in the UI. Apparently this was broken ever since we hit serial / migration ID 10... [static] Signed-off-by: Martin Florian * feat: ensure pure gha can build images (#6525) * feat: ensure pure gha can build images Signed-off-by: krzysztofczyz-da * [ci] bump Signed-off-by: krzysztofczyz-da --------- Signed-off-by: krzysztofczyz-da * Add input validation for getSettlementFactory to have bounded DB operation cost (#6520) [ci] Signed-off-by: pasindutennage-da Signed-off-by: Pasindu Tennage * Log ignore bft warnings (#6531) [static] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Remove catchup monitoring from splice (#6532) Signed-off-by: Julien Tinguely * Increase cometbft propose timeout to 2s from 1s (#6527) This should avoid most of timeouts and starting new rounds which is quite a heavy operation with the downside of possible larger latency when some nodes are slow. It will have a bigger impact if a node is down but we can handle that at an operational level. [ci] Signed-off-by: Nicu Reut * Fix grafana muteTimeIntervals for interval across different days (#6529) Signed-off-by: Julien Tinguely * Remove double splitwell test setup (#6534) [ci] Signed-off-by: Oriol Muñoz * Extend LSU cancellation test to mimic actual cancellations (#6503) * Extend LSU cancellation test to mimic actual cancellations [ci] Signed-off-by: Nicu Reut * Lower cantonbft empty block timeout to 500ms (#6533) [ci] Signed-off-by: Nicu Reut * Reduce cometbft ttl duration to 60s (#6537) It doesn't make sense to add something to ablock after 30s actually [ci] Signed-off-by: Nicu Reut * Remove retries for node initialized (#6536) We already retry everywhere where we call that This was interefering with the check for active synchronzier which is used in a lot of places and making things timeout because we had those implicit retries In most places we had nested retries in this case [ci] Signed-off-by: Nicu Reut * Bump Canton to 3.5.10 (#6541) s.a. #6140 [ci] Signed-off-by: Martin Florian * Fix SV UI crash when CometBFT is not configured (#6543) Fixes #6540 Verified with a new vitest that reproduces the paused-pending state via onlineManager.setOnline(false); it fails on the old code with the exact production error. Signed-off-by: Martin Florian * [static] clear release notes for 0.6.14 (#6545) Signed-off-by: Mateusz Błażejewski * Disable BFT sequencer connections for SVs in SvOnboardingViaNonFoundingSvIntegrationTest (#6538) [ci] Signed-off-by: Oriol Muñoz * bump VERSION and LATEST_RELEASE after releasing 0.6.14 (#6548) [ci] Signed-off-by: Mateusz Błażejewski * Pulumi: Default to `replaceAll` for replacing placeholder strings in Grafana definitions (#6549) Avoids buggy messages such as `Average verdict ingestion batch size is above $VERDICT_INGESTION_BATCH_SIZE_THRESHOLD for namespace sv-9.` [static] Signed-off-by: Martin Florian * Disable credentials check for public docker repo in skopeo (#6552) Signed-off-by: Paweł Perek * Move LSU cancellation endpoint behind operator auth (#6555) No need for admin auth as you can schedule a LSU without it so it doesn't really make sense [ci] Signed-off-by: Nicu Reut * Remove scan transactions endpoint (#6561) * Remove scan transactions endpoint Already deprecated, we removed /activities already, metrics show basically no usage (< 1 request per day) and next release is 0.7.0 so perfect time for removing a deprecated endpoint. [ci] Signed-off-by: moritz.kiefer@digitalasset.com * fix rate limits [ci] Signed-off-by: moritz.kiefer@digitalasset.com --------- Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Add accept-language in pekko ignore-illegal-header (#6566) Signed-off-by: Julien Tinguely * Add a script which constructs a fake single package for speeding up dev cycles (#6523) --------- Signed-off-by: Cale Gibbard * Remove CC txlog parsing from Scan (#6551) --------- Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Disable transfer command support by default (#6567) fixes #6553 [ci] --------- Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Update sbt to 1.12.14. (#6474) Prior to v1.11.1, sbt had a memory leak (https://github.com/sbt/sbt/issues/8142) in its `update` task that caused memory usage to grow linearly with the number of `update`s performed. This affects regular development as `update` is called as part of the `compile` task dependency graph. After upgrading to a version that includes the fix (https://github.com/coursier/sbt-coursier/pull/563, https://github.com/coursier/sbt-coursier/pull/564), I've found development with sbt, especially with long-running sbt shell sessions, to be a more pleasant experience; there's less sluggishness and a lot fewer warnings that say "X seconds of the last 10 seconds were spent in garbage collection." Signed-off-by: Matt Dziuban * Specify set of valid response content types (#6535) * Specify set of valid response content types. Fixes #6483. This updates `HttpClient.createHttpFn` to consider a response invalid if its content type is not `application/json`, `application/octet-stream`, or `text/plain`. This results in `httpClientWithErrors` calling `getApiErrorFromResponse`, which produces an `HttpCommandException` and includes the raw response body in its error message. These three content types are the only ones used across all the OpenAPI specs in the repo. `text-plain` is used by [`devNetOnboardValidatorPrepare`](https://github.com/canton-network/splice/blob/main/apps/sv/src/main/openapi/sv-internal.yaml#L506-L508), and `application/octet-stream` is used by [`bulkStorageDownload`](https://github.com/canton-network/splice/blob/main/apps/scan/src/main/openapi/scan-stream-server.yaml#L46-L50). Signed-off-by: Matt Dziuban * Ensure responses with `ContentTypes.NoContentType` are considered valid. Signed-off-by: Matt Dziuban * Run scalafmt. Signed-off-by: Matt Dziuban --------- Signed-off-by: Matt Dziuban * update our Canton fork to latest release-line-3.5 (#6331) * Undo our changes * Bump Canton commit * Reapply our clean changes * add traffic-enforcement * traffic-enforcement-component deps * get traffic-enforcement-api from maven * don't build ledger-api-core * this is digital-asset/canton@2fc931e * account for cryptoMetrics * renamed synchronizerId to psid in DACH-NY/canton#33295 * do not compile UseLedgerApiTestTool * match canton library version to fork version * Alpha removed in DACH-NY/canton#33332 * setup name now collides * refer to #6544 for further deletions * replace more forked source libraries with binaries - magnolify-addon - daml-jwt - daml-tls * prefer forked source in a couple un-upstreamed cases * use blake2b binary --------- Assisted-by: Copilot:claude-4.6-opus Assisted-by: Copilot:gpt-5.3-codex Signed-off-by: Stephen Compall * remove unused Canton sbt subprojects (#6569) * remove ledger-api-core * remove daml-tls * remove daml-jwt * remove Blake2b * remove magnolify --------- Signed-off-by: Stephen Compall * Support configuring API request logging per app-type. (#6560) Fixes #6337 This adds a new `cantonApiRequestLogLevel` config that controls the API request log level of the Canton apps, while the exiting `apiRequestLogLevel` config controls the API request log level of the Splice apps. For backward compatibility, the Canton apps will fall back to using `apiRequestLogLevel` if `cantonApiRequestLogLevel` is not set. Signed-off-by: Matt Dziuban * helm, info, status: Update grpc_health (#6565) - Add missing mandatory header "TE: trailers" - Add option --connect-only to grpc_health (useful in absence of healthcheck endpoint) Signed-off-by: Stanislav German-Evtushenko * Update brace-expansion (#6574) Signed-off-by: Paweł Perek * Update react-router (#6575) Signed-off-by: Paweł Perek * [static] Fix curl retry piped into jq in skip_on_static and daml_compat_test (#6580) Failed-attempt bodies from --fail-with-body corrupt the pipe into jq (parse error, then EPIPE storm). Write to a file instead: curl resets an -o output file between retries. Signed-off-by: Raymond Roestenburg * Temporarily fix static checks under external PRs (#6582) Signed-off-by: Paweł Perek * Implement new RadioSelector design (issue #2655) (#6397) Signed-off-by: Tim Pelzer * Support different sets of `additionalJvmOptions` for the sequencer and mediator (#6522) Signed-off-by: Matt Dziuban * TBAR: Guard meta row insertion on traffic summaries (#6579) Signed-off-by: Divam * helm, info, status: Check CantonBFT reachability (#6589) * helm, info, status: Add separate function to fetch Scan URLs Signed-off-by: Stanislav German-Evtushenko * Update canton bft dashboards with latest 3.5 (#6595) [static] Signed-off-by: Nicu Reut * PostgreSQL 18 release notes (#6588) --------- Signed-off-by: Oriol Muñoz * [ci] Ignore background warnings in actAs-dso log assertion (#6593) Signed-off-by: Raymond Roestenburg * [ci] Bump timeout on concurrent taps in wallet batch test (#6592) Signed-off-by: Raymond Roestenburg * SV UI: CIP-104 Support UpdateFeatureAppRight Vote (#6441) Signed-off-by: JYC11 Signed-off-by: Divam Co-authored-by: Divam * [ci] Retry event history sanity check to avoid scan ingestion race (#6591) * [static] Retry event history sanity check to avoid scan ingestion race Signed-off-by: Raymond Roestenburg * [static] Explain the ingestion-cap race in the event history retry comment Signed-off-by: Raymond Roestenburg --------- Signed-off-by: Raymond Roestenburg * Implement governance form field styling (Fixes #2656, #2657, #2659) (#6407) Signed-off-by: Lyutskan Lyutskanov Signed-off-by: Tim Pelzer Co-authored-by: Tim Pelzer * Document staging branches (#6598) [static] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * replace Canton source submodules with binaries where matching (#6571) * remove base-errors and google-common-protos-scala * remove empty pekko-fork * use binary util-external * fix util-external call in HttpScanAppClient * remove adjustable-clock * remove upstream-removed ledger-common * remove kms-driver-api * remove wartremover-annotations * remove scalatest-addon --------- Signed-off-by: Stephen Compall * hard-fork ApiCodecCompressed & tests to separate library (#6576) * move ApiCodecCompressed to hard fork library lf-value-json * restore codec tests from DACH-NY/canton#27917 * remove tests that need JsonEncodingTest.dar - dealing with LF Record - decode a JSON array of the right length - fail to decode if missing fields - fail to decode if extra fields - dealing with LF Variant - decode Foo/Baz from JSON - fail decoding Foo/Qux from JSON if 'value' field is missing - decode Foo/Qux (empty value) from JSON - dealing with Contract Key - decode type Key = Party from JSON - decode type Key = (Party, Int) from JSON - decode type Key = (Party, (Int, Foo, BazRecord)) from JSON * proper dependencies for lf-value-json * use lf-value-json in apps-common --------- Signed-off-by: Stephen Compall * [ci] Disable update history sanity check in SvOnboardingViaNonFoundingSvIntegrationTest (#6606) sv1 is stopped mid-test; the plugin accesses sv1Scan.automation after sv1's participant is down, throwing 'Node doesn't have any app state'. This also prevents proper port release, causing three subsequent tests to fail with 'Could not create Prometheus HTTP server'. Signed-off-by: Raymond Roestenburg * [ci] Close all scan resources even when one close fails; include legacy nodes and rewards store (#6605) Signed-off-by: Raymond Roestenburg * [ci] Release netty buffers on zstd compression failures (#6603) Signed-off-by: Raymond Roestenburg * [ci] Delete all toxiproxy proxies even when one delete fails (#6604) Signed-off-by: Raymond Roestenburg * replace storage.underlying with storage in tests for DbAppActivityRecordStoreTest and DbScanAppRewardsStoreTest (#6564) Signed-off-by: JYC11 Co-authored-by: Divam <681060+dfordivam@users.noreply.github.com> * helm, info, status: Updates and fixes (#6609) * helm, info, status: Print position and exit code on error Signed-off-by: Stanislav German-Evtushenko * helm, info, status: Ignore CantonBFT node when unable to fetch URL Before: - If the URL for a single CantonBFT node can't be fetched no results for all CantonBFT nodes are generated After: - If the URL for a single CantonBFT node can't be fetched the result only for this node is missing Signed-off-by: Stanislav German-Evtushenko --------- Signed-off-by: Stanislav German-Evtushenko * SV UI strings adjustment (#6243) Signed-off-by: Paweł Perek * Upgrade Canton to 3.5.11-snapshot.20260730.19128.0.ve4f54d89 (#6613) [ci] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Change default cantonbft config to exponential blacklisting (#6612) To recover more quickly from nodes that are down. Not gonna do much for the cases where someone times out every X epochs but still a better default. [ci] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * fix bft scan connection init leaks (#6617) * [static] Close scan connections when BFT scan connection init fails Signed-off-by: Raymond Roestenburg * [ci] Format Signed-off-by: Raymond Roestenburg --------- Signed-off-by: Raymond Roestenburg * Clear out upcoming release notes (#6620) [static] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Upgrade Canton to 3.5.11 (#6619) [ci] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * [ci] Widen rate limit window in ScanIntegrationTest (#6624) maxAccepted was the exact theoretical ceiling with no slack, so any timing jitter that stretches the 5s emission window lets one more refill batch through (seen accepting 31). Signed-off-by: Raymond Roestenburg * [ci] Pin as_of_round in ScanTimeBasedIntegrationTest holdings summary (#6622) The at_or_before query ran after advanceTime, so it resolved a different earliest open mining round than the exact query and the holding fees differed by one round. Signed-off-by: Raymond Roestenburg * [ci] Close channel and snapshot stream on synchronous failures in Seq… (#6618) Signed-off-by: Raymond Roestenburg * Bump versions after 0.7.0 release (#6625) [ci] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * [ci] Close additionalLegacy synchronizer nodes and isolate node closes (#6626) Signed-off-by: Raymond Roestenburg * [ci] Make wallet service closes failure-isolated and clean up on part… (#6627) * [ci] Make wallet service closes failure-isolated and clean up on partial construction Signed-off-by: Raymond Roestenburg * Fix at threshold decoding in old proposals (#6632) Signed-off-by: Paweł Perek * add per ip rate limits (#6631) Fixes #6599 This PR adds: per IP rate limits validation for the schema example usage: perIpLimits: maxTokens: 120 tokensPerFill: 120 fillInterval: 60s overrides: test: ips: - 192.68.78.50 maxTokens: 220 tokensPerFill: 220 fillInterval: 60s Note: the change is backwards-compatible. * SV UI Redesign Look & Feel: Component: Layout (#6383) Signed-off-by: Puneet Bharti * [ci] Recover accepted DUPLICATE_COMMAND in SpliceLedgerConnection ded… (#6601) Signed-off-by: Raymond Roestenburg * Update to latest version of cantonbft dashboards (#6651) [static] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Add timeout to the grpcClients in splice (#6597) Signed-off-by: Julien Tinguely * Support splice helm chart migration to Postgres 18 image (#6637) --------- Signed-off-by: Oriol Muñoz Co-authored-by: moritzkiefer-da <45630097+moritzkiefer-da@users.noreply.github.com> * SV UI: add Submitted By column to governance tables (#3691) (#6460) Signed-off-by: Puneet Bharti * Fix runbooks' deployments (#6662) * fix validator runbook Signed-off-by: Oriol Muñoz * fix sv runbook Signed-off-by: Oriol Muñoz * doc parameters Signed-off-by: Oriol Muñoz * update expected [static] Signed-off-by: Oriol Muñoz --------- Signed-off-by: Oriol Muñoz * Fix validator runbook using a default postgres host (#6664) [static] Signed-off-by: Oriol Muñoz * Fix backup & restore on helmless postgres (#6668) --------- Signed-off-by: Oriol Muñoz * Fix uid of Sequencer Traffic dashboard (#6666) [static] Signed-off-by: Oriol Muñoz * Fix cilr pod-reaper getting stuck due to pull image (#6672) [ci] Signed-off-by: Pasindu Tennage Signed-off-by: pasindutennage-da * Add alert for repeated `/api/scan/v0/sv-bft-sequencers` fails (#6675) Part of https://github.com/DACH-NY/cn-test-failures/issues/9485 ; next step will be to demote the log omitted there to info (or at least avoid it being warn already on the first fail). [static] Signed-off-by: Martin Florian * Increase default cantonbft segment length (#6671) * Increase default cantonbft segment length --------- Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Demote `Failed to read bft sequencers list from scan` to INFO (#6677) Fixes https://github.com/DACH-NY/cn-test-failures/issues/9485 Alerting now covered via https://github.com/canton-network/splice/pull/6675 I contemplated making the logging smarter (only warn if stays failed for N minutes), but somehow I'm not convinced that this is worth the effort and added complexity. Signed-off-by: Martin Florian * localnet: make TARGET_TRAFFIC_THROUGHPUT configurable (#6676) Signed-off-by: Matteo Limberto * Update canton to 3.5.12-snapshot.20260804.19139.0.vbd8b06e0 (#6684) [ci] Signed-off-by: pasindutennage-da Signed-off-by: Pasindu Tennage * Support external sharing configuration (#6353) Signed-off-by: JYC11 Signed-off-by: Jaeyoon Cho Co-authored-by: Simon Meier Co-authored-by: Divam <681060+dfordivam@users.noreply.github.com> * [ci] bump GHA runner version to the latest (auto-generated) (#6648) Signed-off-by: DA Automation Co-authored-by: DA Automation * Bump timeout in archive expired VoteRequest contracts test (#6692) Signed-off-by: Paweł Perek * remove migration ID from non-SV participant names (#5991) Signed-off-by: Mateusz Błażejewski * Ingest ProcessRewardsV2 in ScanRewardsReferenceStore (#6691) Signed-off-by: Divam * Add bad counterparties auto-ignore mechanism in Ans and Transfer pre-approval expiry triggers (#6680) Signed-off-by: Julien Tinguely * Fix cncluster psql for participants (#6695) Tested on CILR that it works for sv participant, sequencer and sv-app and for validator1 participant. Fixes #6693 [static] Signed-off-by: Moritz Kiefer Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: Moritz Kiefer * Prevent page_token=123 false alert (#6697) [static] Signed-off-by: Oriol Muñoz * Ignore expired preapprovals in AcceptTransferPreapprovalProposalTrigger (#6673) [ci] fixes #6610 Signed-off-by: Moritz Kiefer * Show diff of `dars.lock` if it's out of date (#6657) * Show diff of `dars.lock` if it's out of date. Fixes #5899 This updates `DarLockChecker.scala` to shell out to `diff` to get the difference between the current lock file and its expected contents, and includes the output in the error message. Signed-off-by: Matt Dziuban * Change `garbage collection` to `GC`. Signed-off-by: Matt Dziuban --------- Signed-off-by: Matt Dziuban * Unified URLs (#6455) Signed-off-by: Tim Pelzer * Disable circuit breakers for all sv4 apps in LSU test (#6704) fixes DACH-NY/cn-test-failures#9487 Previous fix (#5587) only covered the sv app; DACH-NY/cn-test-failures#9487 shows the same trip (sequencer backpressure during sv4 catchup) from the sv4 validator app [ci] Signed-off-by: Martin Florian * use binary slick-fork instead of forked source (#6687) * move asUpdateReturning to a syntax extension --------- Assisted-by: Copilot:claude-5-opus Signed-off-by: Stephen Compall * Do not write metadata file in PeriodicTopologySnapshotTrigger (#6706) Signed-off-by: Julien Tinguely * Bump the development-dependencies group across 1 directory with 5 updates (#6475) Bumps the development-dependencies group with 4 updates in the /load-tester directory: [@eslint/eslintrc](https://github.com/eslint/eslintrc), [@types/k6](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/k6), [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) and [prettier](https://github.com/prettier/prettier). Updates `@eslint/eslintrc` from 3.3.5 to 3.3.6 - [Release notes](https://github.com/eslint/eslintrc/releases) - [Changelog](https://github.com/eslint/eslintrc/blob/main/CHANGELOG.md) - [Commits](https://github.com/eslint/eslintrc/compare/eslintrc-v3.3.5...eslintrc-v3.3.6) Updates `@types/k6` from 2.0.0 to 2.0.1 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/k6) Updates `@typescript-eslint/eslint-plugin` from 8.61.1 to 8.65.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.65.0/packages/eslint-plugin) Updates `@typescript-eslint/parser` from 8.61.1 to 8.66.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.66.0/packages/parser) Updates `prettier` from 3.8.4 to 3.9.6 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.4...3.9.6) --- updated-dependencies: - dependency-name: "@eslint/eslintrc" dependency-version: 3.3.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-dependencies - dependency-name: "@types/k6" dependency-version: 2.0.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-dependencies - dependency-name: "@typescript-eslint/eslint-plugin" dependency-version: 8.64.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: development-dependencies - dependency-name: "@typescript-eslint/parser" dependency-version: 8.65.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: development-dependencies - dependency-name: prettier dependency-version: 3.9.5 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: development-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump @types/node from 25.9.4 to 26.1.0 in /gha-scripts (#6683) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.9.4 to 26.1.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 26.1.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump bignumber.js (#6348) Bumps the production-dependencies group with 1 update in the /load-tester directory: [bignumber.js](https://github.com/MikeMcl/bignumber.js). Updates `bignumber.js` from 11.1.4 to 11.1.5 - [Release notes](https://github.com/MikeMcl/bignumber.js/releases) - [Changelog](https://github.com/MikeMcl/bignumber.js/blob/main/CHANGELOG.md) - [Commits](https://github.com/MikeMcl/bignumber.js/compare/v11.1.4...v11.1.5) --- updated-dependencies: - dependency-name: bignumber.js dependency-version: 11.1.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * SV UI: warn when disabled config fields change in proposals (#5650) (#6471) Signed-off-by: Puneet Bharti * [ci] Bump the development-dependencies group across 1 directory with 8 updates (#6667) * [ci] Bump the development-dependencies group across 1 directory with 8 updates Bumps the development-dependencies group with 7 updates in the /cluster/pulumi directory: | Package | From | To | | --- | --- | --- | | [@eslint/eslintrc](https://github.com/eslint/eslintrc) | `3.3.5` | `3.3.6` | | [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.61.1` | `8.65.0` | | [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `9.39.5` | | [minimatch](https://github.com/isaacs/minimatch) | `10.2.5` | `10.2.6` | | [prettier](https://github.com/prettier/prettier) | `3.8.4` | `3.9.6` | | [ts-jest](https://github.com/kulshekhar/ts-jest) | `29.4.11` | `29.4.12` | | [@types/lodash](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/lodash) | `4.17.24` | `4.17.25` | Updates `@eslint/eslintrc` from 3.3.5 to 3.3.6 - [Release notes](https://github.com/eslint/eslintrc/releases) - [Changelog](https://github.com/eslint/eslintrc/blob/main/CHANGELOG.md) - [Commits](https://github.com/eslint/eslintrc/compare/eslintrc-v3.3.5...eslintrc-v3.3.6) Updates `@typescript-eslint/eslint-plugin` from 8.61.1 to 8.65.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.65.0/packages/eslint-plugin) Updates `@typescript-eslint/parser` from 8.61.1 to 8.66.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.66.0/packages/parser) Updates `@eslint/js` from 9.39.4 to 9.39.5 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/commits/v9.39.5/packages/js) Updates `minimatch` from 10.2.5 to 10.2.6 - [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md) - [Commits](https://github.com/isaacs/minimatch/compare/v10.2.5...v10.2.6) Updates `prettier` from 3.8.4 to 3.9.6 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.4...3.9.6) Updates `ts-jest` from 29.4.11 to 29.4.12 - [Release notes](https://github.com/kulshekhar/ts-jest/releases) - [Changelog](https://github.com/kulshekhar/ts-jest/blob/main/CHANGELOG.md) - [Commits](https://github.com/kulshekhar/ts-jest/compare/v29.4.11...v29.4.12) Updates `@types/lodash` from 4.17.24 to 4.17.25 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/lodash) --- updated-dependencies: - dependency-name: "@eslint/eslintrc" dependency-version: 3.3.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-dependencies - dependency-name: "@eslint/js" dependency-version: 9.39.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-dependencies - dependency-name: "@types/lodash" dependency-version: 4.17.25 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-dependencies - dependency-name: "@typescript-eslint/eslint-plugin" dependency-version: 8.65.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: development-dependencies - dependency-name: "@typescript-eslint/parser" dependency-version: 8.66.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: development-dependencies - dependency-name: minimatch dependency-version: 10.2.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-dependencies - dependency-name: prettier dependency-version: 3.9.6 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: development-dependencies - dependency-name: ts-jest dependency-version: 29.4.12 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-dependencies ... Signed-off-by: dependabot[bot] * fix: fix prettier issues Signed-off-by: krzysztofczyz-da * [ci] bump Signed-off-by: krzysztofczyz-da * fix: add prettier changes Signed-off-by: krzysztofczyz-da --------- Signed-off-by: dependabot[bot] Signed-off-by: krzysztofczyz-da Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: krzysztofczyz-da * Close stream in TemplateJsonDecoder (#6705) [ci] Signed-off-by: Oriol Muñoz * Upgrade Canton to 3.5.12 (#6712) [ci] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Clear Release Notes (#6721) [static] Signed-off-by: Pasindu Tennage Signed-off-by: pasindutennage-da * implement split SV deployment migration procedure (#6655) Signed-off-by: Mateusz Błażejewski * Revert "[ci] Bump the development-dependencies group across 1 directory with 8 updates" (#6726) This reverts commit 48911a80c33f10f96e25e156597ed01e393a1abc. --------- Signed-off-by: Oriol Muñoz * fix typo in ValidatorReonboardingIntegrationTest.scala (#5535) Signed-off-by: Richard Kapolnai <53859003+richardkapolnai-da@users.noreply.github.com> Co-authored-by: Martin Florian * Bump versions after 0.7.1 release (#6729) [ci] Signed-off-by: Pasindu Tennage Signed-off-by: pasindutennage-da * SV UI: remove "proposal reason" from update feature app weight proposal form (#6689) --------- Signed-off-by: JYC11 * Upgrade Canton to 3.5.13-snapshot.20260809.19149.0.v1b50fc03 (#6735) [ci] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Reconcile setBalanceRequestSubmissionWindowSize (#6728) Main part of #4715 Once merged, I'll make sure to alert SVs about the upcoming change right away. [ci] Signed-off-by: Martin Florian * Fix lsu dashboard participant status panel (#6736) also update unresponsive parties to show all in the tooltip [static] Signed-off-by: Nicu Reut * Parametrize chaos mesh schedule (#6737) Signed-off-by: Julien Tinguely * Update bft dashboard to latest version (#6739) [static] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Enable istio rate limiting metrics (#6614) Signed-off-by: Julien Tinguely * Allow voting after effectivity date. (#6646) Signed-off-by: Matt Dziuban * Bump aws_version (#6740) Signed-off-by: Robert Autenrieth * Add advisory lock to `SqlIndexInitializationTrigger` (#6681) * Add advisory lock to `SqlIndexInitializationTrigger`. Fixes #4738 This adds an advisory lock around the index DDL statements in `SqlIndexInitializationTrigger`. When a trigger fails to acquire the lock, the task will be retried. Signed-off-by: Matt Dziuban * use consistent 'should' style in DbScanAppRewardsStoreTest (#6146) Signed-off-by: JYC11 * fix sv network banner to always show (#6087) Signed-off-by: Jaeyoon Cho Signed-off-by: JYC11 Signed-off-by: Paweł Perek Co-authored-by: Paweł Perek * SV UI: More descriptive names for Reward Config (#6578) Signed-off-by: Tim Emiola Signed-off-by: JYC11 Signed-off-by: Jaeyoon Cho Co-authored-by: Tim Emiola Co-authored-by: Divam <681060+dfordivam@users.noreply.github.com> Co-authored-by: Simon Meier * Fix GCP log-based upgrade alerts (#6752) Apparently they changed something and the old query doesn't give signal anymore. I picked the new query based on looking at logs... and looking at historic data it looks right and not too spammy. Signed-off-by: Martin Florian * Upgrade Canton to 3.5.13-snapshot.20260811.19159.0.va77f0cc3 (#6753) Has the fixed metrics. [ci] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Fix rewardWeightBps specified with '_' breaking js_yaml >= 4.2 (#6749) --------- Signed-off-by: Oriol Muñoz * adjust_perf_test_benchmarks (#6761) Signed-off-by: Jagath Weerasinghe * Allow arbitrary labels in GKE node config (#6758) Signed-off-by: Paweł Perek * Ignore stream restart warnings on shutdown (#6760) [ci] Signed-off-by: Oriol Muñoz * Fix more LOCAL_VERDICT_INACTIVE_CONTRACTS in TestTokenV2SettlementIntegrationTest (#6759) fixes https://github.com/DACH-NY/cn-test-failures/issues/9517 same logic as https://github.com/canton-network/splice/pull/6432 Signed-off-by: Oriol Muñoz * Auto-ignore party when no amulet version is vetted (#6747) Signed-off-by: Julien Tinguely * Reapply "[ci] Bump the development-dependencies group across 1 directory with 8 updates" (#6757) This reverts commit 905ead3cf829334795e830a2b7a00d7faa4099ea. --------- Signed-off-by: Oriol Muñoz * Bump canton version (#6765) Signed-off-by: Julien Tinguely * Auto-enable session signing keys on KMS participants (#6778) Part of #5135 Signed-off-by: Martin Florian * Bump canton version to 3.5.13 (#6781) Signed-off-by: Julien Tinguely Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Clear upcoming release notes (#6779) Signed-off-by: Julien Tinguely * Upload protobuf encoding of snapshot/updates to bulk storage. (#6602) Signed-off-by: Matt Dziuban * Bump versions after release (#6783) Signed-off-by: Julien Tinguely * added a new data stream and code to create datatransfer jobs to load prod tables (#6542) * added a new data stream and code to create datatransfer jobs to load prod tables Signed-off-by: Kajal Shah * Added code to stop and start datastreams from config and intergrated PR comments Signed-off-by: Kajal Shah * added index file changes Signed-off-by: Kajal Shah * Addressed PR comments and refactured the code Signed-off-by: Kajal Shah * made extra edits to bigquery.ts Signed-off-by: Kajal Shah * added new boundary condition per PR comments Signed-off-by: Kajal Shah * restored index and envr.envrs file Signed-off-by: Kajal Shah * removed the watermark update criteria as it would always fail being less than the crrent value Signed-off-by: Kajal Shah * Addresed PR comments Signed-off-by: Kajal Shah * formatting Signed-off-by: Kajal Shah * formatting and deleting extra coments Signed-off-by: Kajal Shah * Apply suggestions from code review Co-authored-by: Stephen Compall Signed-off-by: kajalshah-da Signed-off-by: Kajal Shah * minor edits Signed-off-by: Kajal Shah * minor edits- removed IAM function Signed-off-by: Kajal Shah * Merged with main and fixed all diffs[ci] Signed-off-by: Kajal Shah * added partition in publication per issue #6730 Signed-off-by: Kajal Shah * added partition in publication per issue #6730 [ci] Signed-off-by: Kajal Shah * update expected Signed-off-by: Kajal Shah --------- Signed-off-by: Kajal Shah Signed-off-by: kajalshah-da Co-authored-by: Stephen Compall * Remove Scan app `enableAppActivityRecordAndTrafficIngestion` and `serveAppActivityRecordsAndTraffic` config options (#6643) This removes the `enableAppActivityRecordAndTrafficIngestion` and `serveAppActivityRecordsAndTraffic` config options and the optionality proliferation that they resulted in. --------- Signed-off-by: Matt Dziuban * Re-enable `SplitwellUpgradeIntegrationTest` (#6748) * Re-enable `SplitwellUpgradeIntegrationTest`. Signed-off-by: Matt Dziuban * Ensure DAR is uploaded and multi-synchronizer feature flag is enabled. Signed-off-by: Matt Dziuban * Remove unneeded log assertion. Signed-off-by: Matt Dziuban * Re-enable `SplitwellUpgradeFrontendIntegrationTest` as well. Signed-off-by: Matt Dziuban * Move `splitwellUpgradeSynchronizerId` to after Alice's participant connects to that synchronizer. This fixes an issue where the `balance update and invite contracts follow group, which follows installs` test would fail when run in isolation because Alice hadn't connected to the splitwell upgrade synchronizer. When the suite was run in full, earlier tests ensured the pre-condition was met, but this prevents devs from running a command like `testOnly ... -- -z "balance update"`. Signed-off-by: Matt Dziuban --------- Signed-off-by: Matt Dziuban * Bump version to 0.7.4 and latest release to 0.7.3 (#6795) [ci] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Fix buffer usage metric in jvm dashboard (#6796) [static] Not sure if this was ever right but it doesn't work atm. Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * [ci] fully retire artifactory from splice (#6498) Addresses #6448 (the splice side) --------- Signed-off-by: Itai Segall * Expose vote creation metrics (#6780) Signed-off-by: Paweł Perek * apply the envoy flow control to the sequencer bft endpoint (#6784) * apply the envoy flow control to the sequencer bft endpoint [static] Signed-off-by: Nicu Reut * Extend splice rate limits (#6764) * Extend splice rate limits Valid for scan and sv app Add global rate limiter (previosly we rate limited only each individual operation), which is enabled by default. Add the ability to rate limit also per ip for each oepration (disabled by default). The global rate limiter has this option enabled by default. Extend the rate limiter to check a longer interval (60s). The previous behavior was checking only the last 1s, this is still in place and works as a burst limiter, allowing for shorter burts but the longer interval enforces a lower limit for the configured interval. [ci] Signed-off-by: Nicu Reut * Revert "Extend splice rate limits (#6764)" (#6807) This reverts commit ec3d767c3e7585c9e8930b63a17d618e7e24749f. Signed-off-by: Stephen Compall * add BFT enforcement for bulk storage objects (#6438) Signed-off-by: Itai Segall * fix dso missed confirmation alert (#6806) [static] Signed-off-by: Nicu Reut * helm: quote validator wallet user ids (#6805) validatorWalletUsers is rendered into a HOCON fragment as canton.validator-apps.validator_backend.validator-wallet-users.0 = {{ $user }} unquoted, and validatorWalletUser into an env var value unquoted. Both break for user ids that are not bare alphanumeric strings: - '@' is not allowed in an unquoted HOCON string, so an id that is an email address -- which some identity providers use as the user id -- produces a config the validator app cannot parse. - An all-digit id becomes a HOCON number rather than a string, and in the env var case renders as a YAML integer, which is not a valid container env value. Quote both. The surrounding code already does this elsewhere -- scanAddress uses `| quote`, and the scan-client sv-names and seed-urls lists use `| toJson` -- so these two sites look like oversights rather than intent. Note for anyone who worked around this by embedding literal double quotes in the value: those must now be removed, or the id ends up double-quoted. Signed-off-by: Stephane Loeuillet Co-authored-by: Claude Opus 5 (1M context) * New store for unavailable parties (#6725) Signed-off-by: Julien Tinguely * recreate node pools on machine type changes to work around a provider bug (#6819) [static] Signed-off-by: Mateusz Błażejewski * Fix gcloud link in Enforced requests alert (#6820) Signed-off-by: Julien Tinguely * extend splice rate limits (#6812) * Extend splice rate limits Valid for scan and sv app Add global rate limiter (previosly we rate limited only each individual operation), which is enabled by default. Add the ability to rate limit also per ip for each oepration (disabled by default). The global rate limiter has this option enabled by default. Extend the rate limiter to check a longer interval (60s). The previous behavior was checking only the last 1s, this is still in place and works as a burst limiter, allowing for shorter burts but the longer interval enforces a lower limit for the configured interval. [ci] Signed-off-by: Nicu Reut * Restart CometBFT when it starts replaying messages (#6823) * Restart CometBFT when it starts replaying messages fixes #6823 [static] Signed-off-by: moritz.kiefer@digitalasset.com * Apply suggestions from code review Co-authored-by: Martin Florian Signed-off-by: moritzkiefer-da <45630097+moritzkiefer-da@users.noreply.github.com> * randomize Signed-off-by: moritz.kiefer@digitalasset.com * snippet Signed-off-by: moritz.kiefer@digitalasset.com * catch all exceptions Signed-off-by: moritz.kiefer@digitalasset.com * Handle decreases in counter metrics [static] Signed-off-by: moritz.kiefer@digitalasset.com * fix config [static] Signed-off-by: moritz.kiefer@digitalasset.com --------- Signed-off-by: moritz.kiefer@digitalasset.com Signed-off-by: moritzkiefer-da <45630097+moritzkiefer-da@users.noreply.github.com> Co-authored-by: Moritz Kiefer Co-authored-by: Martin Florian * enable split-sv deployment for scratchnets by default (#6808) [static] Signed-off-by: Mateusz Błażejewski * Apply node type changes from scratch (#6827) * Apply node type changes from scratch [static] Copypasta from https://github.com/DACH-NY/canton-network-internal/commit/f6af5604a67439e2a8d71f6204cec8a43c328eb4 Signed-off-by: moritz.kiefer@digitalasset.com * scratchnet config only [static] Signed-off-by: moritz.kiefer@digitalasset.com --------- Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Double scan rate limits for `/registry/transfer-instruction/v1/transfer-factory` (#6830) Based on experience on MainNet, see DACH-NY/canton-network-internal#6543 This endpoint is cheap, some IP(s) are getting rate limited at 3-4 requests / second. [static] Signed-off-by: Martin Florian * Revert "Re-enable `SplitwellUpgradeIntegrationTest` (#6748)" (#6829) [ci] fixes #9652 This reverts commit 429825f355658d46f30dda24c3a4dc8a992590dc. Co-authored-by: moritz.kiefer@digitalasset.com * Upgrade Canton to 3.5.14-snapshot.20260815.19176.0.v65fa04f6 (#6797) fwd port from #6789 [ci] --------- Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Add proposal search functionality (#6491) Signed-off-by: Tim Pelzer * Bump deduplication cache size to 1000000 (#6852) [static] We already asked everyone to apply that on mainnet so may as well make it the default. Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Extend splice rate limiting monitoring (#6851) Signed-off-by: Julien Tinguely * Start splice rate limiters with the configured no of permits available (#6849) If not the per IP rate limiters come in to force too aggressive. [ci] Signed-off-by: Nicu Reut * Improve istio rate limits (#6824) * Improve istio rate limits change per ip attribute used increase max dynamic attributes enable rate limiting headers, log them, then drop them add validation to the limits [static] Signed-off-by: Nicu Reut * Improve client ip rate limits (#6853) Add ability to disable fallback for headers Remove default rate limit when no ip is known and instead just count it through a metric (we count on the global rate limiter to avoid SVs that don't extract the ip correctly to just rate limit everyone more aggressivly) [ci] Signed-off-by: Nicu Reut * Add the ability to restrict whitelisting only to the required hosts (#6621) * Add the ability to restrict whitelisting only to the required hosts Instead of allowing access through the general whitelist we add specific scan/sv app/sequencer/cantonbft allow lists that target only the ip that are required and keep the general whitelisting only for internal ips. cantonbft is exposed only to the svs [static] Signed-off-by: Nicu Reut * improve failure message in packagesAreVetted test (#6838) Signed-off-by: Stephen Compall * Allow external CI on forks for staging bases (#6860) (#6864) * Allow external CI on forks for staging bases (#6860) [static] Signed-off-by: Nicu Reut * Actually enable ci for staging bases [static] Signed-off-by: Nicu Reut --------- Signed-off-by: Nicu Reut * Upgrade Canton to 3.5.14-snapshot.20260819.19183.0.va7a6d3ae (#6865) [ci] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Allow closing contributions that did not get maintainer approval beforehand (#6867) [static] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * manually clear BigQuery with datatransfer instead of relying on table expiry (#6813) * added purge datatransfer job for manually deleting records after 7 days from staging tables Signed-off-by: Kajal * Addressed PR comments [ci] Signed-off-by: Kajal * Addressed PR comments - sharing IAMMember [ci] Signed-off-by: Kajal * Addressed PR comments - sharing IAMMember with defaul removed for 7 days [ci] Signed-off-by: Kajal * Addressed PR comments - sharing IAMMember with defaul removed for 7 days [ci] new Signed-off-by: Kajal * fixed zod error messages [ci] Signed-off-by: Kajal * npm run fix changes [[ci] Signed-off-by: Kajal * formatting cahnges to cloudArmor.ts [ci] Signed-off-by: Kajal * [ci] Signed-off-by: Kajal --------- Signed-off-by: Kajal * in splitwell test, wait longer for checkWallets via argument rather than wrapped eventually (#6869) Signed-off-by: Stephen Compall * Enable gcp enhanced insights for enterprise plus (#6873) [static] Signed-off-by: Nicu Reut * fix: added missing feature flag for multisync (#6809) --------- Signed-off-by: jarekr-da * Remove in-app rate limit override for `getDateOfMostRecentSnapshotBefore` (#6875) [static] A client gets rate limited on this on MainNet DA-2. Upon closer inspection, this endpoint triggers a very cheap query answered by an index scan: https://github.com/canton-network/splice/blob/1973921ef727fded6d6d3734b00e7eeca4010b51/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/AcsSnapshotStore.scala#L72 This doesn't sound as if special rate-limiting is needed at all. Signed-off-by: Martin Florian * Bump Canton to 3.5.14 (#6887) [ci] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Clear release notes for 0.7.4 (#6888) [static] Signed-off-by: Nicu Reut * Update versions after 0.7.4 (#6890) [ci] Signed-off-by: Nicu Reut * handle missing objects correctly when getting s3 checksums (#6844) * [ci] handle missing objects correctly when getting s3 checksums Signed-off-by: Itai Segall * [ci] better logging Signed-off-by: Itai Segall * [ci] . Signed-off-by: Itai Segall * [ci] fmt Signed-off-by: Itai Segall --------- Signed-off-by: Itai Segall * Backport PR #6891 (don't OOM on missing projectId with bigQuery.enableStagProdDatastream=true) to main (#6893) --------- Signed-off-by: Stephen Compall Signed-off-by: DA Automation Co-authored-by: Stephen Compall Co-authored-by: DA Automation * Stop scan connection alerts triggering when value is NaN (#6898) Signed-off-by: Julien Tinguely * Add missing sbt dependency (#6897) Seen here as a failure https://github.com/canton-network/splice/actions/runs/32459976647/job/96705530387?pr=6896 [ci] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Avoid racy synchronizer connection query (#6896) fixes https://github.com/DACH-NY/cn-test-failures/issues/9447 and supersedes #6642 Imho this is the proper fix: Avoid making a separate query after the retry when the state may already have changed. [ci] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * client ip rate limiting - configurable headers (#6905) * client ip rate limiting - configurable headers [ci] Signed-off-by: Nicu Reut * Partition overflow problem solved per issue #6919 [ci] (#6923) * Partition overflow problem solved per issue #6919 [ci] Signed-off-by: Kajal * Fix grafana dashboard panel for hidden app reward coupons (#6850) Signed-off-by: Robert Autenrieth * Enable minimal whitelisting by default (#6928) [static] Signed-off-by: Nicu Reut * Final Implementation Initiate Proposal Flow (#6410) Signed-off-by: Puneet Bharti Co-authored-by: Puneet Bharti * fix stack references and their mocks to allow migrating to split-SV deployment on prod clusters (#6908) Signed-off-by: Mateusz Błażejewski * Improve splice rate limit dashboard - include rate limiter type and attribute (#6926) * Use configured client IP headers in request logs (#6924) Signed-off-by: Vladislav Lapin Co-authored-by: Vladislav Lapin * Traffic-Based App Rewards: Clearly distinguish not-ingestable from not-yet-ingested (#6694) Signed-off-by: Tim Emiola * Remove logic to restart triggers on epoch changes. (#6935) Fixes #4265 This removes `RestartDsoDelegateBasedAutomationTrigger` and updates `SvDsoAutomationService` to construct and start the `DsoDelegateBasedAutomationService`. Signed-off-by: Matt Dziuban * Remove log ignore that is stale after #6935 (#6941) I thought about this a bit too late / hit merge on #6935 a bit too quickly... [force] (is fine because that log message doesn't exist anymore) Signed-off-by: Martin Florian * Replace all `(Long, CantonTimestamp)` tuples with `TimestampWithMigrationId` (#6744) * Replace all `(Long, CantonTimestamp)` tuples with `TimestampWithMigrationId`. * Fix regex to ignore sbt GC warning. Signed-off-by: Matt Dziuban * fix GCP_GET_DATABASE_INSTANCES mock to include the right cluster name (#6949) [static] Signed-off-by: Mateusz Błażejewski * Upgrade base image to 1.0.13 [ci] Signed-off-by: moritz.kiefer@digitalasset.com * add rate limits by CIDRs (#6660) Fixes #6640 --------- Signed-off-by: krzysztofczyz-da * Upgrade Canton to 3.5.15-snapshot.20260825.19200.0.va4ea4fe4 [ci] Signed-off-by: moritz.kiefer@digitalasset.com * Widen proposal-details IDs/URLs and show requester party ID (#6915) (#6955) Signed-off-by: Puneet Bharti * [ci] fix race condition in writing s3 objects (#6942) Signed-off-by: Itai Segall * Revert "add rate limits by CIDRs" (#6962) * Revert "add rate limits by CIDRs (#6660)" This reverts commit 5b48337c4e935803190b3db47653c88264fa93b8. * [static] run Signed-off-by: Oriol Muñoz --------- Signed-off-by: Oriol Muñoz * bulk storage: count also committed objects in metrics (#6916) Signed-off-by: Itai Segall Co-authored-by: Stephen Compall * script to auto-generate monday.com events for versions (#6775) Signed-off-by: Itai Segall Co-authored-by: Amanda L Martin Co-authored-by: Stephen Compall * Log consensus scan URIs, revert BFT logging (#6413) Signed-off-by: Ilya Silvestrov * Improve http client dashboard (#6932) include target host in legend and add error table with flexible filter [static] Signed-off-by: Nicu Reut * Disable alerts for close to max capacity for per attribute rate limiters (#6964) We don't keep track of usage for the actual attribute values so the overall reported usage is actually the global usage [static] Signed-off-by: Nicu Reut * TestTokenV2SettlementIntegrationTest prevent validators from using shutting-down canton (#6958) --------- Signed-off-by: Oriol Muñoz * Add `globalSynchronizerHealth` alerts (#6969) See https://github.com/DACH-NY/canton-network-internal/issues/6371 Regular alerts for now, plan is to promote at least some of them to sev-2 and/or sev-1 after some testing. - Discarded requests / replays <- also adds this as panels on the Global Synchronizer Utilization dashboard - Failed confirmation requests - TPS drop * Expose sv app endpoints to the minimum users (#6934) * Expose sv app endpoints to the minimum users Also extend open api so the other SVs can easily apply the same restriction [static] Signed-off-by: Nicu Reut * Add treasury service metrics (#6971) [ci] fixes #6970 Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: Moritz Kiefer * Introduce optional use of ComputeClasses (#6868) Signed-off-by: Robert Autenrieth * Align proposal placeholders and Your Vote styling (#6910, #6912) (#6972) Signed-off-by: Puneet Bharti * Enable test that verifies high tapped amount. (#6913) Fixes #2336 Since #2336 was filed, #4113 deprecated `scan_txlog.py` and removed the code that ran it, but #5922 added a max tap amount of 100,000,000, so the existing test code failed with a "requirement ... was not met" error. This updates the test code to instead tap the max amount. Signed-off-by: Matt Dziuban * fixed app activity table primary and partition keys [ci] (#6981) Signed-off-by: Kajal * Port from Canton: give native protoc plugins a stable launcher path (#6978) --------- Signed-off-by: Oriol Muñoz * bulk storage: Add debug capabilities (#6961) * add the configs Signed-off-by: Itai Segall * [ci] done Signed-off-by: Itai Segall * [ci] fmt Signed-off-by: Itai Segall * [ci] cleanup Signed-off-by: Itai Segall * [ci] fix the test Signed-off-by: Itai Segall * [ci] Signed-off-by: Itai Segall --------- Signed-off-by: Itai Segall * Remove log ignore for `Waiting for allocation` (#6984) That warning doesn't exist in Canton anymore, see https://github.com/DACH-NY/canton/pull/30310/changes#diff-81c61bdb4b62b057717883fd710a5f662ee23d6769f03115d2db110dfda5829aL127 [ci] Fixes #2706 Signed-off-by: Martin Florian * Move standalone mediator ignores where they're picked up (#6982) --------- Signed-off-by: Oriol Muñoz * Exclude duplicate requests for confirmation request failure alert (#6992) And also the dashboard panel, and add a new panel that shows duplicate confirmation requests. Follow-up to #6969 [static] Signed-off-by: Martin Florian * Add http status label to scan failures (#6996) [ci] Signed-off-by: Nicu Reut * Upgrade Canton to 3.5.15 (#7000) [ci] Signed-off-by: moritz.kiefer@digitalasset.com Co-authored-by: moritz.kiefer@digitalasset.com * Add new ACS snapshot endpoints using opaque pagination tokens (#6995) --------- Signed-off-by: Oriol Muñoz * Update cantonbft dashboards (#7002) [static] Signed-off-by: Nicu Reut * Report how to resolve rate-limit failures in deployment-rest (#7001) Signed-off-by: Oriol Muñoz * fix loopback routes when deploying with GKE L7 ALB (#6095) * add port 80 to helm VirtualServices * direct GKE gateway to port 80 on istio * also port 80 for cn-release-bundles and docs routes * Cloud Armor got more restrictive about condition regexes * fix sequencer gRPC ingress by forcing HTTP/2 in istio gateway chart - found with docker-compose validator, doing this SEQ_HOST=sequencer-0.sv-2.$GCP_CLUSTER_HOSTNAME curl -sv --http2 \ -H 'content-type: application/grpc' \ -H 'te: trailers' \ --data-binary $'\x00\x00\x00\x00\x00' \ "https://${SEQ_HOST}/grpc.health.v1.Health/Check" \ -o /dev/null yields < HTTP/2 200 < content-type: application/grpc < grpc-status: 14 < grpc-message: upstream connect error or disconnect/reset before headers. reset reason: protocol error < server: istio-envoy < x-envoy-upstream-service-time: 3 < via: 1.1 google whereas with GKE L7 off we get < HTTP/2 200 < content-type: application/grpc < grpc-status: 13 < grpc-message: Half-closed without a request < x-envoy-upstream-service-time: 4 < date: Mon, 24 Aug 2026 22:17:07 GMT < server: istio-envoy --------- Assisted-by: Copilot:gpt-5.3-codex Signed-off-by: Stephen Compall * Support for installing Sweet Security via pulumi (#6989) Signed-off-by: Itai Segall --------- Signed-off-by: Oriol Muñoz Signed-off-by: Mateusz Błażejewski Signed-off-by: Nicu Reut Signed-off-by: krzysztofczyz-da Signed-off-by: Martin Florian Signed-off-by: moritz.kiefer@digitalasset.com Signed-off-by: Julien Tinguely Signed-off-by: Pasindu Tennage Signed-off-by: Paweł Perek Signed-off-by: Cale Gibbard Signed-off-by: Matt Dziuban Signed-off-by: Stephen Compall Signed-off-by: Stanislav German-Evtushenko Signed-off-by: Raymond Roestenburg Signed-off-by: Tim Pelzer Signed-off-by: Divam Signed-off-by: JYC11 Signed-off-by: Lyutskan Lyutskanov Signed-off-by: Puneet Bharti Signed-off-by: pasindutennage-da Signed-off-by: Matteo Limberto Signed-off-by: Jaeyoon Cho Signed-off-by: DA Automation Signed-off-by: Moritz Kiefer Signed-off-by: Moritz Kiefer Signed-off-by: Richard Kapolnai <53859003+richardkapolnai-da@users.noreply.github.com> Signed-off-by: Robert Autenrieth Signed-off-by: Jaeyoon Cho Signed-off-by: Tim Emiola Signed-off-by: Jagath Weerasinghe Signed-off-by: Kajal Shah Signed-off-by: kajalshah-da Signed-off-by: Itai Segall Signed-off-by: Stephane Loeuillet Signed-off-by: moritzkiefer-da <45630097+moritzkiefer-da@users.noreply.github.com> Signed-off-by: Kajal Signed-off-by: jarekr-da Signed-off-by: Vladislav Lapin Signed-off-by: Ilya Silvestrov Co-authored-by: Oriol Muñoz Co-authored-by: Mateusz Błażejewski Co-authored-by: Nicu Reut Co-authored-by: krzysztofczyz-da Co-authored-by: Martin Florian Co-authored-by: moritzkiefer-da <45630097+moritzkiefer-da@users.noreply.github.com> Co-authored-by: moritz.kiefer@digitalasset.com Co-authored-by: Julien Tinguely Co-authored-by: Pasindu Tennage Co-authored-by: Paweł Perek Co-authored-by: Cale Gibbard Co-authored-by: Matt Dziuban Co-authored-by: Stephen Compall Co-authored-by: Stanislav German-Evtushenko Co-authored-by: Raymond Roestenburg <98821776+ray-roestenburg-da@users.noreply.github.com> Co-authored-by: Tim Co-authored-by: Divam <681060+dfordivam@users.noreply.github.com> Co-authored-by: Jaeyoon Cho Co-authored-by: Divam Co-authored-by: aonif <104995055+aonif@users.noreply.github.com> Co-authored-by: Puneet Bharti Co-authored-by: Matteo Limberto Co-authored-by: Simon Meier Co-authored-by: canton-network-da Co-authored-by: DA Automation Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Richard Kapolnai <53859003+richardkapolnai-da@users.noreply.github.com> Co-authored-by: Robert Autenrieth <31539813+rautenrieth-da@users.noreply.github.com> Co-authored-by: Tim Emiola Co-authored-by: Jagath Weerasinghe Co-authored-by: kajalshah-da Co-authored-by: Itai Segall Co-authored-by: Stéphane Loeuillet Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: jarekr-da Co-authored-by: loglapa <51929896+loglapa@users.noreply.github.com> Co-authored-by: Vladislav Lapin Co-authored-by: Timothy Emiola Co-authored-by: Amanda L Martin Co-authored-by: Ilya Silvestrov --- .envrc | 26 +- .envrc.validate | 9 +- .envrc.vars | 2 +- .github/actions/nix/setup_nix/action.yml | 33 +- .../sbt/execute_sbt_command/action.yml | 10 +- .../tests/common_test_setup/action.yml | 16 +- .github/actions/tests/scala_test/action.yml | 21 +- .../actions/tests/skip_on_static/action.yml | 15 +- .github/runners/runner-container-hooks | 2 +- .github/store-perf-thresholds.json | 30 +- .github/workflows/build.daml_test.yml | 1 - .github/workflows/build.deployment_test.yml | 3 +- .github/workflows/build.docs.yml | 7 +- .github/workflows/build.scala_test.yml | 10 +- .../build.scala_test_for_compose.yml | 4 +- .../build.scala_test_with_cometbft.yml | 4 +- .github/workflows/build.static_tests.yml | 15 +- .github/workflows/build.ts_cli_tests.yml | 3 +- .github/workflows/build.ui_tests.yml | 5 +- .github/workflows/build.yml | 45 +- .github/workflows/bump_gha_runner_version.yml | 4 +- .github/workflows/canton_oss_test.yml | 22 - .github/workflows/daml_compat_test.yml | 7 +- .github/workflows/monthly-schedule.yml | 53 + .github/workflows/performance_tests.yml | 10 +- .github/workflows/pr_check_github_scripts.yml | 6 +- .github/workflows/pr_non_contributors.yml | 2 +- .github/workflows/pr_static_checks.yml | 1 - .gitignore | 2 + CANTON_CODE_CHANGES.md | 1 + CONTRIBUTING.md | 7 +- DEVELOPMENT.md | 69 +- LATEST_RELEASE | 2 +- MAINTENANCE.md | 12 +- PERFORMANCE.md | 8 +- TROUBLESHOOTING.md | 2 - VERSION | 2 +- apps/app/src/main/resources/application.conf | 3 +- .../splice/config/SpliceConfig.scala | 106 +- .../splice/console/ScanAppReference.scala | 64 +- .../splice/console/SvAppReference.scala | 34 +- .../examples/sv-helm/cometbft-values.yaml | 5 + .../sv-helm/global-domain-values.yaml | 2 +- .../sv-helm/kms-participant-aws-values.yaml | 5 + .../sv-helm/kms-participant-gcp-values.yaml | 5 + .../test/resources/include/scans/_scan.conf | 10 +- .../test/resources/include/sequencers.conf | 2 + .../src/test/resources/include/svs/_sv.conf | 10 +- .../resources/localnet-reassign-topology.conf | 38 + .../splice/config/SpliceConfigTest.scala | 90 +- .../integration/EnvironmentDefinition.scala | 22 +- .../EventHistorySanityCheckPlugin.scala | 12 +- .../UpdateHistorySanityCheckPlugin.scala | 8 +- .../integration/plugins/UseToxiproxy.scala | 7 +- .../tests/AppUpgradeIntegrationTest.scala | 10 +- ...reUnresponsivePartiesIntegrationTest.scala | 2 +- .../BftScanConnectionIntegrationTest.scala | 2 - ...ootstrapPackageConfigIntegrationTest.scala | 18 +- ...DevelopmentFundCouponIntegrationTest.scala | 7 +- .../DistributedDomainIntegrationTest.scala | 2 +- ...conciliationTimeBasedIntegrationTest.scala | 21 +- ...inimalVettedPackagesIntegrationTest.scala} | 251 +- ...nalPartySetupProposalIntegrationTest.scala | 1 + .../tests/ExternallySignedPartyTestUtil.scala | 24 + .../tests/ExternallySignedTxTest.scala | 2 +- ...llySignedTxsTimeBasedIntegrationTest.scala | 1 + .../LocalNetReassignIntegrationTest.scala | 196 + .../tests/LsuIntegrationTest.scala | 428 +- .../ManualSignatureIntegrationTest.scala | 7 - ...HostValidatorOperatorIntegrationTest.scala | 15 +- ...icipantKmsIdentitiesIntegrationTest.scala} | 2 +- ...tSequencingParametersIntegrationTest.scala | 45 +- .../RecoverExternalPartyIntegrationTest.scala | 2 +- .../RollForwardLsuDRIntegrationTest.scala | 2 +- .../tests/RollForwardLsuIntegrationTest.scala | 14 +- ...canHistoryBackfillingIntegrationTest.scala | 74 +- .../tests/ScanIntegrationTest.scala | 132 +- .../tests/ScanTimeBasedIntegrationTest.scala | 29 +- .../SplitwellFrontendIntegrationTest.scala | 24 +- .../tests/SvFrontendIntegrationTest.scala | 97 +- .../SvOnboardingAddlIntegrationTest.scala | 10 +- ...rdingViaNonFoundingSvIntegrationTest.scala | 13 +- .../SvStateManagementIntegrationTest.scala | 13 +- ...SvTimeBasedOnboardingIntegrationTest.scala | 5 +- ...TimeBasedRewardCouponIntegrationTest.scala | 34 +- ...TestTokenV2SettlementIntegrationTest.scala | 1177 +-- ...dCliTestDataTimeBasedIntegrationTest.scala | 6 +- ...TokenStandardTransferIntegrationTest.scala | 34 +- ...kenStandardV2TransferIntegrationTest.scala | 34 +- ...RewardsSvAppTimeBasedIntegrationTest.scala | 252 +- ...BasedRewardsTimeBasedIntegrationTest.scala | 80 +- ...wardCouponV2TimeBasedIntegrationTest.scala | 199 +- ...pportedPackageVettingIntegrationTest.scala | 100 +- ...ValidatorReonboardingIntegrationTest.scala | 2 +- ...lletBuyTrafficRequestIntegrationTest.scala | 6 + .../tests/WalletIntegrationTest.scala | 55 +- ...ngDelegationTimeBasedIntegrationTest.scala | 108 +- .../tests/WalletPaymentIntegrationTest.scala | 16 +- ...alletRewardsTimeBasedIntegrationTest.scala | 65 +- .../WalletTimeBasedIntegrationTest.scala | 70 +- .../tests/WalletTxLogIntegrationTest.scala | 16 +- ...unbookSvPreflightIntegrationTestBase.scala | 34 - .../SvNonDevNetPreflightintegrationTest.scala | 4 - .../tests/BaseStorePerformanceTest.scala | 13 +- .../unit/http/HttpClientProxyTest.scala | 2 +- .../splice/util/FrontendLoginUtil.scala | 7 +- .../splice/util/UpdateHistoryTestUtil.scala | 32 +- .../splice/util/WalletTestUtil.scala | 3 +- .../frontend/src/__tests__/dso.test.tsx | 75 + apps/common/frontend/src/components/Dso.tsx | 2 +- apps/common/frontend/src/theme/index.ts | 20 +- .../concurrent/BurstyRateLimiterFactory.java | 51 + .../src/main/openapi/common-internal.yaml | 27 + .../stable/V073__dso_unavailable_parties.sql | 26 + .../splice/SpliceMetrics.scala | 26 +- .../splice/admin/api/HttpRequestLogger.scala | 16 +- .../splice/admin/http/HttpAdminService.scala | 5 +- ...hedMultiDomainExpiredContractTrigger.scala | 33 +- .../splice/automation/PollingTrigger.scala | 2 + .../SqlIndexInitializationTrigger.scala | 17 +- .../splice/config/RateLimitersConfig.scala | 42 +- .../splice/config/SpliceConfig.scala | 2 + .../config/SpliceParametersConfig.scala | 4 +- .../splice/environment/DarResources.scala | 16 +- .../LsuTopologyAdminConnection.scala | 78 +- .../environment/NodeBootstrapBase.scala | 6 +- ...rticipantAdminSynchronizerConnection.scala | 25 +- .../splice/environment/RetryFor.scala | 7 + .../splice/environment/RetryProvider.scala | 17 + .../SequencerAdminConnection.scala | 75 +- .../SequencerBftAdminConnection.scala | 2 +- .../environment/SpliceLedgerClient.scala | 2 +- .../environment/SpliceLedgerConnection.scala | 53 +- .../environment/StatusAdminConnection.scala | 12 +- .../environment/ledger/api/LedgerClient.scala | 149 +- .../splice/http/ClientIpDirectives.scala | 50 + .../splice/http/HttpClient.scala | 42 +- .../splice/http/HttpRateLimiter.scala | 161 +- .../metrics/ScanConnectionMetrics.scala | 4 +- .../store/DbVotesStoreQueryBuilder.scala | 93 +- .../splice/store/HistoryMetrics.scala | 12 +- .../splice}/store/IgnoredPartiesStore.scala | 3 +- .../splice/store/IngestionSummary.scala | 16 +- .../splice/store/MultiDomainAcsStore.scala | 4 +- .../splice/store/S3BucketConnection.scala | 45 +- .../splice/store/StoreMetrics.scala | 28 +- .../store/UnavailablePartiesStore.scala | 25 + .../splice/store/UpdateHistory.scala | 17 +- .../splice/store/VotesStore.scala | 20 +- .../splice/store/bulk/ZstdGroupedWeight.scala | 31 +- .../splice/store/db/AcsJdbcTypes.scala | 43 +- .../splice/store/db/AcsQueries.scala | 40 +- .../splice/store/db/AdvisoryLockIds.scala | 1 + .../splice/store/db/AdvisoryLocks.scala | 63 + .../splice/store/db/AsUpdateReturning.scala | 39 + .../store/db/DbMultiDomainAcsStore.scala | 124 +- .../store/db/DbUnavailablePartiesStore.scala | 121 + .../splice/store/db/JdbcTypes.scala | 52 + .../splice/store/db/Queries.scala | 48 + .../splice/util/DarResourcesUtil.scala | 14 +- .../splice/util/PackageVetting.scala | 1 - .../splice/util/SpliceCircuitBreaker.scala | 24 +- .../splice/util/SpliceRateLimiter.scala | 236 +- .../splice/util/TemplateJsonDecoder.scala | 19 +- .../admin/api/HttpRequestLoggerTest.scala | 51 +- ...lIndexInitializationTriggerStoreTest.scala | 57 +- .../splice/http/HttpRateLimiterTest.scala | 555 ++ .../http/InvalidResponseContentTest.scala | 89 + .../store/DbUnavailablePartiesStoreTest.scala | 330 + .../splice/store/UpdateHistoryTest.scala | 5 +- .../splice/store/db/AdvisoryLocksTest.scala | 124 + .../splice/store/db/SpliceDbTest.scala | 3 +- .../util/SpliceCircuitBreakerTest.scala | 80 +- .../splice/util/SpliceRateLimiterTest.scala | 191 +- .../darutils/DarResourcesGenerator.scala | 28 +- .../splice/metrics/MetricsDocs.scala | 5 +- apps/package-lock.json | 6 +- apps/scan/src/main/openapi/scan.yaml | 390 +- apps/scan/src/main/protobuf/scan_tx_log.proto | 169 - .../splice/scan/ScanApp.scala | 177 +- .../splice/scan/ScanSynchronizerNode.scala | 2 +- .../admin/api/client/BftScanConnection.scala | 266 +- .../admin/api/client/ScanConnection.scala | 21 +- .../api/client/SingleScanConnection.scala | 57 +- .../client/commands/HttpScanAppClient.scala | 149 +- .../scan/admin/http/HttpScanHandler.scala | 520 +- .../scan/admin/http/ScanHttpEncodings.scala | 11 +- .../automation/AcsSnapshotTriggerBase.scala | 3 +- .../automation/ScanAutomationService.scala | 9 +- .../ScanHistoryBackfillingTrigger.scala | 74 +- .../ScanVerdictAutomationService.scala | 10 +- .../ScanVerdictIngestionService.scala | 55 +- .../scan/config/CantonBftPeerConfig.scala | 4 +- .../splice/scan/config/ScanAppConfig.scala | 18 +- .../scan/config/ScanStorageConfig.scala | 23 + .../scan/config/TokenStandardConfig.scala | 13 + .../splice/scan/metrics/ScanAppMetrics.scala | 2 +- .../scan/rewards/AppActivityComputation.scala | 19 +- .../splice/scan/store/AcsSnapshotStore.scala | 94 +- .../splice/scan/store/AppActivityStore.scala | 38 +- .../CachingScanRewardsReferenceStore.scala | 9 +- .../splice/scan/store/CachingScanStore.scala | 35 +- .../splice/scan/store/ScanEventStore.scala | 21 +- .../store/ScanRewardsReferenceStore.scala | 20 +- .../splice/scan/store/ScanStore.scala | 10 - .../splice/scan/store/ScanTxLogParser.scala | 886 +-- .../splice/scan/store/TxLogEntry.scala | 276 +- .../store/bulk/AcsSnapshotBulkStorage.scala | 10 + ...SnapshotBulkStorageCommitFromStaging.scala | 12 +- .../splice/scan/store/bulk/BulkStorage.scala | 100 +- .../bulk/BulkStorageCommitFromStaging.scala | 143 +- .../scan/store/bulk/BulkStorageReader.scala | 57 +- .../bulk/GroupedWeightS3ObjectFlow.scala | 28 +- .../bulk/MultiEncodingBulkStorageFlow.scala | 49 + .../bulk/SingleAcsSnapshotBulkStorage.scala | 77 +- .../store/bulk/UpdateHistoryBulkStorage.scala | 4 + ...eHistoryBulkStorageCommitFromStaging.scala | 13 +- .../UpdateHistorySegmentBulkStorage.scala | 67 +- .../store/db/DbAppActivityRecordStore.scala | 57 +- .../db/DbScanRewardsReferenceStore.scala | 14 +- .../splice/scan/store/db/DbScanStore.scala | 90 +- .../scan/store/db/DbScanVerdictStore.scala | 35 +- .../splice/scan/store/db/ScanTables.scala | 61 - .../scan/util/PeerBftScanConnection.scala | 67 + .../api/client/BftScanConnectionTest.scala | 66 +- .../api/client/SingleScanConnectionTest.scala | 62 + .../scan/config/TokenStandardConfigTest.scala | 33 +- .../rewards/AppActivityComputationTest.scala | 3 +- .../store/DbAppActivityRecordStoreTest.scala | 143 +- .../store/DbScanAppRewardsStoreTest.scala | 550 +- .../QueryAcsSnapshotPaginationTokenTest.scala | 51 + .../scan/store/ScanEventStoreTest.scala | 63 +- ...shotBulkStorageCommitFromStagingTest.scala | 36 +- ...sSnapshotBulkStorageWriterFromDbTest.scala | 163 +- .../BulkStorageCommitFromStagingTest.scala | 326 +- .../splice/scan/store/bulk/S3UploadTest.scala | 111 +- .../bulk/UpdateHistoryBulkStorageTest.scala | 302 +- .../store/ScanHistoryBackfillingTest.scala | 6 +- .../store/db/AcsSnapshotStoreTest.scala | 2 +- .../db/DbScanRewardsReferenceStoreTest.scala | 21 +- .../splice/store/db/ScanStoreTest.scala | 271 +- .../metrics/SplitwellAppMetrics.scala | 4 +- apps/sv/frontend/index.html | 9 +- apps/sv/frontend/src/App.tsx | 6 +- .../components/copyable-identifier.test.tsx | 159 + .../action-required-section.test.tsx | 50 +- .../governance/create-proposal.test.tsx | 23 +- ...allocated-unclaimed-activity-form.test.tsx | 14 +- .../grant-revoke-featured-app-form.test.tsx | 164 +- .../forms/offboard-sv-form.test.tsx | 44 +- .../governance/forms/pending-fields.test.tsx | 2 +- .../forms/set-amulet-rules-form.test.tsx | 84 +- .../forms/set-dso-rules-form.test.tsx | 48 +- .../forms/update-featured-app-form.test.tsx | 315 + ...update-sv-reward-weight-form-test.test.tsx | 65 +- .../governance/governance-page.test.tsx | 148 +- .../governance/governance-sorting.test.tsx | 13 +- .../proposal-details-content.test.tsx | 415 +- .../governance/proposal-listing.test.tsx | 61 +- .../proposal-search-validation.test.ts | 23 + .../governance/proposal-search.test.tsx | 83 + .../governance/proposal-summary.test.tsx | 196 +- .../src/__tests__/layout/sv-top-nav.test.tsx | 35 + .../frontend/src/__tests__/mocks/constants.ts | 8 +- .../src/__tests__/mocks/handlers/sv-api.ts | 28 +- apps/sv/frontend/src/__tests__/sv.test.tsx | 27 +- .../buildAmuletRulesConfigFromChanges.test.ts | 6 +- .../utils/getRequesterPartyId.test.ts | 31 + apps/sv/frontend/src/components/Layout.tsx | 112 +- .../src/components/PartyIdScrollTracks.tsx | 107 + .../components/beta/CopyableIdentifier.tsx | 162 +- .../src/components/beta/CopyableUrl.tsx | 105 +- .../src/components/beta/MemberIdentifier.tsx | 24 +- .../src/components/beta/PageSectionHeader.tsx | 14 +- .../src/components/beta/identifierStyles.ts | 171 + .../form-components/ConfigField.tsx | 190 +- .../components/form-components/DateField.tsx | 29 +- .../form-components/EffectiveDateField.tsx | 149 +- .../form-components/FormControls.tsx | 89 +- .../form-components/ProposalSummaryField.tsx | 29 +- .../form-components/ProposalTypeField.tsx | 13 +- .../form-components/RadioSelector.tsx | 144 + .../form-components/SelectField.tsx | 33 +- .../components/form-components/TextField.tsx | 47 +- ...UnallocatedUnclaimedActivityRecordForm.tsx | 43 +- .../src/components/forms/FormLayout.tsx | 36 +- .../forms/GrantRevokeFeaturedAppForm.tsx | 149 +- .../src/components/forms/OffboardSvForm.tsx | 50 +- .../src/components/forms/SelectAction.tsx | 199 +- .../forms/SetAmuletConfigRulesForm.tsx | 107 +- .../forms/SetDsoConfigRulesForm.tsx | 108 +- .../forms/UpdateFeaturedAppForm.tsx | 277 + .../forms/UpdateSvRewardWeightForm.tsx | 48 +- .../src/components/forms/formValidators.ts | 42 + .../governance/ActionRequiredSection.tsx | 210 +- .../governance/CancelProposalDialog.tsx | 96 + .../governance/ConfigValuesChanges.tsx | 54 +- .../governance/InitiateProposalHeader.tsx | 43 + .../governance/InitiateProposalLayout.tsx | 24 + .../governance/JsonDiffAccordion.tsx | 273 +- .../governance/ProposalDetailsContent.tsx | 346 +- .../governance/ProposalListingSection.tsx | 209 +- .../governance/ProposalReviewField.tsx | 63 + .../components/governance/ProposalSearch.tsx | 215 + .../components/governance/ProposalSummary.tsx | 255 +- .../governance/ProposalVoteForm.tsx | 90 +- .../proposal-details/DetailItem.tsx | 9 +- .../src/components/layout/LogoutButton.tsx | 68 + .../src/components/layout/LogoutIcon.tsx | 33 + .../components/layout/NavAttentionIcon.tsx | 31 + .../src/components/layout/NavCountBadge.tsx | 46 + .../src/components/layout/NetworkBanner.tsx | 34 + .../src/components/layout/SvNavLink.tsx | 82 + .../components/layout/SvNavigationShell.tsx | 42 + .../src/components/layout/SvTopNav.tsx | 120 + .../frontend/src/components/ui/Dropdown.tsx | 211 + .../votes/actions/GrantFeaturedAppRight.tsx | 25 +- .../src/constants/createProposalLayout.ts | 67 + .../src/constants/formButtonStyles.ts | 70 + .../src/contexts/SvAdminServiceContext.tsx | 11 + apps/sv/frontend/src/hooks/index.ts | 4 +- .../src/hooks/useFeaturedAppRightPicker.ts | 79 + .../src/hooks/useHorizontalScrollMetrics.ts | 61 + .../src/hooks/useListVoteRequests.tsx | 142 +- .../src/hooks/useNetworkInstanceName.ts | 27 +- apps/sv/frontend/src/hooks/useVoteRequest.tsx | 9 +- .../src/hooks/useVoteRequestResultByCid.tsx | 57 +- .../src/hooks/useVoteRequestResultsCount.ts | 20 + .../sv/frontend/src/routes/createProposal.tsx | 9 +- .../frontend/src/routes/delegateElection.tsx | 14 + apps/sv/frontend/src/routes/governance.tsx | 269 +- .../src/routes/voteRequestDetails.tsx | 27 +- apps/sv/frontend/src/theme/tokens.ts | 56 + apps/sv/frontend/src/themes/fieldStyles.ts | 329 + .../src/utils/buildAmuletConfigChanges.ts | 26 +- .../src/utils/buildDsoConfigChanges.ts | 24 +- apps/sv/frontend/src/utils/constants.ts | 35 + apps/sv/frontend/src/utils/governance.ts | 96 +- apps/sv/frontend/src/utils/proposalSearch.ts | 94 + apps/sv/frontend/src/utils/types.ts | 29 +- apps/sv/src/main/openapi/sv-internal.yaml | 30 +- .../splice/sv/SvApp.scala | 22 +- .../commands/HttpSvAdminAppClient.scala | 18 - .../commands/HttpSvOperatorAppClient.scala | 69 +- .../sv/admin/http/HttpSvAdminHandler.scala | 21 - .../sv/admin/http/HttpSvOperatorHandler.scala | 63 +- .../sv/admin/http/HttpSvPublicHandler.scala | 1 + .../DsoDelegateBasedAutomationService.scala | 49 +- .../PeriodicTopologySnapshotTrigger.scala | 20 +- .../automation/SvDsoAutomationService.scala | 50 +- .../VoteRequestMetricsTrigger.scala | 113 + .../CalculateRewardsTrigger.scala | 8 +- .../SummarizingMiningRoundTrigger.scala | 9 +- .../ExpireRewardCouponV2Trigger.scala | 43 +- .../ExpireRewardCouponsTrigger.scala | 81 +- .../ExpireTransferPreapprovalsTrigger.scala | 49 +- .../ExpiredAmuletAllocationTrigger.scala | 44 +- .../ExpiredAmuletAllocationV2Trigger.scala | 41 +- ...iredAmuletTransferInstructionTrigger.scala | 46 +- .../delegatebased/ExpiredAmuletTrigger.scala | 31 +- .../ExpiredAnsEntryTrigger.scala | 45 +- .../ExpiredAnsSubscriptionTrigger.scala | 43 +- .../ExpiredLockedAmuletTrigger.scala | 39 +- .../FeaturedAppActivityMarkerTrigger.scala | 63 +- .../IgnoredAmuletVersionGuard.scala | 67 - .../IgnoredUnavailablePartiesGuard.scala | 110 + .../delegatebased/ProcessRewardsTrigger.scala | 46 +- .../delegatebased/SvTaskBasedTrigger.scala | 14 +- ...DynamicSynchronizerParametersTrigger.scala | 2 + ...artDsoDelegateBasedAutomationTrigger.scala | 204 - .../splice/sv/config/SvAppConfig.scala | 18 +- .../splice/sv/lsu/LsuTrigger.scala | 9 +- .../SequencerBftPeerReconciler.scala | 80 +- .../sv/onboarding/sv1/SV1Initializer.scala | 2 +- .../splice/sv/store/SvDsoStore.scala | 17 +- .../splice/sv/store/db/DbSvDsoStore.scala | 24 +- .../splice/sv/util/ContractStakeholders.scala | 30 + .../splice/store/db/SvDsoStoreTest.scala | 168 +- .../VoteRequestMetricsTriggerTest.scala | 35 + .../SequencerBftPeerReconcilerSpec.scala | 76 +- .../splice/validator/ValidatorApp.scala | 1 + .../http/HttpValidatorAdminHandler.scala | 189 +- ...ptTransferPreapprovalProposalTrigger.scala | 13 +- ...ReconcileSequencerConnectionsTrigger.scala | 2 +- .../ValidatorAutomationService.scala | 20 +- .../ValidatorPackageVettingTrigger.scala | 3 +- .../validator/config/ValidatorAppConfig.scala | 2 + .../domain/SynchronizerConnector.scala | 2 +- .../wallet/ExternalPartyWalletManager.scala | 47 +- .../wallet/ExternalPartyWalletService.scala | 41 +- .../splice/wallet/UserWalletManager.scala | 63 +- .../splice/wallet/UserWalletService.scala | 99 +- .../wallet/admin/http/HttpWalletHandler.scala | 20 + .../admin/http/HttpWalletHandlerUtil.scala | 1 + ...ntingDelegationCollectRewardsTrigger.scala | 64 +- .../automation/RewardSharingTrigger.scala | 2 +- .../UserWalletAutomationService.scala | 19 +- .../wallet/config/WalletAppConfig.scala | 71 +- .../wallet/metrics/TreasuryMetrics.scala | 51 + .../wallet/treasury/TreasuryService.scala | 154 +- build-tools/artifactory_to_gcs.py | 785 -- build-tools/bump-canton.sh | 2 +- build-tools/cncluster | 28 +- .../copy_release_helm_charts_to_ghcr.sh | 2 +- build-tools/copy_release_images_to_ghcr.sh | 4 +- .../splice/build_tools/DarLockChecker.scala | 37 +- .../build_tools/DarLockCheckerTest.scala | 47 + .../lib/hard-domain-migration-commands | 137 - .../lib/logical-synchronizer-upgrade-commands | 105 + build.sbt | 77 +- .../lf/value/json/ApiCodecCompressed.scala | 0 .../lf/value/json/ApiValueImplicits.scala | 0 .../daml/lf/value/json/JsonVariant.scala | 0 .../lf/value/json/NavigatorModelAliases.scala | 0 .../value/json/ApiCodecCompressedSpec.scala | 407 + canton/.proto_snapshot_image.bin.gz | Bin 518672 -> 517511 bytes canton/CODEOWNERS | 3 + canton/UNRELEASED.md | 848 +- canton/VERSION | 2 +- .../com/daml/clock/AdjustableClock.scala | 37 - .../com/daml/jwt/AuthServiceJWTPayload.scala | 413 - .../src/main/scala/com/daml/jwt/Base64.scala | 28 - .../src/main/scala/com/daml/jwt/Error.scala | 11 - .../main/scala/com/daml/jwt/JwtDecoder.scala | 17 - .../com/daml/jwt/JwtFromBearerHeader.scala | 16 - .../scala/com/daml/jwt/JwtGenerator.scala | 10 - .../main/scala/com/daml/jwt/JwtSigner.scala | 90 - .../com/daml/jwt/JwtTimestampLeeway.scala | 41 - .../main/scala/com/daml/jwt/JwtVerifier.scala | 179 - .../main/scala/com/daml/jwt/KeyUtils.scala | 155 - .../com/daml/jwt/WithExecuteUnsafe.scala | 11 - .../src/main/scala/com/daml/jwt/package.scala | 38 - .../daml/jwt/JwtFromBearerHeaderSpec.scala | 31 - .../com/daml/jwt/JwtTimestampLeewaySpec.scala | 361 - .../scala/com/daml/jwt/SignatureSpec.scala | 219 - .../scala/com/daml/tls/OcspProperties.scala | 24 - .../scala/com/daml/tls/ProtocolDisabler.scala | 35 - .../main/scala/com/daml/tls/TlsConfig.scala | 281 - .../scala/com/daml/tls/TlsConfiguration.scala | 89 - .../com/daml/tls/TlsConfigurationCli.scala | 62 - .../src/main/scala/com/daml/tls/TlsInfo.scala | 28 - .../main/scala/com/daml/tls/TlsVersion.scala | 27 - .../test/resources/gen-test-certificates.sh | 162 - .../openssl-alternative-template.cnf | 90 - .../src/test/resources/openssl-template.cnf | 90 - .../test/resources/test-certificates/ca.crt | 29 - .../test/resources/test-certificates/ca.key | 52 - .../test-certificates/ca_alternative.crt | 29 - .../test-certificates/ca_alternative.key | 52 - .../test-certificates/ca_alternative.pem | 52 - .../test-certificates/client-revoked.crt | 25 - .../test-certificates/client-revoked.csr | 16 - .../test-certificates/client-revoked.key | 28 - .../test-certificates/client-revoked.pem | 28 - .../resources/test-certificates/client.crt | 31 - .../resources/test-certificates/client.csr | 27 - .../resources/test-certificates/client.key | 52 - .../resources/test-certificates/client.pem | 52 - .../resources/test-certificates/index.txt | 4 - .../test/resources/test-certificates/ocsp.crt | 30 - .../test/resources/test-certificates/ocsp.csr | 26 - .../resources/test-certificates/ocsp.key.pem | 52 - .../resources/test-certificates/server.crt | 31 - .../resources/test-certificates/server.csr | 27 - .../resources/test-certificates/server.key | 52 - .../resources/test-certificates/server.pem | 52 - .../test-certificates/server.pem.enc | 69 - .../com/daml/tls/ProtocolDisablerTest.scala | 74 - .../com/digitalasset/base/error/Alarm.scala | 48 - .../digitalasset/base/error/BaseError.scala | 138 - .../base/error/BaseErrorLogger.scala | 37 - .../digitalasset/base/error/DamlError.scala | 58 - .../base/error/ErrorCategory.scala | 449 -- .../digitalasset/base/error/ErrorClass.scala | 32 - .../digitalasset/base/error/ErrorCode.scala | 210 - .../digitalasset/base/error/ErrorGroup.scala | 12 - .../base/error/ErrorResource.scala | 80 - .../base/error/GrpcStatuses.scala | 27 - .../base/error/LogOnCreation.scala | 13 - .../digitalasset/base/error/RpcError.scala | 35 - .../error/SerializableErrorComponents.scala | 315 - .../base/error/samples/Example.scala | 122 - .../base/error/utils/DecodedCantonError.scala | 234 - .../base/error/utils/ErrorDetails.scala | 118 - .../base/error/ErrorCodeSpec.scala | 387 - .../base/error/ErrorGenerator.scala | 83 - .../base/error/ErrorGroupSpec.scala | 36 - .../base/error/ErrorsAssertions.scala | 131 - .../base/error/GrpcStatusesSpec.scala | 58 - .../base/error/RedactedMessageSpec.scala | 27 - .../SerializableErrorComponentsSpec.scala | 205 - .../error/samples/SampleClientSideSpec.scala | 15 - .../base/error/utils/BenignError.scala | 31 - .../error/utils/DecodedCantonErrorSpec.scala | 199 - .../base/error/utils/ErrorDetailsSpec.scala | 70 - .../base/error/utils/SevereError.scala | 31 - .../src/main/resources/logback-test.xml | 61 + .../testing/utils/GrpcServerResource.scala | 7 +- .../config/ConfidentialConfigWriter.scala | 21 - .../canton/config/KeyStoreConfig.scala | 27 - .../canton/config/PemFileOrString.scala | 33 - .../canton/config/RequireTypes.scala | 418 - .../canton/discard/Implicits.scala | 14 - .../canton/time/TimeProvider.scala | 12 - .../digitalasset/canton/util/BytesUnit.scala | 60 - .../canton/util/JarResourceUtils.scala | 34 - .../com/digitalasset/canton/util/Mutex.scala | 92 - .../canton/util/VersionUtil.scala | 34 - .../canton/config/RequireTypesTest.scala | 24 - canton/build.sbt | 4 + .../client/commands/LedgerApiCommands.scala | 62 +- .../commands/SequencerBftAdminCommands.scala | 19 +- .../data/SynchronizerConnectionConfig.scala | 7 +- .../canton/config/CantonConfig.scala | 131 +- .../canton/console/InstanceReference.scala | 2 +- .../commands/LedgerApiAdministration.scala | 32 + .../DeclarativeParticipantApi.scala | 5 +- .../canton/environment/Environment.scala | 22 +- .../main/resources/sandbox/bootstrap.canton | 2 +- .../digitalasset/canton/CantonAppDriver.scala | 3 +- .../composability-auto-reassignment.canton | 2 +- .../05-composability/composability1.canton | 2 +- .../05-composability/composability2.canton | 2 +- .../requirements.txt | 2 +- .../dashboards/canton-network/sequencer.json | 2 +- .../canton/config/CantonConfigTest.scala | 70 +- .../canton/config/ConfigValidationsTest.scala | 28 +- .../console/ConsoleEnvironmentTest.scala | 8 +- .../canton/console/ConsoleTest.scala | 2 +- .../CommunityEnvironmentFixture.scala | 2 +- .../canton/environment/NodesTest.scala | 2 + ...wnloadTopologyForInitIntegrationTest.scala | 13 +- .../tests/DumpIntegrationTest.scala | 2 + ...MultipleSynchronizersIntegrationTest.scala | 2 +- .../tests/ProtobufCompatibilityTest.scala | 35 +- .../ReplicatedMediatorIntegrationTest.scala | 4 +- .../tests/ReplicatedParticipantTest.scala | 4 +- ...SequencerIdsRetrieverIntegrationTest.scala | 15 +- ...nRequestAmplificationIntegrationTest.scala | 13 +- .../SynchronizerChangeIntegrationTest.scala | 2 +- ...chronizerConnectivityIntegrationTest.scala | 14 + ...nchronizerRouterIntegrationTestSetup.scala | 2 +- .../tests/TickRequestIntegrationTest.scala | 2 +- .../acs/ActiveContractsIntegrationTest.scala | 11 +- ...csCommitmentProcessorIntegrationTest.scala | 2 +- .../AcsCommitmentToolingIntegrationTest.scala | 2 +- .../commitment/util/CommitmentTestUtil.scala | 60 +- .../benchmarks/ReplayingParticipant.scala | 1 + .../bftsynchronizer/ReassignmentTest.scala | 2 +- ...tSequencerConnectionsIntegrationTest.scala | 8 +- .../ConnectionPoolHealthIntegrationTest.scala | 8 +- ...ncerConnectionServiceIntegrationTest.scala | 8 +- .../AcsCommitmentCrashIntegrationTest.scala | 7 +- .../MediatorFailoverIntegrationTest.scala | 4 +- .../ParticipantRestartTest.scala | 15 +- .../crashrecovery/SequencerRestartTest.scala | 2 +- .../examples/ExampleIntegrationTest.scala | 6 +- .../MultisyncExampleIntegrationTest.scala | 2 +- .../health/RemoteDumpIntegrationTest.scala | 6 +- .../ledgerapi/AchsInitIntegrationTest.scala | 162 + .../tests/ledgerapi/AchsIntegrationTest.scala | 4 +- .../ledgerapi/AchsRepairIntegrationTest.scala | 4 +- .../LedgerApiParticipantPruningTest.scala | 121 +- .../ledgerapi/auth/GetCompletionsAuthIT.scala | 118 + .../auth/IDPBoxingServiceCallOutTests.scala | 40 + .../ledgerapi/fixture/CantonFixture.scala | 4 +- ...ternalPartyOnboardingIntegrationTest.scala | 182 +- .../tests/manual/BftOrderingBenchmark.scala | 93 +- .../manual/BftSingleNodePerformanceTest.scala | 7 +- .../tests/manual/DataContinuityTest.scala | 61 +- .../tests/manual/S3Synchronization.scala | 2 +- .../topology/TopologyStateVerification.scala | 1 + ...ConnectionPoolMetricsIntegrationTest.scala | 232 + ...entDecentralizedPartyIntegrationTest.scala | 2 +- .../DivulgenceIntegrationTestHelpers.scala | 4 +- ...tyReplicationParticipantProtocolTest.scala | 2 +- .../offpr/DivulgenceIntegrationTest.scala | 21 +- .../offpr/WorkflowIdsIntegrationTest.scala | 10 +- ...entBeforeUnassignmentIntegrationTest.scala | 2 +- ...sReassignmentProtocolIntegrationTest.scala | 2 +- ...cReassignmentBatchingIntegrationTest.scala | 2 +- ...onfirmationAdminPartyIntegrationTest.scala | 2 +- ...tConfirmationPoliciesIntegrationTest.scala | 2 +- ...entNoReassignmentDataIntegrationTest.scala | 2 +- ...ncurrentReassignmentsIntegrationTest.scala | 2 +- .../ReassignmentServiceIntegrationTest.scala | 2 +- ...imeoutCommandRejectedIntegrationTest.scala | 2 +- ...eassignmentSubmissionIntegrationTest.scala | 2 +- ...gnmentTargetTimestampIntegrationTest.scala | 2 +- ...ConfirmationObserversIntegrationTest.scala | 2 +- ...ConfirmationThresholdIntegrationTest.scala | 2 +- .../RepairServiceIntegrationTest.scala | 2 +- .../UpdateServiceIntegrationTest.scala | 2 +- ...ryptoNoPreDefinedKeysIntegrationTest.scala | 4 +- ...cerCatchUpPerformanceIntegrationTest.scala | 77 +- .../operations/ViewConsistencyTest.scala | 4 +- .../tests/pkgdars/PackageUsableMixin.scala | 4 +- .../LedgerPruningIntegrationTest.scala | 16 +- .../ReassignmentPruningIntegrationTest.scala | 2 +- ...rtReassignmentCounterIntegrationTest.scala | 4 +- .../ImportContractsIntegrationTest.scala | 24 +- .../repair/RepairServiceIntegrationTest.scala | 16 +- .../RollbackUnassignmentIntegrationTest.scala | 2 +- .../CryptoMetricsIntegrationTest.scala | 58 + ...alidAssignmentRequestIntegrationTest.scala | 2 +- ...InvalidReassignmentIdIntegrationTest.scala | 2 +- ...idUnassignmentRequestIntegrationTest.scala | 2 +- .../KeyManagementIntegrationTest.scala | 2 +- .../kms/KmsCryptoIntegrationTestBase.scala | 2 +- ...ptoWithPreDefinedKeysIntegrationTest.scala | 4 +- .../SessionSigningKeysIntegrationTest.scala | 11 +- ...nSigningKeysLifecycleIntegrationTest.scala | 10 +- ...sEncryptedCryptoPrivateStoreTestBase.scala | 4 +- .../aws/AwsKmsCryptoIntegrationTestBase.scala | 4 +- ...pEncryptedCryptoPrivateStoreTestBase.scala | 4 +- .../gcp/GcpKmsCryptoIntegrationTestBase.scala | 4 +- ...kEncryptedCryptoPrivateStoreTestBase.scala | 4 +- ...ckKmsDriverCryptoIntegrationTestBase.scala | 4 +- .../PackageRemovalIntegrationTest.scala | 2 +- .../PackageVettingIntegrationTest.scala | 83 +- .../DynamicOnboardingIntegrationTest.scala | 11 + .../sequencer/ToxiproxyIntegrationTest.scala | 11 +- ...cerWithTrafficControlApiTestPostgres.scala | 2 + .../bftordering/BftSequencerApiTest.scala | 2 + .../reference/ReferenceSequencerApiTest.scala | 2 + ...quencerWithTrafficControlApiTestBase.scala | 3 + ...cerWithTrafficControlApiTestPostgres.scala | 2 + ...artyToParticipantAuthIntegrationTest.scala | 12 +- ...tionMultiSynchronizerIntegrationTest.scala | 2 +- ...tSequencerConnectionsIntegrationTest.scala | 8 +- .../ParticipantTrafficEnforcementTest.scala | 213 + .../tests/traffic/TrafficControlTest.scala | 3 + ...nNetworkTopologyStateIntegrationTest.scala | 4 +- .../lsu/LsuCommandIdIntegrationTest.scala | 76 +- .../LsuEarlyHandshakeIntegrationTest.scala | 2 +- .../lsu/LsuEndToEndIntegrationTest.scala | 46 + ...suExhaustiveHandshakeIntegrationTest.scala | 206 + ...rectSequencerIdentityIntegrationTest.scala | 21 + .../lsu/LsuMetricsIntegrationTest.scala | 2 +- ...flinePartyReplicationIntegrationTest.scala | 23 +- ...suPurgeStoresAfterLsuIntegrationTest.scala | 21 +- .../lsu/LsuReassignmentsIntegrationTest.scala | 2 +- ...SuccessorSynchronizerIntegrationTest.scala | 55 +- ...encerContactSuccessorIntegrationTest.scala | 141 +- ...LsuSessionSigningKeysIntegrationTest.scala | 2 +- ...rafficTransferRestartIntegrationTest.scala | 12 +- ...olVersionReassignmentIntegrationTest.scala | 2 +- .../util/PartyToParticipantDeclarative.scala | 10 +- .../util/TestSubmissionService.scala | 19 +- .../canton/util/ReleaseUtils.scala | 4 + .../canton/protocol/v30/topology.proto | 2 +- .../canton/config/AuthServiceConfig.scala | 3 + .../canton/config/BaseCantonConfig.scala | 6 + .../config/SessionSigningKeysConfig.scala | 2 +- .../canton/config/StorageConfig.scala | 8 + .../canton/config/TestingConfigInternal.scala | 7 +- .../canton/crypto/CryptoApi.scala | 11 + .../canton/crypto/Encryption.scala | 72 +- .../digitalasset/canton/crypto/Signing.scala | 68 +- .../SyncCryptoApiParticipantProvider.scala | 10 +- .../crypto/SynchronizerCryptoPrivateApi.scala | 7 +- .../crypto/SynchronizerCryptoPureApi.scala | 8 +- .../crypto/provider/jce/JceCrypto.scala | 6 + .../provider/jce/JcePrivateCrypto.scala | 3 + .../crypto/provider/jce/JcePureCrypto.scala | 15 +- .../provider/kms/KmsPrivateCrypto.scala | 10 +- .../crypto/signer/SyncCryptoSigner.scala | 6 +- .../SyncCryptoSignerWithSessionKeys.scala | 17 +- .../crypto/verifier/SyncCryptoVerifier.scala | 16 + .../canton/metrics/CryptoMetrics.scala | 19 + .../canton/metrics/DecryptionMetrics.scala | 31 + .../SequencerConnectionPoolMetrics.scala | 28 +- .../canton/metrics/SigningMetrics.scala | 31 + .../canton/networking/grpc/GrpcError.scala | 28 +- .../canton/resource/DbMigrations.scala | 11 +- .../canton/resource/DbStorageMulti.scala | 2 +- .../canton/resource/DbStorageSingle.scala | 2 +- .../client/PeriodicAcknowledgements.scala | 13 +- .../sequencing/client/SequencerClient.scala | 32 +- .../client/SequencerClientFactory.scala | 12 +- .../client/SequencerClientSend.scala | 9 + .../GrpcInternalSequencerConnection.scala | 2 +- .../client/pool/GrpcSequencerConnection.scala | 18 +- .../GrpcUserSequencerConnectionStub.scala | 5 +- .../client/pool/SequencerConnectionPool.scala | 4 + .../pool/SequencerConnectionPoolImpl.scala | 16 +- .../pool/SequencerSubscriptionPoolImpl.scala | 3 +- .../GrpcSubscriptionErrorRetryPolicy.scala | 3 + .../sequencing/protocol/ClosedEnvelope.scala | 14 + .../protocol/ClosedUncompressedEnvelope.scala | 11 +- .../sequencing/protocol/SendAsyncError.scala | 3 + .../protocol/SequencerDeliverError.scala | 23 + .../sequencing/protocol/SignedContent.scala | 13 + .../digitalasset/canton/store/Purgeable.scala | 2 + .../com/digitalasset/canton/time/Clock.scala | 76 +- .../canton/time/SynchronizerTimeTracker.scala | 13 +- .../ConfiguredPhysicalSynchronizerId.scala | 1 + .../canton/topology/TopologyManager.scala | 32 +- .../TopologyManagerSigningKeyDetection.scala | 21 +- .../canton/topology/store/TopologyStore.scala | 38 +- .../topology/store/db/DbTopologyStore.scala | 4 +- .../transaction/TopologyMapping.scala | 10 +- .../digitalasset/canton/util/PekkoUtil.scala | 47 +- .../ShardedSequentialProcessingQueue.scala | 174 + .../canton/util/SimpleExecutionQueue.scala | 30 +- .../util/TracedPossiblyPrevalidated.scala | 31 + .../canton/h2/dev/V5_3__tea_initial.sha256 | 1 + .../canton/h2/dev/V5_3__tea_initial.sql | 75 + .../h2/stable/V5_2__dabft_partitioning.sha256 | 1 + .../h2/stable/V5_2__dabft_partitioning.sql | 8 + .../postgres/dev/V5_3__tea_initial.sha256 | 1 + .../canton/postgres/dev/V5_3__tea_initial.sql | 75 + ..._optimize_contract_key_lookup_index.sha256 | 1 + ..._1__optimize_contract_key_lookup_index.sql | 6 + .../stable/V5_2__dabft_partitioning.sha256 | 1 + .../stable/V5_2__dabft_partitioning.sql | 293 + ..._auto_vacuum_analyze_table_settings.sha256 | 2 +- ...ult_auto_vacuum_analyze_table_settings.sql | 62 - .../environment/CantonNodeBootstrap.scala | 5 +- .../grpc/GrpcTopologyManagerReadService.scala | 44 +- .../GrpcTopologyManagerWriteService.scala | 3 +- .../canton/util/GrpcStreamingUtils.scala | 247 +- .../canton/crypto/kms/KmsTest.scala | 2 + .../crypto/provider/jce/JceCryptoTest.scala | 2 + .../crypto/provider/kms/KmsCryptoTest.scala | 2 + .../canton/crypto/sync/SyncCryptoTest.scala | 2 + .../sync/SyncCryptoWithLongTermKeysTest.scala | 2 +- .../sync/SyncCryptoWithSessionKeysTest.scala | 4 +- .../SyncSchemeValidationsTest.scala | 2 + .../client/SequencerClientTest.scala | 4 +- .../client/TestSequencerClientSend.scala | 2 + .../pool/ConnectionPoolTestHelpers.scala | 11 +- .../SequencerConnectionPoolImplTest.scala | 57 +- .../digitalasset/canton/store/db/DbTest.scala | 2 +- ...pologyManagerSigningKeyDetectionTest.scala | 101 +- ...ogyStateForInitializationServiceTest.scala | 7 +- .../topology/store/TopologyStoreTest.scala | 7 +- .../store/TopologyStoreTestData.scala | 8 +- ...rafficPurchasedSubmissionHandlerTest.scala | 6 + .../canton/util/GrpcStreamingUtilsTest.scala | 371 +- .../canton/util/PekkoUtilTest.scala | 721 +- .../util/SimpleExecutionQueueTest.scala | 1 + .../ProtocolContinuityConformanceTest.scala | 17 +- .../tests/ledgerapi/ExcludedTests.scala | 11 +- .../JsonApiConformanceIntegrationTest.scala | 13 +- .../ledgerapi/LedgerApiConformanceTest.scala | 42 +- .../LedgerApiVariationsConformanceTest.scala | 12 +- .../digitalasset/daml/lf/engine/Engine.scala | 39 +- .../digitalasset/daml/lf/engine/Result.scala | 81 +- .../UnsupportedContractIdEngineSpec.scala | 128 + .../integration/tests/UpgradesMatrixIT.scala | 52 +- .../daml/lf/engine/UpgradesMatrix.scala | 397 +- .../daml/lf/engine/UpgradesMatrixUnit.scala | 87 +- .../daml/lf/validation/Typing.scala | 16 +- .../integration/tests/DamlScriptIT.scala | 4 +- .../BaseEnvironmentDefinition.scala | 11 +- .../integration/BaseIntegrationTest.scala | 6 +- .../CommunityIntegrationTest.scala | 2 +- .../canton/integration/ConfigTransforms.scala | 2 +- .../integration/EnvironmentDefinition.scala | 3 +- .../canton/integration/EnvironmentSetup.scala | 9 +- .../canton/integration/HasCycleUtils.scala | 202 + .../integration/HasTrailingNoneUtils.scala | 47 + .../canton/integration/TestEnvironment.scala | 4 +- .../bootstrap/NetworkBootstrapper.scala | 30 +- .../plugins/UseLedgerApiTestTool.scala | 437 ++ .../util/MultiSynchronizerFeatureFlag.scala | 14 +- .../crypto/kms/driver/api/KmsDriver.scala | 6 - .../kms/driver/api/KmsDriverFactory.scala | 10 - .../crypto/kms/driver/api/v1/KmsDriver.scala | 218 - .../driver/api/v1/KmsDriverException.scala | 17 - .../kms/driver/api/v1/KmsDriverFactory.scala | 15 - .../kms/driver/api/v1/KmsDriverHealth.scala | 30 - .../kms/driver/api/v1/KmsDriverSpecs.scala | 74 - .../canton/driver/api/DriverFactory.scala | 17 - .../canton/driver/api/v1/DriverFactory.scala | 59 - .../testing/v1/KmsDriverTestUtils.scala | 5 + .../src/main/resources/logback.xml | 23 + .../com/daml/ledger/api/testtool/Main.scala | 2 +- .../infrastructure/LedgerServices.scala | 40 +- .../infrastructure/LedgerTestSuite.scala | 3 + .../participant/ParticipantTestContext.scala | 20 + .../SingleParticipantTestContext.scala | 38 + .../TimeoutParticipantTestContext.scala | 27 + .../api/testtool/runner/AvailableTests.scala | 7 + .../ledger/api/testtool/suites/V2_2.scala | 51 +- .../ledger/api/testtool/suites/V2_3.scala | 12 +- .../ledger/api/testtool/suites/V2_dev.scala | 2 +- .../v2_2/ActiveContractsServiceIT.scala | 59 +- .../v2_2/CheckpointInTailingStreamsIT.scala | 5 +- ...mmandDeduplicationPeriodValidationIT.scala | 5 +- .../suites/v2_2/CommandServiceIT.scala | 13 +- .../v2_2/CommandSubmissionCompletionIT.scala | 5 +- .../v2_2/CommandSubmissionTestUtils.scala | 9 +- .../suites/v2_2/CompanionImplicits.scala | 106 - .../suites/v2_2/ContractServiceIT.scala | 5 +- .../testtool/suites/v2_2/DivulgenceIT.scala | 5 +- .../suites/v2_2/EventQueryServiceIT.scala | 5 +- .../suites/v2_2/ExplicitDisclosureIT.scala | 87 +- .../suites/v2_2/GetCompletionsIT.scala | 245 + .../v2_2/InteractiveSubmissionServiceIT.scala | 27 +- .../testtool/suites/v2_2/InterfaceIT.scala | 17 +- .../v2_2/InterfaceSubscriptionsIT.scala | 125 +- .../suites/v2_2/ParticipantPruningIT.scala | 17 +- .../v2_2/PartyManagementServiceIT.scala | 5 +- .../testtool/suites/v2_2/SemanticTests.scala | 31 +- .../v2_2/TransactionServiceArgumentsIT.scala | 5 +- .../TransactionServiceAuthorizationIT.scala | 5 +- .../TransactionServiceCorrectnessIT.scala | 5 +- .../v2_2/TransactionServiceExerciseIT.scala | 9 +- .../v2_2/TransactionServiceFiltersIT.scala | 13 +- .../v2_2/TransactionServiceOutputsIT.scala | 5 +- .../TransactionServiceStakeholdersIT.scala | 5 +- .../v2_2/TransactionServiceValidationIT.scala | 5 +- .../v2_2/TransactionServiceVisibilityIT.scala | 5 +- .../suites/v2_2/UpdateServiceQueryIT.scala | 5 +- .../suites/v2_2/UpdateServiceStreamsIT.scala | 9 +- .../testtool/suites/v2_2/UpgradingIT.scala | 111 +- .../api/testtool/suites/v2_2/VettingIT.scala | 146 +- .../v2_2/WronglyTypedContractIdIT.scala | 5 +- .../ContractKeysCommandDeduplicationIT.scala | 6 +- .../v2_3/ContractKeysCompanionImplicits.scala | 71 - .../v2_3/ContractKeysContractIdIT.scala | 16 +- .../ContractKeysExplicitDisclosureIT.scala | 9 +- .../testtool/suites/v2_3/ContractKeysIT.scala | 11 +- ...ContractKeysWronglyTypedContractIdIT.scala | 5 +- .../suites/v2_3/PrefetchContractKeysIT.scala | 9 +- .../suites/v2_dev/EventsDescendantsIT.scala | 15 +- .../ledger/ledger-api-core/src/.gitattributes | 3 - .../src/main/protobuf/daml/buf.yaml | 1 - .../daml/platform/v1/acs_continuation.proto | 23 - .../daml/platform/v1/acs_page_token.proto | 17 - .../protobuf/daml/platform/v1/index.proto | 22 - .../daml/platform/v1/page_tokens.proto | 49 - .../resources/metering-keys/community.json | 5 - .../generator/ErrorCategoryDocItem.scala | 14 - .../ErrorCategoryInventoryDocsGenerator.scala | 42 - .../error/generator/ErrorCodeDocItem.scala | 36 - .../ErrorCodeDocumentationGenerator.scala | 246 - .../ErrorCodeInventoryDocsGenerator.scala | 195 - .../error/generator/ErrorGroupDocItem.scala | 21 - .../ErrorCategoryInventoryDocsGenApp.scala | 32 - .../app/ErrorCodeInventoryDocsGenApp.scala | 30 - .../canton/error/generator/app/Main.scala | 93 - .../canton/ledger/api/ProxyCloseable.scala | 11 - .../ledger/api/SubmissionIdGenerator.scala | 20 - .../canton/ledger/api/TraceIdentifiers.scala | 65 - .../canton/ledger/api/ValidationLogger.scala | 23 - .../IdentityProviderAwareAuthService.scala | 145 - .../auth/IdentityProviderConfigLoader.scala | 17 - .../ledger/api/auth/RequiredClaims.scala | 66 - .../auth/UserBasedOngoingAuthorization.scala | 259 - .../auth/UserRightsChangeAsyncChecker.scala | 80 - .../interceptor/UserBasedClaimResolver.scala | 168 - ...ommandCompletionServiceAuthorization.scala | 51 - ...ommandInspectionServiceAuthorization.scala | 34 - .../CommandServiceAuthorization.scala | 101 - ...ommandSubmissionServiceAuthorization.scala | 51 - .../ContractServiceAuthorization.scala | 39 - .../EventQueryServiceAuthorization.scala | 47 - ...tyProviderConfigServiceAuthorization.scala | 50 - ...activeSubmissionServiceAuthorization.scala | 156 - ...ackageManagementServiceAuthorization.scala | 43 - .../PackageServiceAuthorization.scala | 41 - ...rticipantPruningServiceAuthorization.scala | 35 - .../PartyManagementServiceAuthorization.scala | 118 - .../services/StateServiceAuthorization.scala | 84 - .../services/TimeServiceAuthorization.scala | 32 - .../services/UpdateServiceAuthorization.scala | 67 - .../UserManagementServiceAuthorization.scala | 120 - .../canton/ledger/api/grpc/DropRepeated.scala | 47 - .../ledger/api/grpc/GrpcApiService.scala | 11 - .../ledger/api/grpc/GrpcHealthService.scala | 86 - .../canton/ledger/api/grpc/Logging.scala | 13 - .../StreamingServiceLifecycleManagement.scala | 113 - .../completion/CompletionStreamRequest.scala | 13 - .../SubmitReassignmentRequest.scala | 30 - .../command/submission/SubmitRequest.scala | 8 - .../event/GetEventsByContractIdRequest.scala | 12 - .../event/GetEventsByContractKeyRequest.scala | 15 - .../messages/state/AcsContinuationToken.scala | 118 - .../api/messages/state/AcsPageToken.scala | 86 - .../messages/update/GetLedgerEndRequest.scala | 6 - .../update/GetTransactionByIdRequest.scala | 12 - .../GetTransactionByOffsetRequest.scala | 12 - .../update/GetUpdateByIdRequest.scala | 12 - .../update/GetUpdateByOffsetRequest.scala | 12 - .../update/GetUpdatesPageRequest.scala | 19 - .../messages/update/GetUpdatesRequest.scala | 14 - .../messages/update/UpdatesPageToken.scala | 132 - .../canton/ledger/api/package.scala | 1044 --- .../ledger/api/refinements/ApiTypes.scala | 47 - .../services/CommandInspectionService.scala | 18 - .../ledger/api/services/CommandService.scala | 30 - .../services/CommandSubmissionService.scala | 16 - .../InteractiveSubmissionService.scala | 77 - .../ledger/api/util/DurationConversion.scala | 16 - .../ledger/api/util/LfEngineToApi.scala | 171 - .../ledger/api/util/PageTokenUtils.scala | 22 - .../canton/ledger/api/util/TimeProvider.scala | 31 - .../ledger/api/util/TimeProviderType.scala | 20 - .../ledger/api/util/TimestampConversion.scala | 76 - .../api/util/UpdateFormatHashUtils.scala | 60 - ...andInspectionServiceRequestValidator.scala | 26 - .../api/validation/CommandsValidator.scala | 495 -- .../CompletionServiceRequestValidator.scala | 49 - .../api/validation/CryptoValidator.scala | 79 - .../DeduplicationPeriodValidator.scala | 25 - .../EventQueryServiceRequestValidator.scala | 65 - .../api/validation/FieldValidator.scala | 340 - .../api/validation/FormatValidator.scala | 236 - ...GetPreferredPackagesRequestValidator.scala | 81 - .../ParticipantOffsetValidator.scala | 92 - .../ResourceAnnotationValidator.scala | 142 - .../SubmitAndWaitRequestValidator.scala | 76 - .../validation/SubmitRequestValidator.scala | 209 - .../UpdateServiceRequestValidator.scala | 194 - .../ValidateDisclosedContracts.scala | 137 - .../ValidateUpgradingPackageResolutions.scala | 110 - .../api/validation/ValidationErrors.scala | 56 - .../api/validation/ValueValidator.scala | 241 - .../canton/ledger/client/GrpcChannel.scala | 52 - .../canton/ledger/client/LedgerClient.scala | 220 - .../ledger/client/LedgerClientUtils.scala | 33 - .../ledger/client/LedgerSubscription.scala | 82 - .../client/ResilientLedgerSubscription.scala | 194 - .../CommandClientConfiguration.scala | 31 - .../LedgerClientChannelConfiguration.scala | 46 - .../LedgerClientConfiguration.scala | 20 - .../services/EventQueryServiceClient.scala | 60 - .../admin/IdentityProviderConfigClient.scala | 111 - .../admin/PackageManagementClient.scala | 88 - .../ParticipantPruningManagementClient.scala | 39 - .../admin/PartyManagementClient.scala | 124 - .../services/admin/UserManagementClient.scala | 229 - .../services/commands/CommandClient.scala | 51 - .../commands/CommandServiceClient.scala | 159 - .../client/services/pkg/PackageClient.scala | 60 - .../services/state/StateServiceClient.scala | 97 - .../updates/UpdateServiceClient.scala | 50 - .../services/version/VersionClient.scala | 46 - .../canton/ledger/error/CommonErrors.scala | 124 - .../canton/ledger/error/IndexErrors.scala | 79 - .../canton/ledger/error/JsonApiErrors.scala | 65 - .../canton/ledger/error/LedgerApiErrors.scala | 326 - .../ledger/error/PackageServiceErrors.scala | 298 - .../ledger/error/ParticipantErrorGroup.scala | 36 - .../error/groups/AdminServiceErrors.scala | 42 - .../error/groups/CommandExecutionErrors.scala | 1157 --- .../error/groups/ConsistencyErrors.scala | 199 - .../IdentityProviderConfigServiceErrors.scala | 143 - .../groups/PartyManagementServiceErrors.scala | 156 - .../groups/RequestValidationErrors.scala | 562 -- .../groups/UserManagementServiceErrors.scala | 177 - .../CachedIdentityProviderConfigStore.scala | 86 - .../CachedUserManagementStore.scala | 118 - .../InMemoryIdentityProviderConfigStore.scala | 134 - .../localstore/InMemoryPartyRecordStore.scala | 259 - .../InMemoryUserManagementStore.scala | 283 - .../canton/ledger/localstore/Ops.scala | 33 - ...ersistentIdentityProviderConfigStore.scala | 240 - .../PersistentPartyRecordStore.scala | 354 - .../PersistentUserManagementStore.scala | 484 -- .../api/IdentityProviderConfigStore.scala | 74 - .../api/IdentityProviderConfigUpdate.scala | 15 - .../ledger/localstore/api/PartyRecord.scala | 16 - .../localstore/api/PartyRecordStore.scala | 71 - .../localstore/api/UserManagementStore.scala | 118 - .../utils/LocalAnnotationsUtils.scala | 14 - .../ledger/metrics/CommandMetrics.scala | 158 - .../ledger/metrics/ContractStoreMetrics.scala | 56 - .../metrics/DatabaseMetricsFactory.scala | 14 - .../ledger/metrics/ExecutionMetrics.scala | 217 - .../IdentityProviderConfigStoreMetrics.scala | 26 - .../ledger/metrics/IndexDBMetrics.scala | 562 -- .../canton/ledger/metrics/IndexMetrics.scala | 149 - .../ledger/metrics/IndexerMetrics.scala | 341 - .../canton/ledger/metrics/LAPIMetrics.scala | 81 - .../metrics/PartyRecordStoreMetrics.scala | 22 - .../ledger/metrics/PruningMetrics.scala | 48 - .../ledger/metrics/ServicesMetrics.scala | 379 - .../metrics/UserManagementMetrics.scala | 27 - .../ledger/participant/state/AcsChange.scala | 143 - .../ledger/participant/state/ChangeId.scala | 34 - .../participant/state/CompletionInfo.scala | 81 - .../state/InternalIndexService.scala | 46 - .../state/PackageDescription.scala | 48 - .../state/PackageSyncService.scala | 83 - .../state/ParticipantPruningSyncService.scala | 58 - .../participant/state/PartySyncService.scala | 69 - .../participant/state/PruningResult.scala | 34 - .../participant/state/Reassignment.scala | 112 - .../state/ReassignmentCommand.scala | 28 - .../state/ReassignmentCommandsBatch.scala | 67 - .../state/RoutingSynchronizerState.scala | 59 - .../participant/state/SubmissionResult.scala | 51 - .../state/SubmissionSyncService.scala | 140 - .../participant/state/SubmitterInfo.scala | 119 - .../participant/state/SyncService.scala | 223 - .../participant/state/SynchronizerIndex.scala | 103 - .../participant/state/SynchronizerRank.scala | 27 - .../participant/state/TransactionMeta.scala | 62 - .../ledger/participant/state/Update.scala | 778 -- .../state/index/ContractStore.scala | 81 - .../index/IndexActiveContractsService.scala | 26 - .../state/index/IndexCompletionsService.scala | 22 - .../state/index/IndexEventQueryService.scala | 31 - .../IndexParticipantPruningService.scala | 28 - .../index/IndexPartyManagementService.scala | 30 - .../state/index/IndexService.scala | 17 - .../state/index/IndexUpdateService.scala | 47 - .../state/index/IndexerPartyDetails.scala | 18 - .../state/index/LedgerEndService.scala | 14 - .../index/MaximumLedgerTimeService.scala | 55 - .../state/metrics/TimedSyncService.scala | 335 - .../ledger/participant/state/package.scala | 22 - .../ledger/runner/common/OptConfigValue.scala | 93 - .../common/PureConfigReaderWriter.scala | 213 - .../metrics/LedgerApiServerMetrics.scala | 113 - .../canton/platform/DispatcherState.scala | 172 - .../canton/platform/InMemoryState.scala | 197 - .../platform/InternalUpdateFormat.scala | 23 - .../platform/LedgerApiServerInternals.scala | 64 - .../platform/PackagePreferenceBackend.scala | 605 -- .../canton/platform/ResourceCloseable.scala | 55 - .../platform/TemplatePartiesFilter.scala | 31 - .../platform/apiserver/ApiException.scala | 15 - .../platform/apiserver/ApiService.scala | 13 - .../platform/apiserver/ApiServiceOwner.scala | 262 - .../platform/apiserver/ApiServices.scala | 431 -- .../ExecutionSequencerFactoryOwner.scala | 26 - .../apiserver/GrpcConnectionLogger.scala | 24 - .../platform/apiserver/GrpcInterceptors.scala | 28 - .../platform/apiserver/GrpcServer.scala | 90 - .../platform/apiserver/GrpcServerOwner.scala | 76 - .../apiserver/InProcessGrpcName.scala | 10 - .../platform/apiserver/LedgerApiService.scala | 80 - .../platform/apiserver/LedgerFeatures.scala | 18 - .../platform/apiserver/SeedService.scala | 79 - .../apiserver/TimeServiceBackend.scala | 31 - .../apiserver/TimedIndexService.scala | 215 - .../TruncatedStatusInterceptor.scala | 30 - .../configuration/RateLimitingConfig.scala | 41 - .../apiserver/error/ErrorInterceptor.scala | 159 - .../execution/CommandExecutionResult.scala | 74 - .../apiserver/execution/CommandExecutor.scala | 138 - .../execution/CommandProgressTracker.scala | 267 - .../DynamicSynchronizerParameterGetter.scala | 26 - .../LedgerTimeAwareCommandExecutor.scala | 154 - .../execution/ResolveMaximumLedgerTime.scala | 70 - .../StoreBackedCommandInterpreter.scala | 691 -- .../TapsCommandExecutionFactory.scala | 398 - .../execution/TimedCommandExecutor.scala | 39 - .../TopologyAwareCommandExecutor.scala | 358 - .../apiserver/ratelimiting/MemoryCheck.scala | 136 - .../RateLimitingInterceptorFactory.scala | 42 - .../ApiCommandCompletionService.scala | 98 - .../services/ApiCommandService.scala | 231 - .../ApiCommandSubmissionService.scala | 226 - .../services/ApiContractService.scala | 112 - .../services/ApiEventQueryService.scala | 63 - .../ApiInteractiveSubmissionService.scala | 305 - .../services/ApiPackageService.scala | 182 - .../apiserver/services/ApiStateService.scala | 294 - .../apiserver/services/ApiTimeService.scala | 102 - .../apiserver/services/ApiUpdateService.scala | 254 - .../services/ApiVersionService.scala | 89 - .../services/RejectionGenerators.scala | 267 - .../apiserver/services/StreamMetrics.scala | 16 - .../admin/ApiCommandInspectionService.scala | 64 - .../ApiIdentityProviderConfigService.scala | 237 - .../admin/ApiPackageManagementService.scala | 187 - .../admin/ApiParticipantPruningService.scala | 225 - .../admin/ApiPartyManagementService.scala | 1274 --- .../admin/ApiUserManagementService.scala | 672 -- .../AuthenticatedUserContextResolver.scala | 49 - .../admin/IdentityProviderExists.scala | 21 - .../admin/PackageUpgradeValidator.scala | 215 - .../services/admin/PartyAllocation.scala | 41 - .../services/admin/PartyRecordsExist.scala | 25 - .../admin/PendingPartyAllocations.scala | 39 - .../apiserver/services/admin/Utils.scala | 79 - .../apiserver/services/admin/package.scala | 16 - .../CommandInspectionServiceImpl.scala | 55 - .../services/command/CommandServiceImpl.scala | 406 - .../CommandSubmissionServiceImpl.scala | 224 - .../interactive/CostEstimationHints.scala | 36 - .../InteractiveSubmissionServiceImpl.scala | 493 -- .../codec/EnrichedTransactionData.scala | 219 - .../codec/ExternalTransactionProcessor.scala | 336 - .../codec/PreparedTransactionCodec.scala | 96 - .../codec/PreparedTransactionDecoder.scala | 596 -- .../codec/PreparedTransactionEncoder.scala | 437 -- .../apiserver/services/logging/package.scala | 218 - .../platform/apiserver/services/package.scala | 77 - .../tracking/CancellableTimeoutSupport.scala | 59 - .../tracking/CompletionResponse.scala | 8 - .../services/tracking/StreamTracker.scala | 219 - .../services/tracking/SubmissionTracker.scala | 194 - .../apiserver/update/FieldNames.scala | 78 - .../IdentityProviderConfigUpdateMapper.scala | 99 - .../update/PartyRecordUpdateMapper.scala | 61 - .../apiserver/update/UpdateMapperBase.scala | 93 - .../apiserver/update/UpdatePath.scala | 29 - .../apiserver/update/UpdatePathError.scala | 29 - .../apiserver/update/UpdatePathsTrie.scala | 155 - .../update/UpdateRequestsPaths.scala | 77 - .../apiserver/update/UserUpdateMapper.scala | 94 - .../platform/apiserver/update/update.scala | 8 - .../config/CommandServiceConfig.scala | 39 - .../IdentityProviderManagementConfig.scala | 16 - .../platform/config/IndexServiceConfig.scala | 209 - .../InteractiveSubmissionServiceConfig.scala | 35 - .../config/InvalidConfigException.scala | 10 - .../config/PackageServiceConfig.scala | 24 - .../config/PartyManagementServiceConfig.scala | 28 - .../canton/platform/config/Readers.scala | 36 - .../canton/platform/config/ServerRole.scala | 30 - .../platform/config/StateServiceConfig.scala | 23 - .../TopologyAwarePackageSelectionConfig.scala | 21 - .../platform/config/UpdateServiceConfig.scala | 41 - .../config/UserManagementServiceConfig.scala | 44 - ...ctStoreBasedMaximumLedgerTimeService.scala | 56 - .../platform/index/InMemoryStateUpdater.scala | 650 -- .../platform/index/IndexServiceImpl.scala | 1334 ---- .../platform/index/IndexServiceOwner.scala | 262 - .../ParticipantIdNotFoundException.scala | 9 - .../platform/indexer/IndexerConfig.scala | 179 - .../platform/indexer/IndexerState.scala | 368 - .../canton/platform/indexer/JdbcIndexer.scala | 170 - .../indexer/TransactionTraversalUtils.scala | 103 - .../platform/indexer/ha/HaCoordinator.scala | 232 - .../indexer/ha/KillSwitchCaptor.scala | 71 - .../platform/indexer/ha/PollingChecker.scala | 93 - .../indexer/ha/PreemptableSequence.scala | 245 - .../canton/platform/indexer/package.scala | 17 - .../parallel/AchsMaintenancePipe.scala | 402 - .../indexer/parallel/AsyncSupport.scala | 50 - .../BatchingParallelIngestionPipe.scala | 51 - .../parallel/EventMetricsUpdater.scala | 40 - .../InitializeParallelIngestion.scala | 374 - .../parallel/ParallelIndexerFactory.scala | 272 - .../ParallelIndexerSubscription.scala | 1296 ---- .../indexer/parallel/PostPublishData.scala | 101 - .../canton/platform/package.scala | 97 - .../packages/DeduplicatingPackageLoader.scala | 63 - .../store/CompletionFromTransaction.scala | 162 - .../canton/platform/store/DbSupport.scala | 108 - .../canton/platform/store/DbType.scala | 40 - .../platform/store/EventSequentialId.scala | 10 - .../platform/store/FlywayMigrations.scala | 91 - .../store/LedgerApiContractStore.scala | 116 - .../platform/store/PruningOffsetService.scala | 89 - .../store/ScalaPbStreamingOptimizations.scala | 41 - .../platform/store/backend/Conversions.scala | 240 - .../canton/platform/store/backend/DbDto.scala | 749 -- .../store/backend/PersistentEventType.scala | 61 - .../platform/store/backend/RowDef.scala | 74 - .../store/backend/StorageBackend.scala | 886 --- .../store/backend/StorageBackendFactory.scala | 71 - .../store/backend/UpdateToDbDto.scala | 654 -- .../store/backend/VerifiedDataSource.scala | 73 - .../store/backend/common/CommonRowDefs.scala | 37 - .../common/CommonStorageBackendFactory.scala | 33 - .../CompletionStorageBackendTemplate.scala | 233 - .../backend/common/ComposableQuery.scala | 83 - .../ContractStorageBackendTemplate.scala | 134 - .../common/DataSourceStorageBackendImpl.scala | 20 - .../backend/common/EventReaderQueries.scala | 125 - .../common/EventStorageBackendTemplate.scala | 1492 ---- .../platform/store/backend/common/Field.scala | 126 - .../IngestionStorageBackendTemplate.scala | 77 - .../common/InitHookDataSourceProxy.scala | 73 - .../common/IntegrityStorageBackendImpl.scala | 642 -- .../backend/common/MismatchException.scala | 23 - .../common/ParameterStorageBackendImpl.scala | 432 -- .../common/PartyStorageBackendTemplate.scala | 85 - .../store/backend/common/QueryStrategy.scala | 204 - .../store/backend/common/Schema.scala | 441 -- .../backend/common/SimpleSqlExtensions.scala | 66 - .../StringInterningStorageBackendImpl.scala | 30 - .../platform/store/backend/common/Table.scala | 84 - .../common/UpdatePointwiseQueries.scala | 63 - .../common/UpdateStreamingQueries.scala | 424 - .../backend/h2/H2DBLockStorageBackend.scala | 24 - .../h2/H2DataSourceStorageBackend.scala | 58 - .../backend/h2/H2EventStorageBackend.scala | 42 - .../platform/store/backend/h2/H2Field.scala | 22 - .../store/backend/h2/H2QueryStrategy.scala | 13 - .../backend/h2/H2ResetStorageBackend.scala | 51 - .../platform/store/backend/h2/H2Schema.scala | 35 - .../backend/h2/H2StorageBackendFactory.scala | 82 - .../IdentityProviderStorageBackend.scala | 54 - .../IdentityProviderStorageBackendImpl.scala | 179 - .../ParticipantMetadataBackend.scala | 115 - .../PartyRecordStorageBackend.scala | 56 - .../PartyRecordStorageBackendImpl.scala | 159 - .../localstore/ResourceVersionOps.scala | 17 - .../UserManagementStorageBackend.scala | 88 - .../UserManagementStorageBackendImpl.scala | 387 - .../store/backend/postgresql/PGField.scala | 45 - .../store/backend/postgresql/PGSchema.scala | 40 - .../store/backend/postgresql/PGTable.scala | 62 - .../PostgresContractStorageBackend.scala | 144 - .../PostgresDBLockStorageBackend.scala | 52 - .../PostgresDataSourceStorageBackend.scala | 170 - .../PostgresEventStorageBackend.scala | 78 - .../postgresql/PostgresQueryStrategy.scala | 54 - .../PostgresResetStorageBackend.scala | 46 - .../PostgresStorageBackendFactory.scala | 59 - .../platform/store/cache/AchsStateCache.scala | 35 - .../store/cache/ContractKeyStateCache.scala | 41 - .../store/cache/ContractStateCaches.scala | 110 - .../store/cache/ContractsStateCache.scala | 32 - .../store/cache/InMemoryFanoutBuffer.scala | 360 - .../platform/store/cache/LedgerEndCache.scala | 30 - .../MutableCacheBackedContractStore.scala | 161 - .../store/cache/OffsetCheckpointCache.scala | 38 - ...lyForTestingTransactionInMemoryStore.scala | 41 - .../platform/store/cache/StateCache.scala | 230 - .../canton/platform/store/cache/package.scala | 23 - .../BufferedCommandCompletionsReader.scala | 121 - .../store/dao/BufferedStreamsReader.scala | 205 - .../dao/BufferedUpdatePointwiseReader.scala | 69 - .../store/dao/CommandCompletionsReader.scala | 83 - .../store/dao/DatabaseSelfServiceError.scala | 66 - .../platform/store/dao/DbDispatcher.scala | 304 - .../store/dao/EventProjectionProperties.scala | 156 - .../dao/HikariJdbcConnectionProvider.scala | 130 - .../store/dao/JdbcConnectionProvider.scala | 18 - .../platform/store/dao/JdbcLedgerDao.scala | 389 - .../canton/platform/store/dao/LedgerDao.scala | 132 - .../store/dao/PaginatingAsyncStream.scala | 326 - .../store/dao/PersistenceResponse.scala | 12 - .../platform/store/dao/QueryRange.scala | 9 - .../platform/store/dao/events/ACSReader.scala | 872 --- .../dao/events/BufferedUpdateReader.scala | 168 - .../store/dao/events/CompressionMetrics.scala | 24 - .../dao/events/CompressionStrategy.scala | 97 - .../store/dao/events/ContractLoader.scala | 396 - .../store/dao/events/ContractStateEvent.scala | 21 - .../store/dao/events/ContractsReader.scala | 170 - .../store/dao/events/EventIdsUtils.scala | 53 - .../store/dao/events/EventsRange.scala | 14 - .../store/dao/events/EventsReader.scala | 180 - .../store/dao/events/EventsTable.scala | 101 - .../store/dao/events/FilterUtils.scala | 27 - .../store/dao/events/IdPageSizing.scala | 90 - .../dao/events/InputContractPackages.scala | 63 - .../store/dao/events/LfEnricher.scala | 155 - .../store/dao/events/LfValueTranslation.scala | 565 -- .../store/dao/events/QueryValidRange.scala | 176 - .../TopologyTransactionPointwiseReader.scala | 88 - .../TopologyTransactionsStreamReader.scala | 176 - .../TransactionLogUpdatesConversions.scala | 672 -- ...sactionOrReassignmentPointwiseReader.scala | 214 - .../dao/events/UpdatePointwiseReader.scala | 91 - .../store/dao/events/UpdateReader.scala | 589 -- .../dao/events/UpdatesStreamReader.scala | 1017 --- .../platform/store/dao/events/Utils.scala | 14 - .../interfaces/LedgerDaoContractsReader.scala | 93 - .../interfaces/TransactionLogUpdate.scala | 204 - .../store/interning/MockStringInterning.scala | 176 - .../store/interning/RawStringInterning.scala | 77 - .../store/interning/StringInterning.scala | 132 - .../store/interning/StringInterningView.scala | 231 - .../store/serialization/Compression.scala | 53 - .../store/serialization/ValueSerializer.scala | 46 - .../store/utils/ConcurrencyLimiter.scala | 58 - .../platform/store/utils/EventOps.scala | 83 - .../platform/store/utils/Telemetry.scala | 34 - .../SerializableTraceContextConverter.scala | 44 - .../HashingSchemeVersionConverter.scala | 11 - .../src/test/resources/application.conf | 5 - .../src/test/resources/config/test.conf | 6 - .../src/test/resources/config/test2.conf | 6 - .../src/test/resources/config/testp.conf | 3 - .../src/test/resources/test-metering-key.json | 5 - .../canton/auth/AuthInterceptorSpec.scala | 103 - .../canton/auth/AuthServiceJWTCodecSpec.scala | 586 -- .../canton/auth/AuthorizerSpec.scala | 969 --- .../digitalasset/canton/auth/JwksSpec.scala | 178 - .../canton/auth/JwtVerifierLoaderSpec.scala | 201 - .../ErrorCodeDocumentationGeneratorSpec.scala | 184 - .../HelloServiceReferenceImplementation.scala | 30 - .../canton/ledger/api/ApiMocks.scala | 56 - .../ledger/api/IdentityProviderIdSpec.scala | 76 - .../canton/ledger/api/MockMessages.scala | 77 - .../ledger/api/TraceIdentifiersTest.scala | 46 - .../api/ValueConversionRoundTripTest.scala | 249 - .../ledger/api/auth/RequiredClaimsSpec.scala | 535 -- .../StreamAuthorizationComponentSpec.scala | 380 - .../auth/UserBasedAuthInterceptorSpec.scala | 119 - .../UserBasedOngoingAuthorizationSpec.scala | 224 - .../ApiServicesRequiredClaimSpec.scala | 755 -- .../ledger/api/grpc/DropRepeatedSpec.scala | 59 - .../ledger/api/grpc/GrpcClientResource.scala | 47 - .../api/grpc/GrpcHealthServiceSpec.scala | 337 - .../state/AcsContinuationTokenTest.scala | 129 - .../api/messages/state/AcsPageTokenTest.scala | 138 - .../api/util/TimestampConversionSpec.scala | 143 - ...ompletionServiceRequestValidatorTest.scala | 152 - ...ventQueryServiceRequestValidatorTest.scala | 146 - .../validation/IdentifierValidatorTest.scala | 48 - .../ResourceAnnotationValidationsSpec.scala | 131 - .../SubmitRequestValidatorTest.scala | 1143 --- .../UpdateServiceRequestValidatorTest.scala | 1000 --- .../ValidateDisclosedContractsTest.scala | 387 - ...idateUpgradingPackageResolutionsTest.scala | 129 - .../api/validation/ValidatorTestUtils.scala | 125 - .../ResilientLedgerSubscriptionTest.scala | 195 - ...achedIdentityProviderConfigStoreSpec.scala | 184 - .../CachedUserManagementStoreSpec.scala | 228 - .../ConcurrentChangeControlTests.scala | 146 - ...rrentPersistentPartyRecordStoreTests.scala | 53 - .../ConcurrentPersistentUserStoreTests.scala | 57 - .../localstore/DbDispatcherLeftOpsSpec.scala | 32 - .../IdentityProviderConfigStoreSpecBase.scala | 25 - .../IdentityProviderConfigStoreTests.scala | 421 - ...emoryIdentityProviderConfigStoreSpec.scala | 17 - .../InMemoryPartyRecordStoreSpec.scala | 24 - .../InMemoryUserManagementStoreSpec.scala | 25 - .../localstore/PartyRecordStoreSpecBase.scala | 29 - .../localstore/PartyRecordStoreTests.scala | 413 - ...entIdentityProviderConfigStoreSpecH2.scala | 12 - ...ntityProviderConfigStoreSpecPostgres.scala | 12 - ...tentIdentityProviderConfigStoreTests.scala | 23 - .../PersistentPartyRecordStoreSpecH2.scala | 12 - ...rsistentPartyRecordStoreSpecPostgres.scala | 13 - .../PersistentPartyRecordStoreTests.scala | 44 - .../localstore/PersistentStoreSpecBase.scala | 105 - .../PersistentUserStoreSpecH2.scala | 12 - .../PersistentUserStoreSpecPostgres.scala | 13 - .../localstore/PersistentUserStoreTests.scala | 45 - .../ledger/localstore/UserStoreSpecBase.scala | 28 - .../ledger/localstore/UserStoreTests.scala | 734 -- .../state/ReassignmentCommandsBatchTest.scala | 108 - .../runner/common/ArbitraryConfig.scala | 261 - .../common/PureConfigReaderWriterSpec.scala | 536 -- .../canton/platform/DispatcherStateSpec.scala | 173 - .../canton/platform/InMemoryStateSpec.scala | 294 - .../apiserver/FatContractInstanceHelper.scala | 48 - .../platform/apiserver/GrpcServerSpec.scala | 319 - .../platform/apiserver/SeedingSpec.scala | 20 - .../SimpleTimeServiceBackendSpec.scala | 44 - .../error/ErrorInterceptorSpec.scala | 380 - .../LedgerTimeAwareCommandExecutorSpec.scala | 323 - .../ResolveMaximumLedgerTimeSpec.scala | 142 - .../StoreBackedCommandInterpreterSpec.scala | 762 -- ...stDynamicSynchronizerParameterGetter.scala | 23 - .../ratelimiting/MemoryCheckSpec.scala | 70 - .../RateLimitingInterceptorChecksSpec.scala | 687 -- .../services/ApiCommandServiceSpec.scala | 202 - .../ApiCommandSubmissionServiceSpec.scala | 173 - .../services/DisclosedContractCreator.scala | 91 - .../ApiPackageManagementServiceSpec.scala | 289 - .../admin/ApiPartyManagementServiceSpec.scala | 1202 --- .../admin/ApiUserManagementServiceSpec.scala | 92 - .../services/admin/PackageTestUtils.scala | 92 - .../admin/PackageUpgradeValidatorSpec.scala | 135 - .../admin/PendingPartyAllocationsSpec.scala | 80 - .../command/CommandServiceImplSpec.scala | 333 - .../CommandSubmissionServiceImplSpec.scala | 359 - .../GeneratorsInteractiveSubmission.scala | 299 - .../PreparedTransactionCodecV1Spec.scala | 170 - .../CancellableTimeoutSupportSpec.scala | 70 - .../tracking/SubmissionTrackerSpec.scala | 491 -- .../apiserver/tls/OcspResponderFixture.scala | 118 - ...TlsCertificateRevocationCheckingSpec.scala | 115 - .../platform/apiserver/tls/TlsFixture.scala | 140 - .../platform/apiserver/tls/TlsSpec.scala | 127 - ...entityProviderConfigUpdateMapperSpec.scala | 177 - .../update/PartyRecordUpdateMapperSpec.scala | 208 - .../apiserver/update/UpdatePathSpec.scala | 32 - .../update/UpdatePathsTrieSpec.scala | 152 - .../update/UserUpdateMapperSpec.scala | 205 - .../validation/ErrorFactoriesSpec.scala | 736 -- .../component/AchsIndexComponentTest.scala | 932 --- .../component/IndexComponentLoadTest.scala | 743 -- .../component/IndexComponentTest.scala | 920 --- ...nUniqueContractKeyIndexComponentTest.scala | 359 - .../component/PersistenceSqlQueries.scala | 76 - .../SingleStepIncreasingRecordTime.scala | 13 - .../component/UpdatePagesComponentTest.scala | 239 - .../component/UpdateStreamComponentTest.scala | 242 - ...dateStreamReaderPruningComponentTest.scala | 86 - ...oreBasedMaximumLedgerTimeServiceSpec.scala | 302 - .../index/InMemoryStateUpdaterSpec.scala | 1431 ---- .../platform/index/IndexServiceImplSpec.scala | 1848 ----- .../platform/indexer/IndexerStateSpec.scala | 1607 ---- .../TransactionTraversalUtilsSpec.scala | 430 -- .../indexer/ha/HaCoordinatorSpec.scala | 961 --- .../indexer/ha/TestDBLockStorageBackend.scala | 230 - .../ha/TestDBLockStorageBackendSpec.scala | 31 - .../parallel/AchsMaintenancePipeSpec.scala | 693 -- .../BatchingParallelIngestionPipeSpec.scala | 256 - .../parallel/EventMetricsUpdaterSpec.scala | 204 - .../parallel/ParallelIndexerFactorySpec.scala | 312 - .../ParallelIndexerSubscriptionSpec.scala | 2732 ------- .../parallel/PostPublishDataSpec.scala | 219 - .../MultiSynchronizerIndexComponentTest.scala | 412 - .../DeduplicatingPackageLoaderSpec.scala | 155 - .../store/CompletionFromTransactionSpec.scala | 171 - .../platform/store/FlywayMigrationsSpec.scala | 102 - .../store/PruningOffsetServiceSpec.scala | 379 - .../platform/store/backend/DbDtoSpec.scala | 822 -- .../store/backend/PruningDtoQueries.scala | 64 - .../backend/ScalatestEqualityHelpers.scala | 60 - .../ScalatestEqualityHelpersSpec.scala | 34 - .../backend/StorageBackendProvider.scala | 143 - .../store/backend/StorageBackendSpec.scala | 132 - .../store/backend/StorageBackendSpecH2.scala | 11 - .../backend/StorageBackendSpecPostgres.scala | 662 -- .../store/backend/StorageBackendSuite.scala | 29 - .../backend/StorageBackendTestValues.scala | 663 -- .../StorageBackendTestsCompletions.scala | 434 -- .../StorageBackendTestsContracts.scala | 959 --- .../StorageBackendTestsConversions.scala | 38 - .../backend/StorageBackendTestsDBLock.scala | 184 - .../backend/StorageBackendTestsEvents.scala | 3371 -------- .../StorageBackendTestsIDPConfig.scala | 189 - .../StorageBackendTestsInitialization.scala | 58 - ...orageBackendTestsInitializeIngestion.scala | 713 -- .../StorageBackendTestsIntegrity.scala | 1119 --- .../StorageBackendTestsParameters.scala | 275 - ...orageBackendTestsParticipantMetadata.scala | 170 - .../backend/StorageBackendTestsParties.scala | 148 - .../StorageBackendTestsPartyRecord.scala | 194 - ...torageBackendTestsPartyToParticipant.scala | 384 - .../backend/StorageBackendTestsPruning.scala | 1065 --- .../StorageBackendTestsQueryValidRange.scala | 381 - .../backend/StorageBackendTestsReset.scala | 226 - .../StorageBackendTestsStringInterning.scala | 53 - .../StorageBackendTestsTimestamps.scala | 77 - .../StorageBackendTestsUserManagement.scala | 520 -- .../store/backend/UpdateToDbDtoSpec.scala | 2994 -------- .../backend/common/ComposableQuerySpec.scala | 153 - .../h2/H2DataSourceStorageBackendSpec.scala | 51 - .../store/cache/AchsStateCacheSpec.scala | 95 - .../store/cache/ContractStateCachesSpec.scala | 147 - .../cache/InMemoryFanoutBufferSpec.scala | 740 -- ...bleCacheBackedContractStoreRaceTests.scala | 529 -- .../MutableCacheBackedContractStoreSpec.scala | 480 -- .../platform/store/cache/StateCacheSpec.scala | 303 - .../store/dao/BufferedStreamsReaderSpec.scala | 640 -- .../BufferedUpdatePointwiseReaderSpec.scala | 143 - .../dao/EventProjectionPropertiesSpec.scala | 1199 --- .../HikariJdbcConnectionProviderSpec.scala | 157 - .../JdbcLedgerDaoActiveContractsSpec.scala | 666 -- .../store/dao/JdbcLedgerDaoBackend.scala | 224 - .../dao/JdbcLedgerDaoBackendH2Database.scala | 16 - .../dao/JdbcLedgerDaoBackendPostgresql.scala | 18 - .../dao/JdbcLedgerDaoCompletionsSpec.scala | 333 - .../dao/JdbcLedgerDaoContractsSpec.scala | 239 - .../dao/JdbcLedgerDaoExceptionSpec.scala | 64 - .../store/dao/JdbcLedgerDaoPartiesSpec.scala | 171 - .../store/dao/JdbcLedgerDaoSpecH2.scala | 21 - .../store/dao/JdbcLedgerDaoSpecPostgres.scala | 22 - .../store/dao/JdbcLedgerDaoSuite.scala | 863 --- .../dao/JdbcLedgerDaoTransactionsSpec.scala | 1042 --- .../JdbcLedgerDaoTransactionsWriterSpec.scala | 64 - .../store/dao/JdbcLedgerWriteDao.scala | 310 - .../platform/store/dao/LedgerWriteDao.scala | 71 - .../store/dao/PaginatingAsyncStreamSpec.scala | 604 -- .../store/dao/SequentialWriteDao.scala | 189 - .../store/dao/SequentialWriteDaoSpec.scala | 464 -- .../store/dao/events/ACSReaderSpec.scala | 337 - .../dao/events/GroupContiguousSpec.scala | 56 - .../events/InputContractPackagesTest.scala | 102 - .../store/dao/events/LfEnricherSpec.scala | 72 - ...PekkoStreamParallelBatchedLoaderSpec.scala | 219 - .../platform/store/dao/events/UtilsSpec.scala | 22 - .../platform/store/entries/LedgerEntry.scala | 28 - .../interning/RawStringInterningSpec.scala | 125 - .../interning/StringInterningDomainSpec.scala | 95 - .../interning/StringInterningViewSpec.scala | 522 -- .../DbConnectionAndDataSourceAroundEach.scala | 66 - .../store/migration/DbDataTypes.scala | 67 - .../migration/MigrationTestSupport.scala | 137 - .../PostgresAroundEachForMigrations.scala | 18 - .../RemovalOfJavaMigrationsPostgres.scala | 30 - .../testing/postgresql/PostgresAround.scala | 119 - .../postgresql/PostgresAroundAll.scala | 23 - .../postgresql/PostgresAroundEach.scala | 36 - .../postgresql/PostgresAroundSuite.scala | 31 - .../testing/postgresql/PostgresDatabase.scala | 23 - .../testing/postgresql/PostgresResource.scala | 19 - .../testing/postgresql/PostgresServer.scala | 12 - .../store/utils/ConcurrencyLimiterSpec.scala | 130 - .../ConcurrentBufferedProcessLogger.scala | 19 - .../canton/util/ContractValidatorTest.scala | 302 - .../digitalasset/canton/util/TestEngine.scala | 456 -- .../util/api/TimestampConversionTest.scala | 31 - .../main/resources/ledger-api/proto-data.yml | 30 +- .../http/json/v2/JsCommandService.scala | 55 + .../canton/http/json/v2/JsUpdateService.scala | 2 +- .../resources/json-api-docs/asyncapi.yaml | 53 +- .../test/resources/json-api-docs/openapi.yaml | 94 +- .../canton/openapi/OpenapiTypesTest.scala | 6 + .../crypto/digests/canton/Blake2bDigest.java | 456 -- .../scalacheck/shrink/DerivedShrink.scala | 63 - .../magnolify/scalacheck/shrink/package.scala | 80 - .../shrink/ShrinkDerivationTest.scala | 119 - .../scala/org/scalatest/AssertionsUtil.scala | 19 - .../org/scalatest/AssertionsUtilMacros.scala | 71 - .../org/scalatest/AssertionsUtilTest.scala | 36 - canton/community/lib/slick/LICENSE.txt | 25 - .../scala/slick/jdbc/canton/StaticQuery.scala | 133 - .../canton/AllowTraverseSingleContainer.scala | 8 - .../canton/DoNotDiscardLikeFuture.scala | 11 - ...oNotReturnFromSynchronizedLikeFuture.scala | 11 - .../canton/DoNotTraverseLikeFuture.scala | 11 - .../canton/FutureTransformer.scala | 14 - .../canton/GrpcServiceInvocationMethod.scala | 11 - .../canton/AcsCommitmentBenchmark.scala | 11 +- .../kms/mock/v1/MockKmsDriverFactory.scala | 26 + .../canton/participant/ParticipantNode.scala | 41 +- .../ParticipantNodeParameters.scala | 4 +- .../admin/data/LateLsuRequest.scala | 4 +- .../admin/data/ManualLsuRequest.scala | 4 +- .../GrpcParticipantInspectionService.scala | 8 +- .../GrpcSynchronizerConnectivityService.scala | 6 +- .../inspection/SyncStateInspection.scala | 17 +- .../PartyReplicationTopologyWorkflow.scala | 3 + .../admin/repair/ChangeAssignation.scala | 1 + .../admin/repair/RepairService.scala | 5 +- .../RepairServiceContractsImporter.scala | 4 +- .../config/ParticipantInitConfig.scala | 3 - .../config/ParticipantNodeConfig.scala | 65 +- .../participant/event/AcsChangeListener.scala | 11 +- ...csCommitmentPublicationPostProcessor.scala | 10 +- .../ledger/api/LedgerApiIndexer.scala | 49 +- .../ledger/api/LedgerApiServer.scala | 5 + .../ledger/api/LedgerApiStore.scala | 43 - .../metrics/ParticipantMetrics.scala | 21 +- .../protocol/AbstractMessageProcessor.scala | 2 + .../protocol/ProtocolProcessor.scala | 24 +- .../protocol/TransactionProcessor.scala | 19 + .../reassignment/ReassignmentValidation.scala | 2 +- .../UnassignmentValidationResult.scala | 1 + .../pruning/AcsCommitmentProcessor.scala | 110 +- .../replica/ParticipantReplicaManager.scala | 16 + ...ticipantPurgeStoresAfterLsuScheduler.scala | 49 +- .../PostLsuPurgeableStoresComputation.scala | 70 + .../store/AcsCommitmentStore.scala | 12 +- .../store/SubmissionTrackerStore.scala | 3 + .../SynchronizerConnectivityStatusStore.scala | 2 +- .../store/db/DbAcsCommitmentStore.scala | 70 +- .../store/db/DbSubmissionTrackerStore.scala | 3 + .../DbSynchronizerConnectionConfigStore.scala | 20 +- ...bSynchronizerConnectivityStatusStore.scala | 2 +- .../memory/InMemoryAcsCommitmentStore.scala | 7 +- .../InMemorySubmissionTrackerStore.scala | 2 + .../memory/InMemorySyncPersistentState.scala | 2 +- ...orySynchronizerConnectionConfigStore.scala | 6 +- ...ySynchronizerConnectivityStatusStore.scala | 2 +- .../participant/sync/CantonSyncService.scala | 10 +- .../sync/ConnectedSynchronizer.scala | 8 + .../sync/LogicalSynchronizerUpgrade.scala | 4 +- .../participant/sync/SyncServiceError.scala | 6 +- .../sync/SynchronizerConnectionsManager.scala | 194 +- .../sync/SynchronizerMigration.scala | 4 +- .../SynchronizerConnectionConfig.scala | 16 +- .../synchronizer/SynchronizerRegistry.scala | 7 +- .../SynchronizerRegistryHelpers.scala | 13 +- .../grpc/GrpcSynchronizerRegistry.scala | 78 +- .../participant/topology/PackageOps.scala | 5 +- .../ParticipantTopologyDispatcher.scala | 1 + .../ParticipantTopologyValidation.scala | 10 +- .../participant/topology/PartyOps.scala | 7 +- ...SequencerConnectionSuccessorListener.scala | 6 +- .../topology/TopologyComponentFactory.scala | 13 +- .../participant/topology/TopologyLookup.scala | 30 +- .../traffic/TrafficCostEstimator.scala | 2 +- .../participant/admin/PackageOpsTest.scala | 3 + ...artyOnboardingClearanceSchedulerTest.scala | 1 - ...PartyReplicationTopologyWorkflowTest.scala | 29 +- .../protocol/ProtocolProcessorTest.scala | 3 + .../protocol/SubmissionTrackerTest.scala | 8 +- .../protocol/ViewMessageDecrypterTest.scala | 2 + ...pantPurgeStoresAfterLsuSchedulerTest.scala | 59 +- ...ynchronizerConnectionConfigStoreTest.scala | 29 +- ...chronizerConnectivityStatusStoreTest.scala | 8 +- .../store/db/DbContractStoreTest.scala | 2 +- .../SubmissionTrackerStoreTestInMemory.scala | 7 +- .../QueueBasedSynchronizerOutboxTest.scala | 1 + .../StoreBasedSynchronizerOutboxTest.scala | 1 + .../topology/TopologyLookupTest.scala | 2 +- ...sequencer_bft_administration_service.proto | 7 + .../v30/bft_ordering_service.proto | 1 + .../bft_ordering_sequencing_parameters.proto | 11 + .../block/BlockSequencerStateManager.scala | 264 +- .../block/update/BlockChunkProcessor.scala | 171 +- .../block/update/BlockUpdate.scala | 6 +- .../block/update/BlockUpdateGenerator.scala | 133 +- .../update/InFlightAggregationHandler.scala | 2 +- .../SequencedSubmissionsValidator.scala | 6 +- .../update/SubmissionRequestValidator.scala | 153 +- ...firmationRequestAndResponseProcessor.scala | 53 +- .../synchronizer/mediator/Mediator.scala | 14 +- .../mediator/MediatorConfig.scala | 3 - .../synchronizer/mediator/MediatorNode.scala | 89 +- .../mediator/MediatorRuntimeFactory.scala | 4 +- .../mediator/ProcessingQueue.scala | 77 - .../synchronizer/mediator/VerdictSender.scala | 139 +- .../GrpcMediatorInspectionService.scala | 22 +- .../metrics/BftOrderingMetrics.scala | 60 +- .../metrics/SynchronizerMetrics.scala | 31 +- .../sequencer/BaseSequencer.scala | 41 +- .../sequencer/DatabaseSequencer.scala | 1 + .../DirectSequencerConnectionPool.scala | 3 + .../LocalSequencerStateEventSignaller.scala | 19 +- .../synchronizer/sequencer/Sequencer.scala | 25 +- .../sequencer/SequencerNode.scala | 20 +- .../sequencer/block/BlockSequencer.scala | 211 +- .../block/BlockSequencerFactory.scala | 21 +- .../block/BlockSequencerThroughputCap.scala | 39 +- .../block/DriverBlockSequencerFactory.scala | 17 +- .../BftOrderingSequencerAdminService.scala | 2 + .../admin/SequencerBftAdminData.scala | 25 +- .../canton/sequencing/BftBlockOrderer.scala | 43 +- .../sequencing/BftSequencerFactory.scala | 17 +- .../p2p/grpc/P2PGrpcConnectionManager.scala | 133 +- .../p2p/grpc/P2PGrpcConnectionState.scala | 163 +- .../p2p/grpc/P2PGrpcStreamingReceiver.scala | 4 + .../core/BftBlockOrdererConfig.scala | 37 + .../BftOrderingModuleSystemInitializer.scala | 30 +- .../availability/AvailabilityModule.scala | 97 +- .../BatchDisseminationNodeQuotaTracker.scala | 6 +- .../OutputFetchProtocolState.scala | 7 +- .../availability/data/AvailabilityStore.scala | 10 +- .../data/db/DbAvailabilityStore.scala | 130 +- .../memory/InMemoryAvailabilityStore.scala | 16 +- .../consensus/iss/IssConsensusModule.scala | 59 +- .../consensus/iss/IssSegmentModule.scala | 23 +- .../consensus/iss/TimeoutManager.scala | 25 +- .../consensus/iss/data/EpochStoreReader.scala | 17 +- .../consensus/iss/data/db/DbEpochStore.scala | 47 +- .../iss/data/memory/InMemoryEpochStore.scala | 25 +- .../statetransfer/StateTransferBehavior.scala | 65 +- .../statetransfer/StateTransferManager.scala | 26 +- .../StateTransferMessageSender.scala | 3 +- .../core/modules/output/OutputModule.scala | 446 +- .../modules/output/OutputModuleMetrics.scala | 8 +- .../output/PekkoBlockSubscription.scala | 179 +- .../data/db/DbOutputMetadataStore.scala | 28 +- .../memory/InMemoryOutputMetadataStore.scala | 3 + .../BlacklistLeaderSelectionInitializer.scala | 4 +- .../BlacklistLeaderSelectionPolicy.scala | 10 +- .../BlacklistLeaderSelectionPolicyState.scala | 20 +- .../modules/p2p/P2PNetworkOutModule.scala | 42 +- .../modules/pruning/PartitionManager.scala | 475 ++ .../core/modules/pruning/PruningModule.scala | 88 +- .../framework/BlockSubscription.scala | 10 +- .../block/bftordering/framework/Module.scala | 43 +- .../framework/data/availability/BatchId.scala | 2 + .../data/ordering/OrderedBlockForOutput.scala | 21 +- .../data/ordering/OrderingMode.scala | 20 + ...BlacklistLeaderSelectionPolicyConfig.scala | 89 + .../framework/data/topology/Membership.scala | 1 + .../framework/modules/Availability.scala | 23 +- .../framework/modules/Consensus.scala | 2 + .../framework/modules/Output.scala | 9 + .../framework/modules/P2PNetworkOut.scala | 2 +- .../framework/modules/Pruning.scala | 6 +- .../bftordering/utils/FairBoundedQueue.scala | 25 +- .../config/SequencerNodeParameterConfig.scala | 9 + .../config/SequencerNodeParameters.scala | 9 + .../sequencer/time/LsuSequencingBounds.scala | 40 +- .../service/DirectSequencerSubscription.scala | 2 +- .../GrpcSequencerAdministrationService.scala | 10 +- .../service/GrpcSequencerService.scala | 23 +- .../SequencerRateLimitManagerImpl.scala | 35 +- ...BftOrderingExplorativeSimulationTest.scala | 21 +- .../BlockSequencerStateAsyncWriterTest.scala | 2 + .../update/BlockChunkProcessorTest.scala | 16 +- .../update/BlockUpdateGeneratorImplTest.scala | 72 +- ...ationRequestAndResponseProcessorTest.scala | 8 +- .../mediator/DefaultVerdictSenderTest.scala | 94 +- .../MediatorEventDeduplicatorTest.scala | 2 +- .../mediator/ProcessingQueueTest.scala | 181 - ...ShardedSequentialProcessingQueueTest.scala | 317 + .../mediator/TestVerdictSender.scala | 16 +- .../sequencer/BaseSequencerTest.scala | 1 + .../sequencer/SequencerApiTest.scala | 67 +- .../sequencer/block/BlockSequencerTest.scala | 33 +- ...BftOrderingSequencerAdminServiceTest.scala | 8 +- .../PekkoBlockSubscriptionTest.scala | 77 +- .../CantonOrderingTopologyProviderTest.scala | 4 + .../core/modules/PruningModuleTest.scala | 30 +- .../AvailabilityModuleDisseminationTest.scala | 53 +- .../AvailabilityModuleOutputFetchTest.scala | 82 +- .../AvailabilityModuleTestUtils.scala | 11 +- ...tchDisseminationNodeQuotaTrackerTest.scala | 4 +- .../data/AvailabilityStoreTest.scala | 77 +- .../availability/data/model/Command.scala | 5 +- .../availability/data/model/Generator.scala | 22 +- .../iss/IssConsensusModuleTest.scala | 291 +- .../iss/StateTransferBehaviorTest.scala | 4 +- .../consensus/iss/TimeoutManagerTest.scala | 1 + .../consensus/iss/data/EpochStoreTest.scala | 62 +- .../StateTransferManagerTest.scala | 7 +- .../PbftMessageValidatorImplTest.scala | 8 +- ...ksRecoveredFromConsensusMessagesTest.scala | 204 + .../modules/output/OutputModuleTest.scala | 368 +- .../SimulationOutputMetadataStore.scala | 6 +- ...cklistLeaderSelectionPolicyStateTest.scala | 138 +- .../BlacklistLeaderSelectionPolicyTest.scala | 2 + .../modules/p2p/P2PNetworkOutModuleTest.scala | 8 +- .../pruning/PartitionManagerTest.scala | 279 + .../pruning/PartitionMigrationTest.scala | 123 + .../framework/EmptyBlockSubscription.scala | 17 +- .../SimulationBlockSubscription.scala | 9 +- .../simulation/FutureSimulator.scala | 5 +- .../framework/simulation/Simulation.scala | 3 +- .../future/FutureSimulatorAllocator.scala | 7 +- .../BftOrderingSimulationTest.scala | 8 +- .../SequencerSnapshotOnboardingManager.scala | 16 +- ...imeAdvancingTopologySubscriberV1Test.scala | 5 + ...imeAdvancingTopologySubscriberV2Test.scala | 11 + .../sequencer/SequencerStateManagerTest.scala | 33 +- .../traffic/RateLimitManagerTesting.scala | 3 + .../SequencerRateLimitManagerImplTest.scala | 2 +- .../canton/MockedNodeParameters.scala | 2 +- .../provider/symbolic/SymbolicCrypto.scala | 2 + .../symbolic/SymbolicPrivateCrypto.scala | 6 + .../symbolic/SymbolicPureCrypto.scala | 12 +- .../canton/metrics/CommonMockMetrics.scala | 17 + .../topology/TestingIdentityFactory.scala | 5 +- .../traffic-enforcement/api/protobuf/buf.yaml | 5 + .../canton/tea/scalapb/package.proto | 14 + .../canton/tea/v1/traffic_service.proto | 62 + .../canton/tea/TrafficEnforcementApp.scala | 86 + .../tea/TrafficEnforcementService.scala | 122 + .../tea/TrafficEnforcementServiceGrpc.scala | 87 + .../tea/projection/CloseableProjection.scala | 65 + .../tea/projection/ProjectionEvent.scala | 180 + .../canton/tea/projection/TeaProjection.scala | 133 + .../tea/projection/TeaTrafficStore.scala | 69 + .../tea/projection/db/TeaDbProjection.scala | 103 + .../tea/projection/db/TeaDbTrafficStore.scala | 205 + .../memory/TeaMemoryProjection.scala | 51 + .../memory/TeaMemoryTrafficStore.scala | 123 + .../tea/projection/TeaProjectionTest.scala | 257 + .../tea/projection/TeaTrafficStoreTest.scala | 221 + .../projection/db/DbTeaProjectionTest.scala | 81 + .../projection/db/DbTeaTrafficStoreTest.scala | 52 + .../memory/MemoryTeaProjectionTest.scala | 33 + .../memory/MemoryTeaTrafficStoreTest.scala | 16 + .../src/main/daml/examples/Conformance.daml | 7 + .../transcode/daml_lf/LfSchemaProcessor.scala | 13 + .../daml_lf/SchemaProcessorSpecDefault.scala | 27 +- .../transcode/daml_lf/package.scala | 8 - .../transcode/schema/CodecVisitor.scala | 7 + .../digitalasset/transcode/schema/Debug.scala | 11 +- .../transcode/schema/Descriptor.scala | 6 + .../schema/DescriptorSchemaProcessor.scala | 1 + .../transcode/schema/DescriptorVisitor.scala | 1 + .../transcode/schema/Pickler.scala | 1 + .../transcode/schema/SchemaVisitor.scala | 15 + .../conformance/data/Primitives.scala | 4 + .../conformance/generator/DamlRoundtrip.scala | 9 + .../transcode/conformance/package.scala | 5 + .../propertygenerators/SchemaGenerator.scala | 1 + ...AwarePackageSelectionIntegrationTest.scala | 2 +- canton/release-notes/3.5.1.md | 899 +++ canton/release-notes/3.5.2.md | 69 + canton/release-notes/3.5.3.md | 30 + canton/release-notes/3.5.4.md | 51 + canton/release-notes/3.5.5.md | 68 + canton/release-notes/3.5.6.md | 77 + canton/shared_dependencies.json | 4 +- .../localnet/conf/console/app-synchronizer.sc | 53 +- cluster/compose/localnet/env/splice.env | 2 +- .../TestNet/approved-sv-id-values.yaml | 8 +- cluster/configs/shared/base.yaml | 37 +- .../shared/rate-limits/token-registry.yaml | 121 + .../configs/shared/rate-limits/unlimited.yaml | 16 +- .../configs/shared/rate-limits/v0-acs.yaml | 9 +- ...cratch-default-synchronizer-migration.yaml | 1 + cluster/configs/shared/scratchnet-sv.yaml | 1 + cluster/configs/shared/scratchnet.yaml | 10 +- cluster/deployment/mock/config.yaml | 58 +- cluster/deployment/package-lock.json | 6 + .../scratchneta/config.resolved.yaml | 192 +- .../scratchnetb/config.resolved.yaml | 192 +- .../scratchnetc/config.resolved.yaml | 192 +- .../scratchnetd/config.resolved.yaml | 192 +- .../scratchnete/config.resolved.yaml | 192 +- cluster/expected/canton-network/expected.json | 4360 +---------- cluster/expected/cluster/expected.json | 41 +- cluster/expected/deployment/expected.json | 38 +- cluster/expected/gcp/expected.json | 178 +- cluster/expected/infra/expected.json | 731 +- .../expected/multi-validator/expected.json | 276 +- cluster/expected/observability/expected.json | 62 +- cluster/expected/operator/expected.json | 18 +- cluster/expected/splitwell/expected.json | 207 +- cluster/expected/sv-canton/expected.json | 216 +- cluster/expected/sv-runbook/expected.json | 1231 ++- cluster/expected/sv/expected.json | 6843 ++++++++++++++++- .../expected/validator-runbook/expected.json | 296 +- cluster/expected/validator1/expected.json | 264 +- .../templates/migration_sequencer.yaml | 2 + .../templates/rateLimit.yaml | 18 +- .../templates/required.yaml | 4 - .../templates/splitwell.yaml | 6 +- .../templates/sv.yaml | 11 + .../templates/validator.yaml | 8 + .../splice-cometbft/templates/deployment.yaml | 65 +- .../templates/partials/_config-toml.tpl | 2 +- .../tests/cometbft_deployment_test.yaml | 2 + .../tests/cometbft_pvc_test.yaml | 2 + .../tests/cometbft_watchdog_test.yaml | 98 + .../helm/splice-cometbft/values-template.yaml | 29 +- .../helm/splice-cometbft/values.schema.json | 82 +- .../templates/mediator.yaml | 2 +- .../templates/sequencer.yaml | 2 +- .../tests/mediator_test.yaml | 15 + .../tests/sequencer_test.yaml | 15 + .../splice-global-domain/values-template.yaml | 2 + .../helm/splice-info/scripts/get-status.sh | 626 +- .../templates/virtualService.yaml | 5 + .../helm/splice-postgres/Chart-template.yaml | 8 +- .../helm/splice-postgres/templates/NOTES.txt | 15 +- .../helm/splice-postgres/values-template.yaml | 6 + cluster/helm/splice-scan/templates/scan.yaml | 55 +- cluster/helm/splice-scan/tests/scan_test.yaml | 28 +- .../templates/virtualService.yaml | 1 + .../splice-validator/templates/validator.yaml | 4 +- .../tests/validator_test.yaml | 1 + .../tests/wallet-user-ids_test.yaml | 69 + .../canton-sequencer/additional-config.conf | 10 +- cluster/images/cometbft-watchdog/Dockerfile | 16 + cluster/images/cometbft-watchdog/local.mk | 9 + .../cometbft-watchdog/restart-watchdog.py | 314 + cluster/images/cometbft/Dockerfile | 4 +- cluster/images/local.mk | 9 +- cluster/images/scan-app/app.conf | 15 +- cluster/images/splice-app/Dockerfile | 2 +- .../images/splice-test-cometbft/Dockerfile | 4 +- .../splice-test-docker-runner/Dockerfile | 4 +- .../images/splice-test-runner-hook/Dockerfile | 4 +- cluster/images/sv-app/app.conf | 16 +- .../canton-network/bigquery-cloudsql.sh | 4 +- .../pulumi/canton-network/src/chaosMesh.ts | 44 +- .../canton-network/src/clusterVersion.ts | 4 + cluster/pulumi/canton-network/src/dso.ts | 159 +- cluster/pulumi/canton-network/src/index.ts | 20 +- .../canton-network/src/installCluster.ts | 122 +- .../canton-network/tsconfig.eslint.json | 1 + cluster/pulumi/circleci/src/index.ts | 8 +- cluster/pulumi/circleci/tsconfig.eslint.json | 1 + cluster/pulumi/cluster/src/config.ts | 4 + cluster/pulumi/cluster/src/fluentBit.ts | 9 +- cluster/pulumi/cluster/src/nodePools.ts | 309 +- cluster/pulumi/cluster/tsconfig.eslint.json | 1 + .../common-sv/src/approvedIdentities.ts | 22 +- cluster/pulumi/common-sv/src/bigQuery.ts | 790 +- cluster/pulumi/common-sv/src/bulkStorage.ts | 75 +- cluster/pulumi/common-sv/src/config.ts | 23 +- .../pulumi/common-sv/src/hourly_append.sql | 99 + .../src/physicalSynchronizerConfig.ts | 12 + .../pulumi/common-sv/src/singleSvConfig.ts | 42 +- cluster/pulumi/common-sv/src/sv.ts | 511 +- cluster/pulumi/common-sv/src/svApp.ts | 4 +- .../common-sv/src/synchronizer/cometbft.ts | 39 +- cluster/pulumi/common-sv/tsconfig.eslint.json | 1 + cluster/pulumi/common-validator/src/config.ts | 4 + .../common-validator/src/participant.ts | 22 +- .../common-validator/tsconfig.eslint.json | 1 + cluster/pulumi/common/package.json | 4 +- cluster/pulumi/common/src/auth0/auth0.ts | 4 +- cluster/pulumi/common/src/config/cloudSql.ts | 26 - .../pulumi/common/src/config/configSchema.ts | 18 +- cluster/pulumi/common/src/config/database.ts | 52 + .../src/config/hyperdiskSupportConfig.ts | 20 - cluster/pulumi/common/src/config/index.ts | 2 +- .../common/src/config/migrationSchema.ts | 3 + .../pulumi/common/src/config/scanEndpoints.ts | 57 + cluster/pulumi/common/src/dockerConfig.ts | 9 +- .../pulumi/common/src/dump-config-common.ts | 73 +- cluster/pulumi/common/src/helm.ts | 153 +- cluster/pulumi/common/src/operator/config.ts | 19 +- .../pulumi/common/src/operator/flux-source.ts | 2 +- cluster/pulumi/common/src/operator/stack.ts | 4 +- cluster/pulumi/common/src/postgres.ts | 599 +- .../src/ratelimit/envoyRateLimiter.test.ts | 342 + .../common/src/ratelimit/envoyRateLimiter.ts | 328 +- cluster/pulumi/common/src/ratelimit/index.ts | 1 + .../pulumi/common/src/ratelimit/rateLimit.ts | 34 + .../common/src/ratelimit/rateLimitHeaders.ts | 30 + .../src/ratelimit/rateLimitSchema.test.ts | 128 + .../common/src/ratelimit/rateLimitSchema.ts | 15 +- cluster/pulumi/common/src/serviceAccount.ts | 3 +- cluster/pulumi/common/src/stackReferences.ts | 20 +- .../pulumi/common/src/storage/storageClass.ts | 15 +- cluster/pulumi/common/tsconfig.eslint.json | 1 + .../pulumi/deployment/tsconfig.eslint.json | 1 + cluster/pulumi/eslint.config.mjs | 2 +- cluster/pulumi/gcp/tsconfig.eslint.json | 1 + cluster/pulumi/gha/src/controller.ts | 4 +- cluster/pulumi/gha/src/dockerMirror.ts | 4 +- cluster/pulumi/gha/src/github.ts | 22 - cluster/pulumi/gha/src/runners.test.ts | 2 +- cluster/pulumi/gha/src/runners.ts | 18 +- cluster/pulumi/gha/tsconfig.eslint.json | 1 + cluster/pulumi/infra/src/cloudArmor.ts | 24 +- cluster/pulumi/infra/src/config.ts | 58 +- cluster/pulumi/infra/src/index.ts | 7 +- cluster/pulumi/infra/src/istio.ts | 360 +- cluster/pulumi/infra/src/maintenance.ts | 9 +- cluster/pulumi/infra/src/network.ts | 19 +- cluster/pulumi/infra/src/reloader.ts | 4 +- .../pulumi/infra/src/whitelisting/gateway.ts | 37 + .../pulumi/infra/src/whitelisting/index.ts | 30 + .../pulumi/infra/src/whitelisting/ipRanges.ts | 56 + .../pulumi/infra/src/whitelisting/policies.ts | 83 + .../infra/src/whitelisting/publicInfo.ts | 54 + .../src/whitelisting/publicTokenRegistry.ts | 39 + .../infra/src/whitelisting/scanAndSvApp.ts | 51 + .../infra/src/whitelisting/sequencer.ts | 60 + .../whitelisting/svPublicEndpoints.test.ts | 105 + .../src/whitelisting/svPublicEndpoints.ts | 96 + cluster/pulumi/infra/sweet.ts | 61 + cluster/pulumi/infra/tsconfig.eslint.json | 1 + cluster/pulumi/multi-validator/src/config.ts | 6 +- .../pulumi/multi-validator/src/installNode.ts | 6 +- .../src/multiNodeDeployment.ts | 21 +- .../pulumi/multi-validator/src/postgres.ts | 40 +- .../multi-validator/tsconfig.eslint.json | 1 + .../grafana-alerting/acs-stores_alerts.yaml | 4 +- .../dso_missed_confirmations_alerts.yaml | 10 +- .../global-sync-health_alerts.yaml | 215 + .../istio-rate-limiting_alerts.yaml | 67 + .../scan_bft_sequencers_alerts.yaml | 79 + .../scan_connection_disagreement_alerts.yaml | 24 +- .../splice-rate-limiting_alerts.yaml | 123 + .../canton-bft}/bft-ordering-performance.json | 1017 ++- .../canton-bft}/bft-ordering.json | 318 +- .../canton-network/app-rewards.json | 72 +- .../global-sync-utilization.json | 288 +- .../canton-network/treasury-service.json | 323 + .../validator-scan-connections.json | 122 + .../canton/acknowledgements.json | 2 +- .../grafana-dashboards/canton/lsu-status.json | 70 +- .../canton/unresponsive_parties.json | 4 +- .../grafana-dashboards/jvm/jvm.json | 56 +- .../platform/http_client.json | 264 +- .../grafana-dashboards/platform/istio.json | 651 ++ .../platform/rate_limiters.json | 365 +- .../splice-stores/acs-size.json | 138 +- .../mediator-verdicts-ingestion.json | 4 +- cluster/pulumi/observability/src/config.ts | 86 +- cluster/pulumi/observability/src/gcpAlerts.ts | 14 +- .../observability/src/grafana-dashboards.ts | 5 - cluster/pulumi/observability/src/istio.ts | 3 +- .../pulumi/observability/src/observability.ts | 146 +- .../pulumi/observability/tsconfig.eslint.json | 1 + cluster/pulumi/operator/src/config.ts | 24 + .../pulumi/operator/src/flux/flux-alerts.ts | 5 +- cluster/pulumi/operator/src/flux/flux.ts | 8 +- cluster/pulumi/operator/src/index.ts | 2 +- cluster/pulumi/operator/src/operator.ts | 4 +- cluster/pulumi/operator/tsconfig.eslint.json | 1 + cluster/pulumi/package-lock.json | 794 +- cluster/pulumi/package.json | 12 +- cluster/pulumi/policies/tsconfig.eslint.json | 1 + cluster/pulumi/pulumiUp.ts | 6 +- cluster/pulumi/splitwell/src/splitwell.ts | 4 +- cluster/pulumi/splitwell/tsconfig.eslint.json | 1 + cluster/pulumi/sv-canton/package.json | 2 +- cluster/pulumi/sv-canton/src/canton.ts | 3 + .../src/decentralizedSynchronizerNode.ts | 15 +- cluster/pulumi/sv-canton/tsconfig.eslint.json | 1 + cluster/pulumi/sv-runbook/dump-config.ts | 5 - cluster/pulumi/sv-runbook/src/installNode.ts | 6 +- cluster/pulumi/sv-runbook/src/postgres.ts | 18 +- .../pulumi/sv-runbook/tsconfig.eslint.json | 1 + cluster/pulumi/sv/src/installNode.ts | 39 +- cluster/pulumi/sv/src/participant.ts | 1 + cluster/pulumi/sv/tsconfig.eslint.json | 1 + cluster/pulumi/tsconfig.eslint.json | 5 + .../validator-runbook/src/installNode.ts | 23 +- .../validator-runbook/src/partyAllocator.ts | 9 +- .../validator-runbook/tsconfig.eslint.json | 1 + cluster/pulumi/validator1/src/validator1.ts | 15 +- .../pulumi/validator1/tsconfig.eslint.json | 1 + cluster/scripts/find-recent-backup.sh | 72 +- cluster/scripts/monitor-sv-catchup.sh | 173 - cluster/scripts/node-backup.sh | 92 +- cluster/scripts/node-restore.sh | 81 +- cluster/scripts/utils.source | 64 +- daml/daml-ide-mono/README.md | 15 + daml/daml-ide-mono/daml.yaml | 17 + docs/gen-daml-docs.sh | 1 + docs/src/release_notes_upcoming.rst | 82 +- gha-scripts/package-lock.json | 16 +- gha-scripts/package.json | 2 +- load-tester/package-lock.json | 510 +- nix/canton-sources.json | 12 +- nix/cometbft-driver.nix | 2 +- nix/flake.lock | 6 +- nix/flake.nix | 29 +- nix/overlays.nix | 17 +- nix/shell.nix | 5 +- project/BuildCommon.scala | 576 +- project/CantonDependencies.scala | 40 +- project/ProtocNixPlugin.scala | 71 + project/build.properties | 2 +- ...cel-global-synchronizer-upgrade.ignore.txt | 4 + ...standalone-mediator-offboarding.ignore.txt | 2 + ...tor-offboarding_before_shutdown.ignore.txt | 1 - ...ton-standalone-sv4-reonboarding.ignore.txt | 2 + ...v4-reonboarding_before_shutdown.ignore.txt | 1 - project/ignore-patterns/canton_log.ignore.txt | 17 +- .../ignore-patterns/canton_log_bft.ignore.txt | 2 + .../canton_log_simtime_extra.ignore.txt | 2 +- .../canton_network_test_log.ignore.txt | 22 +- project/ignore-patterns/sbt-output.ignore.txt | 8 +- scripts/copy-canton.sh | 17 + scripts/monthly-schedule.py | 822 ++ scripts/rename.sh | 2 +- scripts/setup-mono-package.sh | 34 + scripts/test-postgres-migration-k8s.py | 270 + scripts/test-postgres-migration.py | 252 + start-frontends.sh | 12 +- test-full-class-names-canton-enterprise.log | 1 - test-full-class-names-docker-no-canton.log | 1 + test-full-class-names-non-integration.log | 7 + test-full-class-names.log | 6 +- token-standard/cli/src/txparse/parserv2.ts | 14 +- 2032 files changed, 59422 insertions(+), 163145 deletions(-) delete mode 100644 .github/workflows/canton_oss_test.yml create mode 100644 .github/workflows/monthly-schedule.yml create mode 100644 apps/app/src/test/resources/localnet-reassign-topology.conf rename apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/{AmuletExpiryWithOldPackageIntegrationTest.scala => ExpiryWithMinimalVettedPackagesIntegrationTest.scala} (54%) create mode 100644 apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/LocalNetReassignIntegrationTest.scala rename apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/{ParticipantKmsIdentitiesEnterpriseIntegrationTest.scala => ParticipantKmsIdentitiesIntegrationTest.scala} (99%) create mode 100644 apps/common/frontend/src/__tests__/dso.test.tsx create mode 100644 apps/common/src/main/java/com/google/common/util/concurrent/BurstyRateLimiterFactory.java create mode 100644 apps/common/src/main/resources/db/migration/canton-network/postgres/stable/V073__dso_unavailable_parties.sql create mode 100644 apps/common/src/main/scala/org/lfdecentralizedtrust/splice/http/ClientIpDirectives.scala rename apps/{sv/src/main/scala/org/lfdecentralizedtrust/splice/sv => common/src/main/scala/org/lfdecentralizedtrust/splice}/store/IgnoredPartiesStore.scala (85%) create mode 100644 apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/UnavailablePartiesStore.scala create mode 100644 apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/AdvisoryLocks.scala create mode 100644 apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/AsUpdateReturning.scala create mode 100644 apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/DbUnavailablePartiesStore.scala create mode 100644 apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/JdbcTypes.scala create mode 100644 apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/Queries.scala create mode 100644 apps/common/src/test/scala/org/lfdecentralizedtrust/splice/http/HttpRateLimiterTest.scala create mode 100644 apps/common/src/test/scala/org/lfdecentralizedtrust/splice/http/InvalidResponseContentTest.scala create mode 100644 apps/common/src/test/scala/org/lfdecentralizedtrust/splice/store/DbUnavailablePartiesStoreTest.scala create mode 100644 apps/common/src/test/scala/org/lfdecentralizedtrust/splice/store/db/AdvisoryLocksTest.scala create mode 100644 apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/MultiEncodingBulkStorageFlow.scala create mode 100644 apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/util/PeerBftScanConnection.scala create mode 100644 apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/SingleScanConnectionTest.scala create mode 100644 apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/QueryAcsSnapshotPaginationTokenTest.scala create mode 100644 apps/sv/frontend/src/__tests__/components/copyable-identifier.test.tsx create mode 100644 apps/sv/frontend/src/__tests__/governance/forms/update-featured-app-form.test.tsx create mode 100644 apps/sv/frontend/src/__tests__/governance/proposal-search-validation.test.ts create mode 100644 apps/sv/frontend/src/__tests__/governance/proposal-search.test.tsx create mode 100644 apps/sv/frontend/src/__tests__/layout/sv-top-nav.test.tsx create mode 100644 apps/sv/frontend/src/__tests__/utils/getRequesterPartyId.test.ts create mode 100644 apps/sv/frontend/src/components/PartyIdScrollTracks.tsx create mode 100644 apps/sv/frontend/src/components/beta/identifierStyles.ts create mode 100644 apps/sv/frontend/src/components/form-components/RadioSelector.tsx create mode 100644 apps/sv/frontend/src/components/forms/UpdateFeaturedAppForm.tsx create mode 100644 apps/sv/frontend/src/components/governance/CancelProposalDialog.tsx create mode 100644 apps/sv/frontend/src/components/governance/InitiateProposalHeader.tsx create mode 100644 apps/sv/frontend/src/components/governance/InitiateProposalLayout.tsx create mode 100644 apps/sv/frontend/src/components/governance/ProposalReviewField.tsx create mode 100644 apps/sv/frontend/src/components/governance/ProposalSearch.tsx create mode 100644 apps/sv/frontend/src/components/layout/LogoutButton.tsx create mode 100644 apps/sv/frontend/src/components/layout/LogoutIcon.tsx create mode 100644 apps/sv/frontend/src/components/layout/NavAttentionIcon.tsx create mode 100644 apps/sv/frontend/src/components/layout/NavCountBadge.tsx create mode 100644 apps/sv/frontend/src/components/layout/NetworkBanner.tsx create mode 100644 apps/sv/frontend/src/components/layout/SvNavLink.tsx create mode 100644 apps/sv/frontend/src/components/layout/SvNavigationShell.tsx create mode 100644 apps/sv/frontend/src/components/layout/SvTopNav.tsx create mode 100644 apps/sv/frontend/src/components/ui/Dropdown.tsx create mode 100644 apps/sv/frontend/src/constants/createProposalLayout.ts create mode 100644 apps/sv/frontend/src/constants/formButtonStyles.ts create mode 100644 apps/sv/frontend/src/hooks/useFeaturedAppRightPicker.ts create mode 100644 apps/sv/frontend/src/hooks/useHorizontalScrollMetrics.ts create mode 100644 apps/sv/frontend/src/hooks/useVoteRequestResultsCount.ts create mode 100644 apps/sv/frontend/src/routes/delegateElection.tsx create mode 100644 apps/sv/frontend/src/theme/tokens.ts create mode 100644 apps/sv/frontend/src/themes/fieldStyles.ts create mode 100644 apps/sv/frontend/src/utils/proposalSearch.ts create mode 100644 apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/VoteRequestMetricsTrigger.scala delete mode 100644 apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/IgnoredAmuletVersionGuard.scala create mode 100644 apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/IgnoredUnavailablePartiesGuard.scala delete mode 100644 apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/RestartDsoDelegateBasedAutomationTrigger.scala create mode 100644 apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/util/ContractStakeholders.scala create mode 100644 apps/sv/src/test/scala/org/lfdecentralizedtrust/splice/sv/automation/VoteRequestMetricsTriggerTest.scala create mode 100644 apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/metrics/TreasuryMetrics.scala delete mode 100755 build-tools/artifactory_to_gcs.py delete mode 100644 build-tools/lib/hard-domain-migration-commands rename {canton/community/ledger/ledger-json-api => canton-fork/lf-value-json}/src/main/scala/com/digitalasset/canton/daml/lf/value/json/ApiCodecCompressed.scala (100%) rename {canton/community/ledger/ledger-json-api => canton-fork/lf-value-json}/src/main/scala/com/digitalasset/canton/daml/lf/value/json/ApiValueImplicits.scala (100%) rename {canton/community/ledger/ledger-json-api => canton-fork/lf-value-json}/src/main/scala/com/digitalasset/canton/daml/lf/value/json/JsonVariant.scala (100%) rename {canton/community/ledger/ledger-json-api => canton-fork/lf-value-json}/src/main/scala/com/digitalasset/canton/daml/lf/value/json/NavigatorModelAliases.scala (100%) create mode 100644 canton-fork/lf-value-json/src/test/scala/com/digitalasset/canton/daml/lf/value/json/ApiCodecCompressedSpec.scala delete mode 100644 canton/base/adjustable-clock/src/main/scala/com/daml/clock/AdjustableClock.scala delete mode 100644 canton/base/daml-jwt/src/main/scala/com/daml/jwt/AuthServiceJWTPayload.scala delete mode 100644 canton/base/daml-jwt/src/main/scala/com/daml/jwt/Base64.scala delete mode 100644 canton/base/daml-jwt/src/main/scala/com/daml/jwt/Error.scala delete mode 100644 canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtDecoder.scala delete mode 100644 canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtFromBearerHeader.scala delete mode 100644 canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtGenerator.scala delete mode 100644 canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtSigner.scala delete mode 100644 canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtTimestampLeeway.scala delete mode 100644 canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtVerifier.scala delete mode 100644 canton/base/daml-jwt/src/main/scala/com/daml/jwt/KeyUtils.scala delete mode 100644 canton/base/daml-jwt/src/main/scala/com/daml/jwt/WithExecuteUnsafe.scala delete mode 100644 canton/base/daml-jwt/src/main/scala/com/daml/jwt/package.scala delete mode 100644 canton/base/daml-jwt/src/test/scala/com/daml/jwt/JwtFromBearerHeaderSpec.scala delete mode 100644 canton/base/daml-jwt/src/test/scala/com/daml/jwt/JwtTimestampLeewaySpec.scala delete mode 100644 canton/base/daml-jwt/src/test/scala/com/daml/jwt/SignatureSpec.scala delete mode 100644 canton/base/daml-tls/src/main/scala/com/daml/tls/OcspProperties.scala delete mode 100644 canton/base/daml-tls/src/main/scala/com/daml/tls/ProtocolDisabler.scala delete mode 100644 canton/base/daml-tls/src/main/scala/com/daml/tls/TlsConfig.scala delete mode 100644 canton/base/daml-tls/src/main/scala/com/daml/tls/TlsConfiguration.scala delete mode 100644 canton/base/daml-tls/src/main/scala/com/daml/tls/TlsConfigurationCli.scala delete mode 100644 canton/base/daml-tls/src/main/scala/com/daml/tls/TlsInfo.scala delete mode 100644 canton/base/daml-tls/src/main/scala/com/daml/tls/TlsVersion.scala delete mode 100755 canton/base/daml-tls/src/test/resources/gen-test-certificates.sh delete mode 100644 canton/base/daml-tls/src/test/resources/openssl-alternative-template.cnf delete mode 100644 canton/base/daml-tls/src/test/resources/openssl-template.cnf delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/ca.crt delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/ca.key delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/ca_alternative.crt delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/ca_alternative.key delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/ca_alternative.pem delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/client-revoked.crt delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/client-revoked.csr delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/client-revoked.key delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/client-revoked.pem delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/client.crt delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/client.csr delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/client.key delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/client.pem delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/index.txt delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/ocsp.crt delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/ocsp.csr delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/ocsp.key.pem delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/server.crt delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/server.csr delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/server.key delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/server.pem delete mode 100644 canton/base/daml-tls/src/test/resources/test-certificates/server.pem.enc delete mode 100644 canton/base/daml-tls/src/test/scala/com/daml/tls/ProtocolDisablerTest.scala delete mode 100644 canton/base/errors/src/main/scala/com/digitalasset/base/error/Alarm.scala delete mode 100644 canton/base/errors/src/main/scala/com/digitalasset/base/error/BaseError.scala delete mode 100644 canton/base/errors/src/main/scala/com/digitalasset/base/error/BaseErrorLogger.scala delete mode 100644 canton/base/errors/src/main/scala/com/digitalasset/base/error/DamlError.scala delete mode 100644 canton/base/errors/src/main/scala/com/digitalasset/base/error/ErrorCategory.scala delete mode 100644 canton/base/errors/src/main/scala/com/digitalasset/base/error/ErrorClass.scala delete mode 100644 canton/base/errors/src/main/scala/com/digitalasset/base/error/ErrorCode.scala delete mode 100644 canton/base/errors/src/main/scala/com/digitalasset/base/error/ErrorGroup.scala delete mode 100644 canton/base/errors/src/main/scala/com/digitalasset/base/error/ErrorResource.scala delete mode 100644 canton/base/errors/src/main/scala/com/digitalasset/base/error/GrpcStatuses.scala delete mode 100644 canton/base/errors/src/main/scala/com/digitalasset/base/error/LogOnCreation.scala delete mode 100644 canton/base/errors/src/main/scala/com/digitalasset/base/error/RpcError.scala delete mode 100644 canton/base/errors/src/main/scala/com/digitalasset/base/error/SerializableErrorComponents.scala delete mode 100644 canton/base/errors/src/main/scala/com/digitalasset/base/error/samples/Example.scala delete mode 100644 canton/base/errors/src/main/scala/com/digitalasset/base/error/utils/DecodedCantonError.scala delete mode 100644 canton/base/errors/src/main/scala/com/digitalasset/base/error/utils/ErrorDetails.scala delete mode 100644 canton/base/errors/src/test/scala/com/digitalasset/base/error/ErrorCodeSpec.scala delete mode 100644 canton/base/errors/src/test/scala/com/digitalasset/base/error/ErrorGenerator.scala delete mode 100644 canton/base/errors/src/test/scala/com/digitalasset/base/error/ErrorGroupSpec.scala delete mode 100644 canton/base/errors/src/test/scala/com/digitalasset/base/error/ErrorsAssertions.scala delete mode 100644 canton/base/errors/src/test/scala/com/digitalasset/base/error/GrpcStatusesSpec.scala delete mode 100644 canton/base/errors/src/test/scala/com/digitalasset/base/error/RedactedMessageSpec.scala delete mode 100644 canton/base/errors/src/test/scala/com/digitalasset/base/error/SerializableErrorComponentsSpec.scala delete mode 100644 canton/base/errors/src/test/scala/com/digitalasset/base/error/samples/SampleClientSideSpec.scala delete mode 100644 canton/base/errors/src/test/scala/com/digitalasset/base/error/utils/BenignError.scala delete mode 100644 canton/base/errors/src/test/scala/com/digitalasset/base/error/utils/DecodedCantonErrorSpec.scala delete mode 100644 canton/base/errors/src/test/scala/com/digitalasset/base/error/utils/ErrorDetailsSpec.scala delete mode 100644 canton/base/errors/src/test/scala/com/digitalasset/base/error/utils/SevereError.scala create mode 100644 canton/base/testing-utils/src/main/resources/logback-test.xml delete mode 100644 canton/base/util-external/src/main/scala/com/digitalasset/canton/config/ConfidentialConfigWriter.scala delete mode 100644 canton/base/util-external/src/main/scala/com/digitalasset/canton/config/KeyStoreConfig.scala delete mode 100644 canton/base/util-external/src/main/scala/com/digitalasset/canton/config/PemFileOrString.scala delete mode 100644 canton/base/util-external/src/main/scala/com/digitalasset/canton/config/RequireTypes.scala delete mode 100644 canton/base/util-external/src/main/scala/com/digitalasset/canton/discard/Implicits.scala delete mode 100644 canton/base/util-external/src/main/scala/com/digitalasset/canton/time/TimeProvider.scala delete mode 100644 canton/base/util-external/src/main/scala/com/digitalasset/canton/util/BytesUnit.scala delete mode 100644 canton/base/util-external/src/main/scala/com/digitalasset/canton/util/JarResourceUtils.scala delete mode 100644 canton/base/util-external/src/main/scala/com/digitalasset/canton/util/Mutex.scala delete mode 100644 canton/base/util-external/src/main/scala/com/digitalasset/canton/util/VersionUtil.scala delete mode 100644 canton/base/util-external/src/test/scala/com/digitalasset/canton/config/RequireTypesTest.scala create mode 100644 canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/AchsInitIntegrationTest.scala create mode 100644 canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/auth/GetCompletionsAuthIT.scala create mode 100644 canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/metrics/SequencerConnectionPoolMetricsIntegrationTest.scala create mode 100644 canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/CryptoMetricsIntegrationTest.scala create mode 100644 canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/traffic/ParticipantTrafficEnforcementTest.scala create mode 100644 canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuExhaustiveHandshakeIntegrationTest.scala create mode 100644 canton/community/base/src/main/scala/com/digitalasset/canton/metrics/CryptoMetrics.scala create mode 100644 canton/community/base/src/main/scala/com/digitalasset/canton/metrics/DecryptionMetrics.scala create mode 100644 canton/community/base/src/main/scala/com/digitalasset/canton/metrics/SigningMetrics.scala create mode 100644 canton/community/base/src/main/scala/com/digitalasset/canton/util/ShardedSequentialProcessingQueue.scala create mode 100644 canton/community/base/src/main/scala/com/digitalasset/canton/util/TracedPossiblyPrevalidated.scala create mode 100644 canton/community/common/src/main/resources/db/migration/canton/h2/dev/V5_3__tea_initial.sha256 create mode 100644 canton/community/common/src/main/resources/db/migration/canton/h2/dev/V5_3__tea_initial.sql create mode 100644 canton/community/common/src/main/resources/db/migration/canton/h2/stable/V5_2__dabft_partitioning.sha256 create mode 100644 canton/community/common/src/main/resources/db/migration/canton/h2/stable/V5_2__dabft_partitioning.sql create mode 100644 canton/community/common/src/main/resources/db/migration/canton/postgres/dev/V5_3__tea_initial.sha256 create mode 100644 canton/community/common/src/main/resources/db/migration/canton/postgres/dev/V5_3__tea_initial.sql create mode 100644 canton/community/common/src/main/resources/db/migration/canton/postgres/stable/V5_1__optimize_contract_key_lookup_index.sha256 create mode 100644 canton/community/common/src/main/resources/db/migration/canton/postgres/stable/V5_1__optimize_contract_key_lookup_index.sql create mode 100644 canton/community/common/src/main/resources/db/migration/canton/postgres/stable/V5_2__dabft_partitioning.sha256 create mode 100644 canton/community/common/src/main/resources/db/migration/canton/postgres/stable/V5_2__dabft_partitioning.sql create mode 100644 canton/community/daml-lf/engine/src/test/scala/com/digitalasset/daml/lf/engine/UnsupportedContractIdEngineSpec.scala create mode 100644 canton/community/integration-testing/src/main/scala/com/digitalasset/canton/integration/HasCycleUtils.scala create mode 100644 canton/community/integration-testing/src/main/scala/com/digitalasset/canton/integration/HasTrailingNoneUtils.scala create mode 100644 canton/community/integration-testing/src/main/scala/com/digitalasset/canton/integration/plugins/UseLedgerApiTestTool.scala delete mode 100644 canton/community/kms-driver-api/src/main/scala/com/digitalasset/canton/crypto/kms/driver/api/KmsDriver.scala delete mode 100644 canton/community/kms-driver-api/src/main/scala/com/digitalasset/canton/crypto/kms/driver/api/KmsDriverFactory.scala delete mode 100644 canton/community/kms-driver-api/src/main/scala/com/digitalasset/canton/crypto/kms/driver/api/v1/KmsDriver.scala delete mode 100644 canton/community/kms-driver-api/src/main/scala/com/digitalasset/canton/crypto/kms/driver/api/v1/KmsDriverException.scala delete mode 100644 canton/community/kms-driver-api/src/main/scala/com/digitalasset/canton/crypto/kms/driver/api/v1/KmsDriverFactory.scala delete mode 100644 canton/community/kms-driver-api/src/main/scala/com/digitalasset/canton/crypto/kms/driver/api/v1/KmsDriverHealth.scala delete mode 100644 canton/community/kms-driver-api/src/main/scala/com/digitalasset/canton/crypto/kms/driver/api/v1/KmsDriverSpecs.scala delete mode 100644 canton/community/kms-driver-api/src/main/scala/com/digitalasset/canton/driver/api/DriverFactory.scala delete mode 100644 canton/community/kms-driver-api/src/main/scala/com/digitalasset/canton/driver/api/v1/DriverFactory.scala create mode 100644 canton/community/ledger-test-tool/src/main/resources/logback.xml delete mode 100644 canton/community/ledger-test-tool/src/main/scala/com/daml/ledger/api/testtool/suites/v2_2/CompanionImplicits.scala create mode 100644 canton/community/ledger-test-tool/src/main/scala/com/daml/ledger/api/testtool/suites/v2_2/GetCompletionsIT.scala delete mode 100644 canton/community/ledger-test-tool/src/main/scala/com/daml/ledger/api/testtool/suites/v2_3/ContractKeysCompanionImplicits.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/.gitattributes delete mode 100644 canton/community/ledger/ledger-api-core/src/main/protobuf/daml/buf.yaml delete mode 100644 canton/community/ledger/ledger-api-core/src/main/protobuf/daml/platform/v1/acs_continuation.proto delete mode 100644 canton/community/ledger/ledger-api-core/src/main/protobuf/daml/platform/v1/acs_page_token.proto delete mode 100644 canton/community/ledger/ledger-api-core/src/main/protobuf/daml/platform/v1/index.proto delete mode 100644 canton/community/ledger/ledger-api-core/src/main/protobuf/daml/platform/v1/page_tokens.proto delete mode 100644 canton/community/ledger/ledger-api-core/src/main/resources/metering-keys/community.json delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/error/generator/ErrorCategoryDocItem.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/error/generator/ErrorCategoryInventoryDocsGenerator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/error/generator/ErrorCodeDocItem.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/error/generator/ErrorCodeDocumentationGenerator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/error/generator/ErrorCodeInventoryDocsGenerator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/error/generator/ErrorGroupDocItem.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/error/generator/app/ErrorCategoryInventoryDocsGenApp.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/error/generator/app/ErrorCodeInventoryDocsGenApp.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/error/generator/app/Main.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/ProxyCloseable.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/SubmissionIdGenerator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/TraceIdentifiers.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/ValidationLogger.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/IdentityProviderAwareAuthService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/IdentityProviderConfigLoader.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/RequiredClaims.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/UserBasedOngoingAuthorization.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/UserRightsChangeAsyncChecker.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/interceptor/UserBasedClaimResolver.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/services/CommandCompletionServiceAuthorization.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/services/CommandInspectionServiceAuthorization.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/services/CommandServiceAuthorization.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/services/CommandSubmissionServiceAuthorization.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/services/ContractServiceAuthorization.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/services/EventQueryServiceAuthorization.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/services/IdentityProviderConfigServiceAuthorization.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/services/InteractiveSubmissionServiceAuthorization.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/services/PackageManagementServiceAuthorization.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/services/PackageServiceAuthorization.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/services/ParticipantPruningServiceAuthorization.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/services/PartyManagementServiceAuthorization.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/services/StateServiceAuthorization.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/services/TimeServiceAuthorization.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/services/UpdateServiceAuthorization.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/auth/services/UserManagementServiceAuthorization.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/grpc/DropRepeated.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/grpc/GrpcApiService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/grpc/GrpcHealthService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/grpc/Logging.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/grpc/StreamingServiceLifecycleManagement.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/messages/command/completion/CompletionStreamRequest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/messages/command/submission/SubmitReassignmentRequest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/messages/command/submission/SubmitRequest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/messages/event/GetEventsByContractIdRequest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/messages/event/GetEventsByContractKeyRequest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/messages/state/AcsContinuationToken.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/messages/state/AcsPageToken.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/messages/update/GetLedgerEndRequest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/messages/update/GetTransactionByIdRequest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/messages/update/GetTransactionByOffsetRequest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/messages/update/GetUpdateByIdRequest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/messages/update/GetUpdateByOffsetRequest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/messages/update/GetUpdatesPageRequest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/messages/update/GetUpdatesRequest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/messages/update/UpdatesPageToken.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/package.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/refinements/ApiTypes.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/services/CommandInspectionService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/services/CommandService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/services/CommandSubmissionService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/services/InteractiveSubmissionService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/util/DurationConversion.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/util/LfEngineToApi.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/util/PageTokenUtils.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/util/TimeProvider.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/util/TimeProviderType.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/util/TimestampConversion.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/util/UpdateFormatHashUtils.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/validation/CommandInspectionServiceRequestValidator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/validation/CommandsValidator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/validation/CompletionServiceRequestValidator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/validation/CryptoValidator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/validation/DeduplicationPeriodValidator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/validation/EventQueryServiceRequestValidator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/validation/FieldValidator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/validation/FormatValidator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/validation/GetPreferredPackagesRequestValidator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/validation/ParticipantOffsetValidator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/validation/ResourceAnnotationValidator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/validation/SubmitAndWaitRequestValidator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/validation/SubmitRequestValidator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/validation/UpdateServiceRequestValidator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/validation/ValidateDisclosedContracts.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/validation/ValidateUpgradingPackageResolutions.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/validation/ValidationErrors.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/api/validation/ValueValidator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/client/GrpcChannel.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/client/LedgerClient.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/client/LedgerClientUtils.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/client/LedgerSubscription.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/client/ResilientLedgerSubscription.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/client/configuration/CommandClientConfiguration.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/client/configuration/LedgerClientChannelConfiguration.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/client/configuration/LedgerClientConfiguration.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/client/services/EventQueryServiceClient.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/client/services/admin/IdentityProviderConfigClient.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/client/services/admin/PackageManagementClient.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/client/services/admin/ParticipantPruningManagementClient.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/client/services/admin/PartyManagementClient.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/client/services/admin/UserManagementClient.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/client/services/commands/CommandClient.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/client/services/commands/CommandServiceClient.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/client/services/pkg/PackageClient.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/client/services/state/StateServiceClient.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/client/services/updates/UpdateServiceClient.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/client/services/version/VersionClient.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/error/CommonErrors.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/error/IndexErrors.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/error/JsonApiErrors.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/error/LedgerApiErrors.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/error/PackageServiceErrors.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/error/ParticipantErrorGroup.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/error/groups/AdminServiceErrors.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/error/groups/CommandExecutionErrors.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/error/groups/ConsistencyErrors.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/error/groups/IdentityProviderConfigServiceErrors.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/error/groups/PartyManagementServiceErrors.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/error/groups/RequestValidationErrors.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/error/groups/UserManagementServiceErrors.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/localstore/CachedIdentityProviderConfigStore.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/localstore/CachedUserManagementStore.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/localstore/InMemoryIdentityProviderConfigStore.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/localstore/InMemoryPartyRecordStore.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/localstore/InMemoryUserManagementStore.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/localstore/Ops.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/localstore/PersistentIdentityProviderConfigStore.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/localstore/PersistentPartyRecordStore.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/localstore/PersistentUserManagementStore.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/localstore/api/IdentityProviderConfigStore.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/localstore/api/IdentityProviderConfigUpdate.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/localstore/api/PartyRecord.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/localstore/api/PartyRecordStore.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/localstore/api/UserManagementStore.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/localstore/utils/LocalAnnotationsUtils.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/metrics/CommandMetrics.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/metrics/ContractStoreMetrics.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/metrics/DatabaseMetricsFactory.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/metrics/ExecutionMetrics.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/metrics/IdentityProviderConfigStoreMetrics.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/metrics/IndexDBMetrics.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/metrics/IndexMetrics.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/metrics/IndexerMetrics.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/metrics/LAPIMetrics.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/metrics/PartyRecordStoreMetrics.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/metrics/PruningMetrics.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/metrics/ServicesMetrics.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/metrics/UserManagementMetrics.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/AcsChange.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/ChangeId.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/CompletionInfo.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/InternalIndexService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/PackageDescription.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/PackageSyncService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/ParticipantPruningSyncService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/PartySyncService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/PruningResult.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/Reassignment.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/ReassignmentCommand.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/ReassignmentCommandsBatch.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/RoutingSynchronizerState.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/SubmissionResult.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/SubmissionSyncService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/SubmitterInfo.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/SyncService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/SynchronizerIndex.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/SynchronizerRank.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/TransactionMeta.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/Update.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/index/ContractStore.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/index/IndexActiveContractsService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/index/IndexCompletionsService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/index/IndexEventQueryService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/index/IndexParticipantPruningService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/index/IndexPartyManagementService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/index/IndexService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/index/IndexUpdateService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/index/IndexerPartyDetails.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/index/LedgerEndService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/index/MaximumLedgerTimeService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/metrics/TimedSyncService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/participant/state/package.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/runner/common/OptConfigValue.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/ledger/runner/common/PureConfigReaderWriter.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/metrics/LedgerApiServerMetrics.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/DispatcherState.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/InMemoryState.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/InternalUpdateFormat.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/LedgerApiServerInternals.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/PackagePreferenceBackend.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/ResourceCloseable.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/TemplatePartiesFilter.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/ApiException.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/ApiService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/ApiServiceOwner.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/ApiServices.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/ExecutionSequencerFactoryOwner.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/GrpcConnectionLogger.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/GrpcInterceptors.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/GrpcServer.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/GrpcServerOwner.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/InProcessGrpcName.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/LedgerApiService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/LedgerFeatures.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/SeedService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/TimeServiceBackend.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/TimedIndexService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/TruncatedStatusInterceptor.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/configuration/RateLimitingConfig.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/error/ErrorInterceptor.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/execution/CommandExecutionResult.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/execution/CommandExecutor.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/execution/CommandProgressTracker.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/execution/DynamicSynchronizerParameterGetter.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/execution/LedgerTimeAwareCommandExecutor.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/execution/ResolveMaximumLedgerTime.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/execution/StoreBackedCommandInterpreter.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/execution/TapsCommandExecutionFactory.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/execution/TimedCommandExecutor.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/execution/TopologyAwareCommandExecutor.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/ratelimiting/MemoryCheck.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/ratelimiting/RateLimitingInterceptorFactory.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/ApiCommandCompletionService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/ApiCommandService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/ApiCommandSubmissionService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/ApiContractService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/ApiEventQueryService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/ApiInteractiveSubmissionService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/ApiPackageService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/ApiStateService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/ApiTimeService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/ApiUpdateService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/ApiVersionService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/RejectionGenerators.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/StreamMetrics.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/admin/ApiCommandInspectionService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/admin/ApiIdentityProviderConfigService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/admin/ApiPackageManagementService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/admin/ApiParticipantPruningService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/admin/ApiPartyManagementService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/admin/ApiUserManagementService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/admin/AuthenticatedUserContextResolver.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/admin/IdentityProviderExists.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/admin/PackageUpgradeValidator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/admin/PartyAllocation.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/admin/PartyRecordsExist.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/admin/PendingPartyAllocations.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/admin/Utils.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/admin/package.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/command/CommandInspectionServiceImpl.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/command/CommandServiceImpl.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/command/CommandSubmissionServiceImpl.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/command/interactive/CostEstimationHints.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/command/interactive/InteractiveSubmissionServiceImpl.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/command/interactive/codec/EnrichedTransactionData.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/command/interactive/codec/ExternalTransactionProcessor.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/command/interactive/codec/PreparedTransactionCodec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/command/interactive/codec/PreparedTransactionDecoder.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/command/interactive/codec/PreparedTransactionEncoder.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/logging/package.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/package.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/tracking/CancellableTimeoutSupport.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/tracking/CompletionResponse.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/tracking/StreamTracker.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/tracking/SubmissionTracker.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/update/FieldNames.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/update/IdentityProviderConfigUpdateMapper.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/update/PartyRecordUpdateMapper.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/update/UpdateMapperBase.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/update/UpdatePath.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/update/UpdatePathError.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/update/UpdatePathsTrie.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/update/UpdateRequestsPaths.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/update/UserUpdateMapper.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/update/update.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/config/CommandServiceConfig.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/config/IdentityProviderManagementConfig.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/config/IndexServiceConfig.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/config/InteractiveSubmissionServiceConfig.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/config/InvalidConfigException.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/config/PackageServiceConfig.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/config/PartyManagementServiceConfig.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/config/Readers.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/config/ServerRole.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/config/StateServiceConfig.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/config/TopologyAwarePackageSelectionConfig.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/config/UpdateServiceConfig.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/config/UserManagementServiceConfig.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/index/ContractStoreBasedMaximumLedgerTimeService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/index/InMemoryStateUpdater.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/index/IndexServiceImpl.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/index/IndexServiceOwner.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/index/ParticipantIdNotFoundException.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/IndexerConfig.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/IndexerState.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/JdbcIndexer.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/TransactionTraversalUtils.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/ha/HaCoordinator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/ha/KillSwitchCaptor.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/ha/PollingChecker.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/ha/PreemptableSequence.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/package.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/parallel/AchsMaintenancePipe.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/parallel/AsyncSupport.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/parallel/BatchingParallelIngestionPipe.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/parallel/EventMetricsUpdater.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/parallel/InitializeParallelIngestion.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/parallel/ParallelIndexerFactory.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/parallel/ParallelIndexerSubscription.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/parallel/PostPublishData.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/package.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/packages/DeduplicatingPackageLoader.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/CompletionFromTransaction.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/DbSupport.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/DbType.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/EventSequentialId.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/FlywayMigrations.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/LedgerApiContractStore.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/PruningOffsetService.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/ScalaPbStreamingOptimizations.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/Conversions.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/DbDto.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/PersistentEventType.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/RowDef.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/StorageBackend.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/StorageBackendFactory.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/UpdateToDbDto.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/VerifiedDataSource.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/CommonRowDefs.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/CommonStorageBackendFactory.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/CompletionStorageBackendTemplate.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/ComposableQuery.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/ContractStorageBackendTemplate.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/DataSourceStorageBackendImpl.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/EventReaderQueries.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/EventStorageBackendTemplate.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/Field.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/IngestionStorageBackendTemplate.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/InitHookDataSourceProxy.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/IntegrityStorageBackendImpl.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/MismatchException.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/ParameterStorageBackendImpl.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/PartyStorageBackendTemplate.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/QueryStrategy.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/Schema.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/SimpleSqlExtensions.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/StringInterningStorageBackendImpl.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/Table.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/UpdatePointwiseQueries.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/common/UpdateStreamingQueries.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/h2/H2DBLockStorageBackend.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/h2/H2DataSourceStorageBackend.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/h2/H2EventStorageBackend.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/h2/H2Field.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/h2/H2QueryStrategy.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/h2/H2ResetStorageBackend.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/h2/H2Schema.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/h2/H2StorageBackendFactory.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/localstore/IdentityProviderStorageBackend.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/localstore/IdentityProviderStorageBackendImpl.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/localstore/ParticipantMetadataBackend.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/localstore/PartyRecordStorageBackend.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/localstore/PartyRecordStorageBackendImpl.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/localstore/ResourceVersionOps.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/localstore/UserManagementStorageBackend.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/localstore/UserManagementStorageBackendImpl.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/postgresql/PGField.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/postgresql/PGSchema.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/postgresql/PGTable.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/postgresql/PostgresContractStorageBackend.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/postgresql/PostgresDBLockStorageBackend.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/postgresql/PostgresDataSourceStorageBackend.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/postgresql/PostgresEventStorageBackend.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/postgresql/PostgresQueryStrategy.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/postgresql/PostgresResetStorageBackend.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/backend/postgresql/PostgresStorageBackendFactory.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/cache/AchsStateCache.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/cache/ContractKeyStateCache.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/cache/ContractStateCaches.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/cache/ContractsStateCache.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/cache/InMemoryFanoutBuffer.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/cache/LedgerEndCache.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/cache/MutableCacheBackedContractStore.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/cache/OffsetCheckpointCache.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/cache/OnlyForTestingTransactionInMemoryStore.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/cache/StateCache.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/cache/package.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/BufferedCommandCompletionsReader.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/BufferedStreamsReader.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/BufferedUpdatePointwiseReader.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/CommandCompletionsReader.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/DatabaseSelfServiceError.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/DbDispatcher.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/EventProjectionProperties.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/HikariJdbcConnectionProvider.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/JdbcConnectionProvider.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/JdbcLedgerDao.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/LedgerDao.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/PaginatingAsyncStream.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/PersistenceResponse.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/QueryRange.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/ACSReader.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/BufferedUpdateReader.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/CompressionMetrics.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/CompressionStrategy.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/ContractLoader.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/ContractStateEvent.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/ContractsReader.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/EventIdsUtils.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/EventsRange.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/EventsReader.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/EventsTable.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/FilterUtils.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/IdPageSizing.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/InputContractPackages.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/LfEnricher.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/LfValueTranslation.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/QueryValidRange.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/TopologyTransactionPointwiseReader.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/TopologyTransactionsStreamReader.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/TransactionLogUpdatesConversions.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/TransactionOrReassignmentPointwiseReader.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/UpdatePointwiseReader.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/UpdateReader.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/UpdatesStreamReader.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/Utils.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/interfaces/LedgerDaoContractsReader.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/interfaces/TransactionLogUpdate.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/interning/MockStringInterning.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/interning/RawStringInterning.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/interning/StringInterning.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/interning/StringInterningView.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/serialization/Compression.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/serialization/ValueSerializer.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/utils/ConcurrencyLimiter.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/utils/EventOps.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/utils/Telemetry.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/tracing/SerializableTraceContextConverter.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/version/HashingSchemeVersionConverter.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/resources/application.conf delete mode 100644 canton/community/ledger/ledger-api-core/src/test/resources/config/test.conf delete mode 100644 canton/community/ledger/ledger-api-core/src/test/resources/config/test2.conf delete mode 100644 canton/community/ledger/ledger-api-core/src/test/resources/config/testp.conf delete mode 100644 canton/community/ledger/ledger-api-core/src/test/resources/test-metering-key.json delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/auth/AuthInterceptorSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/auth/AuthServiceJWTCodecSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/auth/AuthorizerSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/auth/JwksSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/auth/JwtVerifierLoaderSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/error/generator/ErrorCodeDocumentationGeneratorSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/grpc/sampleservice/HelloServiceReferenceImplementation.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/ApiMocks.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/IdentityProviderIdSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/MockMessages.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/TraceIdentifiersTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/ValueConversionRoundTripTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/auth/RequiredClaimsSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/auth/StreamAuthorizationComponentSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/auth/UserBasedAuthInterceptorSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/auth/UserBasedOngoingAuthorizationSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/auth/services/ApiServicesRequiredClaimSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/grpc/DropRepeatedSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/grpc/GrpcClientResource.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/grpc/GrpcHealthServiceSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/messages/state/AcsContinuationTokenTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/messages/state/AcsPageTokenTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/util/TimestampConversionSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/validation/CompletionServiceRequestValidatorTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/validation/EventQueryServiceRequestValidatorTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/validation/IdentifierValidatorTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/validation/ResourceAnnotationValidationsSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/validation/SubmitRequestValidatorTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/validation/UpdateServiceRequestValidatorTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/validation/ValidateDisclosedContractsTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/validation/ValidateUpgradingPackageResolutionsTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/api/validation/ValidatorTestUtils.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/client/ResilientLedgerSubscriptionTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/CachedIdentityProviderConfigStoreSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/CachedUserManagementStoreSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/ConcurrentChangeControlTests.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/ConcurrentPersistentPartyRecordStoreTests.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/ConcurrentPersistentUserStoreTests.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/DbDispatcherLeftOpsSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/IdentityProviderConfigStoreSpecBase.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/IdentityProviderConfigStoreTests.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/InMemoryIdentityProviderConfigStoreSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/InMemoryPartyRecordStoreSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/InMemoryUserManagementStoreSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/PartyRecordStoreSpecBase.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/PartyRecordStoreTests.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/PersistentIdentityProviderConfigStoreSpecH2.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/PersistentIdentityProviderConfigStoreSpecPostgres.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/PersistentIdentityProviderConfigStoreTests.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/PersistentPartyRecordStoreSpecH2.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/PersistentPartyRecordStoreSpecPostgres.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/PersistentPartyRecordStoreTests.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/PersistentStoreSpecBase.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/PersistentUserStoreSpecH2.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/PersistentUserStoreSpecPostgres.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/PersistentUserStoreTests.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/UserStoreSpecBase.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/localstore/UserStoreTests.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/participant/state/ReassignmentCommandsBatchTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/runner/common/ArbitraryConfig.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/ledger/runner/common/PureConfigReaderWriterSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/DispatcherStateSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/InMemoryStateSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/FatContractInstanceHelper.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/GrpcServerSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/SeedingSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/SimpleTimeServiceBackendSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/error/ErrorInterceptorSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/execution/LedgerTimeAwareCommandExecutorSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/execution/ResolveMaximumLedgerTimeSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/execution/StoreBackedCommandInterpreterSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/execution/TestDynamicSynchronizerParameterGetter.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/ratelimiting/MemoryCheckSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/ratelimiting/RateLimitingInterceptorChecksSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/services/ApiCommandServiceSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/services/ApiCommandSubmissionServiceSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/services/DisclosedContractCreator.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/services/admin/ApiPackageManagementServiceSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/services/admin/ApiPartyManagementServiceSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/services/admin/ApiUserManagementServiceSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/services/admin/PackageTestUtils.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/services/admin/PackageUpgradeValidatorSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/services/admin/PendingPartyAllocationsSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/services/command/CommandServiceImplSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/services/command/CommandSubmissionServiceImplSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/services/command/interactive/GeneratorsInteractiveSubmission.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/services/command/interactive/PreparedTransactionCodecV1Spec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/services/tracking/CancellableTimeoutSupportSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/services/tracking/SubmissionTrackerSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/tls/OcspResponderFixture.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/tls/TlsCertificateRevocationCheckingSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/tls/TlsFixture.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/tls/TlsSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/update/IdentityProviderConfigUpdateMapperSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/update/PartyRecordUpdateMapperSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/update/UpdatePathSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/update/UpdatePathsTrieSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/update/UserUpdateMapperSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/validation/ErrorFactoriesSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/component/AchsIndexComponentTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/component/IndexComponentLoadTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/component/IndexComponentTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/component/NonUniqueContractKeyIndexComponentTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/component/PersistenceSqlQueries.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/component/SingleStepIncreasingRecordTime.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/component/UpdatePagesComponentTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/component/UpdateStreamComponentTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/component/UpdateStreamReaderPruningComponentTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/index/ContractStoreBasedMaximumLedgerTimeServiceSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/index/InMemoryStateUpdaterSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/index/IndexServiceImplSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/indexer/IndexerStateSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/indexer/TransactionTraversalUtilsSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/indexer/ha/HaCoordinatorSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/indexer/ha/TestDBLockStorageBackend.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/indexer/ha/TestDBLockStorageBackendSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/indexer/parallel/AchsMaintenancePipeSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/indexer/parallel/BatchingParallelIngestionPipeSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/indexer/parallel/EventMetricsUpdaterSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/indexer/parallel/ParallelIndexerFactorySpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/indexer/parallel/ParallelIndexerSubscriptionSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/indexer/parallel/PostPublishDataSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/multisynchronizer/MultiSynchronizerIndexComponentTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/packages/DeduplicatingPackageLoaderSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/CompletionFromTransactionSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/FlywayMigrationsSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/PruningOffsetServiceSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/DbDtoSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/PruningDtoQueries.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/ScalatestEqualityHelpers.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/ScalatestEqualityHelpersSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendProvider.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendSpecH2.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendSpecPostgres.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendSuite.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestValues.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestsCompletions.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestsContracts.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestsConversions.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestsDBLock.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestsEvents.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestsIDPConfig.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestsInitialization.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestsInitializeIngestion.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestsIntegrity.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestsParameters.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestsParticipantMetadata.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestsParties.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestsPartyRecord.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestsPartyToParticipant.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestsPruning.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestsQueryValidRange.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestsReset.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestsStringInterning.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestsTimestamps.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/StorageBackendTestsUserManagement.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/UpdateToDbDtoSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/common/ComposableQuerySpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/backend/h2/H2DataSourceStorageBackendSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/cache/AchsStateCacheSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/cache/ContractStateCachesSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/cache/InMemoryFanoutBufferSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/cache/MutableCacheBackedContractStoreRaceTests.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/cache/MutableCacheBackedContractStoreSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/cache/StateCacheSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/BufferedStreamsReaderSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/BufferedUpdatePointwiseReaderSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/EventProjectionPropertiesSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/HikariJdbcConnectionProviderSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/JdbcLedgerDaoActiveContractsSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/JdbcLedgerDaoBackend.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/JdbcLedgerDaoBackendH2Database.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/JdbcLedgerDaoBackendPostgresql.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/JdbcLedgerDaoCompletionsSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/JdbcLedgerDaoContractsSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/JdbcLedgerDaoExceptionSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/JdbcLedgerDaoPartiesSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/JdbcLedgerDaoSpecH2.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/JdbcLedgerDaoSpecPostgres.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/JdbcLedgerDaoSuite.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/JdbcLedgerDaoTransactionsSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/JdbcLedgerDaoTransactionsWriterSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/JdbcLedgerWriteDao.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/LedgerWriteDao.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/PaginatingAsyncStreamSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/SequentialWriteDao.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/SequentialWriteDaoSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/events/ACSReaderSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/events/GroupContiguousSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/events/InputContractPackagesTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/events/LfEnricherSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/events/PekkoStreamParallelBatchedLoaderSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/dao/events/UtilsSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/entries/LedgerEntry.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/interning/RawStringInterningSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/interning/StringInterningDomainSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/interning/StringInterningViewSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/migration/DbConnectionAndDataSourceAroundEach.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/migration/DbDataTypes.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/migration/MigrationTestSupport.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/migration/postgres/PostgresAroundEachForMigrations.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/migration/postgres/RemovalOfJavaMigrationsPostgres.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/testing/postgresql/PostgresAround.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/testing/postgresql/PostgresAroundAll.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/testing/postgresql/PostgresAroundEach.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/testing/postgresql/PostgresAroundSuite.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/testing/postgresql/PostgresDatabase.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/testing/postgresql/PostgresResource.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/testing/postgresql/PostgresServer.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/store/utils/ConcurrencyLimiterSpec.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/util/ConcurrentBufferedProcessLogger.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/util/ContractValidatorTest.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/util/TestEngine.scala delete mode 100644 canton/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/util/api/TimestampConversionTest.scala delete mode 100644 canton/community/lib/Blake2b/src/main/java/org/bouncycastle/crypto/digests/canton/Blake2bDigest.java delete mode 100644 canton/community/lib/magnolify/src/main/scala/magnolify/scalacheck/shrink/DerivedShrink.scala delete mode 100644 canton/community/lib/magnolify/src/main/scala/magnolify/scalacheck/shrink/package.scala delete mode 100644 canton/community/lib/magnolify/src/test/scala/magnolify/scalacheck/shrink/ShrinkDerivationTest.scala delete mode 100644 canton/community/lib/scalatest/src/main/scala/org/scalatest/AssertionsUtil.scala delete mode 100644 canton/community/lib/scalatest/src/main/scala/org/scalatest/AssertionsUtilMacros.scala delete mode 100644 canton/community/lib/scalatest/src/test/scala/org/scalatest/AssertionsUtilTest.scala delete mode 100644 canton/community/lib/slick/LICENSE.txt delete mode 100644 canton/community/lib/slick/src/main/scala/slick/jdbc/canton/StaticQuery.scala delete mode 100644 canton/community/lib/wartremover-annotations/src/main/scala/com/digitalasset/canton/AllowTraverseSingleContainer.scala delete mode 100644 canton/community/lib/wartremover-annotations/src/main/scala/com/digitalasset/canton/DoNotDiscardLikeFuture.scala delete mode 100644 canton/community/lib/wartremover-annotations/src/main/scala/com/digitalasset/canton/DoNotReturnFromSynchronizedLikeFuture.scala delete mode 100644 canton/community/lib/wartremover-annotations/src/main/scala/com/digitalasset/canton/DoNotTraverseLikeFuture.scala delete mode 100644 canton/community/lib/wartremover-annotations/src/main/scala/com/digitalasset/canton/FutureTransformer.scala delete mode 100644 canton/community/lib/wartremover-annotations/src/main/scala/com/digitalasset/canton/GrpcServiceInvocationMethod.scala create mode 100644 canton/community/participant/src/main/scala/com/digitalasset/canton/participant/scheduler/PostLsuPurgeableStoresComputation.scala delete mode 100644 canton/community/synchronizer/src/main/scala/com/digitalasset/canton/synchronizer/mediator/ProcessingQueue.scala create mode 100644 canton/community/synchronizer/src/main/scala/com/digitalasset/canton/synchronizer/sequencer/block/bftordering/core/modules/pruning/PartitionManager.scala create mode 100644 canton/community/synchronizer/src/main/scala/com/digitalasset/canton/synchronizer/sequencer/block/bftordering/framework/data/ordering/OrderingMode.scala delete mode 100644 canton/community/synchronizer/src/test/scala/com/digitalasset/canton/synchronizer/mediator/ProcessingQueueTest.scala create mode 100644 canton/community/synchronizer/src/test/scala/com/digitalasset/canton/synchronizer/mediator/ShardedSequentialProcessingQueueTest.scala create mode 100644 canton/community/synchronizer/src/test/scala/com/digitalasset/canton/synchronizer/sequencer/block/bftordering/core/modules/output/BlocksRecoveredFromConsensusMessagesTest.scala create mode 100644 canton/community/synchronizer/src/test/scala/com/digitalasset/canton/synchronizer/sequencer/block/bftordering/core/modules/pruning/PartitionManagerTest.scala create mode 100644 canton/community/synchronizer/src/test/scala/com/digitalasset/canton/synchronizer/sequencer/block/bftordering/core/modules/pruning/PartitionMigrationTest.scala create mode 100644 canton/community/traffic-enforcement/api/protobuf/buf.yaml create mode 100644 canton/community/traffic-enforcement/api/protobuf/com/digitalasset/canton/tea/scalapb/package.proto create mode 100644 canton/community/traffic-enforcement/api/protobuf/com/digitalasset/canton/tea/v1/traffic_service.proto create mode 100644 canton/community/traffic-enforcement/component/src/main/scala/com/digitalasset/canton/tea/TrafficEnforcementApp.scala create mode 100644 canton/community/traffic-enforcement/component/src/main/scala/com/digitalasset/canton/tea/TrafficEnforcementService.scala create mode 100644 canton/community/traffic-enforcement/component/src/main/scala/com/digitalasset/canton/tea/TrafficEnforcementServiceGrpc.scala create mode 100644 canton/community/traffic-enforcement/component/src/main/scala/com/digitalasset/canton/tea/projection/CloseableProjection.scala create mode 100644 canton/community/traffic-enforcement/component/src/main/scala/com/digitalasset/canton/tea/projection/ProjectionEvent.scala create mode 100644 canton/community/traffic-enforcement/component/src/main/scala/com/digitalasset/canton/tea/projection/TeaProjection.scala create mode 100644 canton/community/traffic-enforcement/component/src/main/scala/com/digitalasset/canton/tea/projection/TeaTrafficStore.scala create mode 100644 canton/community/traffic-enforcement/component/src/main/scala/com/digitalasset/canton/tea/projection/db/TeaDbProjection.scala create mode 100644 canton/community/traffic-enforcement/component/src/main/scala/com/digitalasset/canton/tea/projection/db/TeaDbTrafficStore.scala create mode 100644 canton/community/traffic-enforcement/component/src/main/scala/com/digitalasset/canton/tea/projection/memory/TeaMemoryProjection.scala create mode 100644 canton/community/traffic-enforcement/component/src/main/scala/com/digitalasset/canton/tea/projection/memory/TeaMemoryTrafficStore.scala create mode 100644 canton/community/traffic-enforcement/component/src/test/scala/com/digitalasset/canton/tea/projection/TeaProjectionTest.scala create mode 100644 canton/community/traffic-enforcement/component/src/test/scala/com/digitalasset/canton/tea/projection/TeaTrafficStoreTest.scala create mode 100644 canton/community/traffic-enforcement/component/src/test/scala/com/digitalasset/canton/tea/projection/db/DbTeaProjectionTest.scala create mode 100644 canton/community/traffic-enforcement/component/src/test/scala/com/digitalasset/canton/tea/projection/db/DbTeaTrafficStoreTest.scala create mode 100644 canton/community/traffic-enforcement/component/src/test/scala/com/digitalasset/canton/tea/projection/memory/MemoryTeaProjectionTest.scala create mode 100644 canton/community/traffic-enforcement/component/src/test/scala/com/digitalasset/canton/tea/projection/memory/MemoryTeaTrafficStoreTest.scala delete mode 100644 canton/community/transcode/daml-lf/src/test/scala/com/digitalasset/transcode/daml_lf/package.scala create mode 100644 canton/release-notes/3.5.1.md create mode 100644 canton/release-notes/3.5.2.md create mode 100644 canton/release-notes/3.5.3.md create mode 100644 canton/release-notes/3.5.4.md create mode 100644 canton/release-notes/3.5.5.md create mode 100644 canton/release-notes/3.5.6.md create mode 100644 cluster/configs/shared/rate-limits/token-registry.yaml create mode 100644 cluster/deployment/package-lock.json create mode 100644 cluster/helm/splice-cometbft/tests/cometbft_watchdog_test.yaml create mode 100644 cluster/helm/splice-validator/tests/wallet-user-ids_test.yaml create mode 100644 cluster/images/cometbft-watchdog/Dockerfile create mode 100644 cluster/images/cometbft-watchdog/local.mk create mode 100644 cluster/images/cometbft-watchdog/restart-watchdog.py create mode 100644 cluster/pulumi/canton-network/tsconfig.eslint.json create mode 100644 cluster/pulumi/circleci/tsconfig.eslint.json create mode 100644 cluster/pulumi/cluster/tsconfig.eslint.json create mode 100644 cluster/pulumi/common-sv/src/hourly_append.sql create mode 100644 cluster/pulumi/common-sv/tsconfig.eslint.json create mode 100644 cluster/pulumi/common-validator/tsconfig.eslint.json delete mode 100644 cluster/pulumi/common/src/config/cloudSql.ts create mode 100644 cluster/pulumi/common/src/config/database.ts delete mode 100644 cluster/pulumi/common/src/config/hyperdiskSupportConfig.ts create mode 100644 cluster/pulumi/common/src/ratelimit/envoyRateLimiter.test.ts create mode 100644 cluster/pulumi/common/src/ratelimit/rateLimitHeaders.ts create mode 100644 cluster/pulumi/common/src/ratelimit/rateLimitSchema.test.ts create mode 100644 cluster/pulumi/common/tsconfig.eslint.json create mode 100644 cluster/pulumi/deployment/tsconfig.eslint.json create mode 100644 cluster/pulumi/gcp/tsconfig.eslint.json create mode 100644 cluster/pulumi/gha/tsconfig.eslint.json create mode 100644 cluster/pulumi/infra/src/whitelisting/gateway.ts create mode 100644 cluster/pulumi/infra/src/whitelisting/index.ts create mode 100644 cluster/pulumi/infra/src/whitelisting/ipRanges.ts create mode 100644 cluster/pulumi/infra/src/whitelisting/policies.ts create mode 100644 cluster/pulumi/infra/src/whitelisting/publicInfo.ts create mode 100644 cluster/pulumi/infra/src/whitelisting/publicTokenRegistry.ts create mode 100644 cluster/pulumi/infra/src/whitelisting/scanAndSvApp.ts create mode 100644 cluster/pulumi/infra/src/whitelisting/sequencer.ts create mode 100644 cluster/pulumi/infra/src/whitelisting/svPublicEndpoints.test.ts create mode 100644 cluster/pulumi/infra/src/whitelisting/svPublicEndpoints.ts create mode 100644 cluster/pulumi/infra/sweet.ts create mode 100644 cluster/pulumi/infra/tsconfig.eslint.json create mode 100644 cluster/pulumi/multi-validator/tsconfig.eslint.json create mode 100644 cluster/pulumi/observability/grafana-alerting/global-sync-health_alerts.yaml create mode 100644 cluster/pulumi/observability/grafana-alerting/istio-rate-limiting_alerts.yaml create mode 100644 cluster/pulumi/observability/grafana-alerting/scan_bft_sequencers_alerts.yaml create mode 100644 cluster/pulumi/observability/grafana-alerting/splice-rate-limiting_alerts.yaml rename {canton/community/app/src/pack/examples/13-observability/grafana/dashboards/Canton => cluster/pulumi/observability/grafana-dashboards/canton-bft}/bft-ordering-performance.json (92%) rename {canton/community/app/src/pack/examples/13-observability/grafana/dashboards/Canton => cluster/pulumi/observability/grafana-dashboards/canton-bft}/bft-ordering.json (93%) create mode 100644 cluster/pulumi/observability/grafana-dashboards/canton-network/treasury-service.json create mode 100644 cluster/pulumi/observability/grafana-dashboards/platform/istio.json create mode 100644 cluster/pulumi/observability/tsconfig.eslint.json create mode 100644 cluster/pulumi/operator/src/config.ts create mode 100644 cluster/pulumi/operator/tsconfig.eslint.json create mode 100644 cluster/pulumi/policies/tsconfig.eslint.json create mode 100644 cluster/pulumi/splitwell/tsconfig.eslint.json create mode 100644 cluster/pulumi/sv-canton/tsconfig.eslint.json create mode 100644 cluster/pulumi/sv-runbook/tsconfig.eslint.json create mode 100644 cluster/pulumi/sv/tsconfig.eslint.json create mode 100644 cluster/pulumi/tsconfig.eslint.json create mode 100644 cluster/pulumi/validator-runbook/tsconfig.eslint.json create mode 100644 cluster/pulumi/validator1/tsconfig.eslint.json delete mode 100755 cluster/scripts/monitor-sv-catchup.sh create mode 100644 daml/daml-ide-mono/README.md create mode 100644 daml/daml-ide-mono/daml.yaml create mode 100644 project/ProtocNixPlugin.scala create mode 100644 project/ignore-patterns/canton-standalone-cancel-global-synchronizer-upgrade.ignore.txt delete mode 100644 project/ignore-patterns/canton-standalone-mediator-offboarding_before_shutdown.ignore.txt delete mode 100644 project/ignore-patterns/canton-standalone-sv4-reonboarding_before_shutdown.ignore.txt create mode 100755 scripts/monthly-schedule.py create mode 100755 scripts/setup-mono-package.sh create mode 100755 scripts/test-postgres-migration-k8s.py create mode 100755 scripts/test-postgres-migration.py delete mode 100644 test-full-class-names-canton-enterprise.log diff --git a/.envrc b/.envrc index 17a303ba34..115b68cb32 100644 --- a/.envrc +++ b/.envrc @@ -7,22 +7,13 @@ use_flake_subdir() { watch_file $file done mkdir -p "$(direnv_layout_dir)" - if [ -z "$ARTIFACTORY_PASSWORD" ] || [ "${OSS_ONLY:-}" == 1 ] || [ "${OSS_ONLY:-}" == "true" ]; then - eval "$(nix print-dev-env --profile "$(direnv_layout_dir)/flake-profile" "path:nix#oss" "$@")" - else - eval "$(nix print-dev-env --profile "$(direnv_layout_dir)/flake-profile" "path:nix" "$@")" - fi + eval "$(nix print-dev-env --profile "$(direnv_layout_dir)/flake-profile" "path:nix" "$@")" } source_envrc_private() { [[ -f .envrc.private ]] && [[ -z "$IGNORE_PRIVATE_ENVRC" ]] && source_env .envrc.private || true } -# Source .envrc.private before use_flake_subdir because it provides ARTIFACTORY_PASSWORD -# which determines whether the enterprise or OSS nix shell is used. -# This workaround can be removed once the enterprise edition is removed. -source_envrc_private - # TODO(DACH-NY/canton-network-node#3876) work around for $TMPDIR is removed. #3876 to investigate more OLD_TMPDIR=${TMPDIR-unset} @@ -42,8 +33,6 @@ source "${TOOLS_LIB}/libcli.source" source_env .envrc.validate -export ENTERPRISE_ARTIFACTORY_DOCKER_REGISTRY=digitalasset-canton-enterprise-docker.jfrog.io - # Configure docker access for cluster and integration tests ## Read current credentials function read_docker_creds() { @@ -58,13 +47,6 @@ function read_docker_creds() { fi } -function check_docker_creds() { - local docker_creds="$1" - [[ -z "$docker_creds" || "$docker_creds" == "null" || "$docker_creds" != "$ARTIFACTORY_USER:$ARTIFACTORY_PASSWORD" ]] && \ - ## And artifactory user and password are set - [[ -n "$ARTIFACTORY_USER" && -n "$ARTIFACTORY_PASSWORD" ]] -} - function check_docker_creds_ghcr() { local docker_creds="$1" [[ -z "$docker_creds" || "$docker_creds" == "null" || "$docker_creds" != "$GH_USER:$GH_TOKEN" ]] && \ @@ -77,12 +59,6 @@ if check_docker_creds_ghcr "$DOCKER_CREDS"; then echo $GH_TOKEN | docker login "$GHCR" -u "$GH_USER" --password-stdin fi -DOCKER_CREDS=$(read_docker_creds "$ENTERPRISE_ARTIFACTORY_DOCKER_REGISTRY") -if check_docker_creds "$DOCKER_CREDS"; then - echo "Logging into $ENTERPRISE_ARTIFACTORY_DOCKER_REGISTRY" - echo $ARTIFACTORY_PASSWORD | docker login "$ENTERPRISE_ARTIFACTORY_DOCKER_REGISTRY" -u "$ARTIFACTORY_USER" --password-stdin -fi - # re-export CIRCLECI_TOKEN (which we use in many places) as CIRCLECI_CLI_TOKEN (which `circleci` cli tool picks up) export CIRCLECI_CLI_TOKEN="${CIRCLECI_TOKEN}" diff --git a/.envrc.validate b/.envrc.validate index 4a73ce2303..f326e2da30 100644 --- a/.envrc.validate +++ b/.envrc.validate @@ -34,12 +34,5 @@ if [[ -z "$IGNORE_PRIVATE_ENVRC" ]]; then AUTH0_VALIDATOR_MANAGEMENT_API_CLIENT_ID \ AUTH0_VALIDATOR_MANAGEMENT_API_CLIENT_SECRET \ AUTH0_TESTS_MANAGEMENT_API_CLIENT_ID \ - AUTH0_TESTS_MANAGEMENT_API_CLIENT_SECRET \ - ARTIFACTORY_USER \ - ARTIFACTORY_PASSWORD -fi - -if [ "$IS_ENTERPRISE" != "true" ]; then - echo "" - _warning "Note: Using Canton community instead of Canton enterprise. Certain tests which rely on Enterprise features will fail locally." + AUTH0_TESTS_MANAGEMENT_API_CLIENT_SECRET fi diff --git a/.envrc.vars b/.envrc.vars index 69864a4ec1..70783df4dd 100644 --- a/.envrc.vars +++ b/.envrc.vars @@ -105,7 +105,7 @@ export SPLICE_OAUTH_SV_TEST_CLIENT_ID_VALIDATOR=bUfFRpl2tEfZBB7wzIo9iRNGTj8wMeIn export USE_GKE_GCLOUD_AUTH_PLUGIN=true # CometBFT settings -export COMETBFT_DOCKER_IMAGE="digitalasset-canton-enterprise-docker.jfrog.io/cometbft-canton-network:${COMETBFT_RELEASE_VERSION}" +export COMETBFT_DOCKER_IMAGE="europe-docker.pkg.dev/da-images/public/docker/cometbft-canton-network:${COMETBFT_RELEASE_VERSION}" #Test containers config ## Speed up runs diff --git a/.github/actions/nix/setup_nix/action.yml b/.github/actions/nix/setup_nix/action.yml index 78920272cf..9ba47061c8 100644 --- a/.github/actions/nix/setup_nix/action.yml +++ b/.github/actions/nix/setup_nix/action.yml @@ -1,19 +1,14 @@ name: "Setup Nix" description: "Setup Nix" inputs: - artifactory_user: - description: "The Artifactory user" - required: true - artifactory_password: - description: "The Artifactory password" - required: true nix_path: description: "The path to nix flake directory" required: false default: ${{ format('{0}/nix', github.repository == 'DACH-NY/canton-network-internal' && 'splice' || '.') }} target: - description: "Choose nix target: oss - restrict upstream dependencies (e.g. Canton) to OSS versions (the equivalent of OSS_ONLY=1 in local checkouts), static_tests - for static tests, default - for enterprise dependencies" - required: true + description: "Choose nix target: static_tests - for static tests, default - for full dependencies" + required: false + default: "default" cache_version: description: "Cache version" required: true @@ -37,12 +32,12 @@ runs: using: "composite" steps: - name: Validate input - if: "${{!( inputs.target == 'default' || inputs.target == 'oss' || inputs.target == 'static_tests')}}" + if: "${{!( inputs.target == 'default' || inputs.target == 'static_tests')}}" shell: bash id: validate_input run: | echo "Target invalid: ${{ inputs.target }}" - echo "Target needs to be one of: 'default', 'oss', 'static_tests'" + echo "Target needs to be one of: 'default', 'static_tests'" exit 1 - name: Compute cache Key id: cache_key @@ -60,8 +55,8 @@ runs: echo "home: $HOME" >> /tmp/nix-cache-key # important when restoring simlinks from cache, apparently echo "nix binary version: $NIX_BINARY_VERSION" >> /tmp/nix-cache-key # different nix versions might behave differently and corrupt the caches echo "target: ${{ inputs.target }}" >> /tmp/nix-cache-key - if [ "${{ inputs.target }}" != 'default' ]; then - echo "Using OSS only dependencies" + if [ "${{ inputs.target }}" == 'static_tests' ]; then + echo "Using minimal nix dependencies for static tests" fi cat /tmp/nix-cache-key cache_key=($(md5sum "/tmp/nix-cache-key")) @@ -76,11 +71,6 @@ runs: run: | set -euxo pipefail - if [[ ${{ inputs.target }} == 'default' ]]; then - echo "Must use OSS only dependencies in GitHub-hosted runners" - exit 1 - fi - echo "Latest nix cache:" wget -q "https://storage.googleapis.com/splice-nix-cache-public/${cache_key}.tar.gz" -O cache.tar.gz || true @@ -143,15 +133,6 @@ runs: sh <(curl -fsSL --retry 8 "https://releases.nixos.org/nix/nix-$NIX_BINARY_VERSION/install") --no-daemon sudo mkdir -p /etc/nix sudo chmod a+rw /etc/nix - if [[ "${{ inputs.target }}" != 'default' ]]; then - echo "Using OSS only dependencies, not setting up Artifactory credentials" - else - cat < /etc/nix/netrc - machine digitalasset.jfrog.io - login ${{ inputs.artifactory_user }} - password ${{ inputs.artifactory_password }} - EOF - fi export USER=$(whoami) echo "Running nix.sh" . ~/.nix-profile/etc/profile.d/nix.sh diff --git a/.github/actions/sbt/execute_sbt_command/action.yml b/.github/actions/sbt/execute_sbt_command/action.yml index 1bb02c8083..295450f584 100644 --- a/.github/actions/sbt/execute_sbt_command/action.yml +++ b/.github/actions/sbt/execute_sbt_command/action.yml @@ -7,12 +7,6 @@ inputs: # The caller needs to quote commands that contain spaces, e.g. "\"testOnly myTest\"". description: "The SBT command to run" required: true - artifactory_user: - description: "Artifactory user" - required: false - artifactory_password: - description: "Artifactory password" - required: false extra_env_vars: description: "Extra environment variables to set before running the SBT command" required: false @@ -51,7 +45,7 @@ runs: - name: Execute SBT command" uses: ./.github/actions/nix/run_bash_command_in_nix with: - additional_nix_args: "--keep GITHUB_ACTION_PATH --keep ARTIFACTORY_USER --keep ARTIFACTORY_PASSWORD ${{ inputs.additional_nix_args }}" + additional_nix_args: "--keep GITHUB_ACTION_PATH ${{ inputs.additional_nix_args }}" cmd: | # This might help resolve https://github.com/DACH-NY/canton-network-node/issues/8146 export PROTOCBRIDGE_NO_CLEANUP="1" @@ -91,8 +85,6 @@ runs: $GITHUB_ACTION_PATH/../../scripts/check-sbt-output.sh "sbt_output" fi } - export ARTIFACTORY_USER="${{ inputs.artifactory_user }}" - export ARTIFACTORY_PASSWORD="${{ inputs.artifactory_password }}" # Ensure that we're in the root of splice before execution pushd ${{ inputs.splice_root }} &> /dev/null diff --git a/.github/actions/tests/common_test_setup/action.yml b/.github/actions/tests/common_test_setup/action.yml index 7f38144eca..c405f8d369 100644 --- a/.github/actions/tests/common_test_setup/action.yml +++ b/.github/actions/tests/common_test_setup/action.yml @@ -16,14 +16,8 @@ inputs: description: "Whether to save the Nix cache to GCP" required: false default: "false" - artifactory_user: - description: "The Artifactory user" - required: false - artifactory_password: - description: "The Artifactory password" - required: false target: - description: "Choose nix target: oss - restrict upstream dependencies (e.g. Canton) to OSS versions (the equivalent of OSS_ONLY=1 in local checkouts), static_tests - for static tests, default - for enterprise dependencies" + description: "Choose nix target: static_tests - for static tests, default - for full dependencies" default: 'default' # type: choice # options: @@ -51,12 +45,6 @@ outputs: runs: using: "composite" steps: - - name: Validate input - if: inputs.target == 'default' && (inputs.artifactory_password == '' || inputs.artifactory_user == '') - shell: bash - run: | - echo "artifactory_user and artifactory_password must be provided if not using OSS only dependencies." - exit 1 - name: Publish test name metric uses: miguelteixeiraa/action-run-in-background@e28f036c202e9066287e6a50ce8b80749627cc7d # v1.0.0 @@ -70,8 +58,6 @@ runs: - name: Set up Nix (Self hosted) uses: ./.github/actions/nix/setup_nix with: - artifactory_user: ${{ inputs.artifactory_user }} - artifactory_password: ${{ inputs.artifactory_password }} cache_version: ${{ inputs.cache_version }} should_save: ${{ inputs.save_nix_cache }} should_save_gcp: ${{ inputs.save_nix_cache_to_gcp }} diff --git a/.github/actions/tests/scala_test/action.yml b/.github/actions/tests/scala_test/action.yml index 728593c162..ac022f3b5b 100644 --- a/.github/actions/tests/scala_test/action.yml +++ b/.github/actions/tests/scala_test/action.yml @@ -8,12 +8,6 @@ inputs: start_canton_options: description: "Options for start-canton.sh" required: true - artifactory_user: - description: "The Artifactory user" - required: true - artifactory_password: - description: "The Artifactory password" - required: true test_suite_name: description: "Name of the test suite" required: true @@ -93,10 +87,6 @@ inputs: protocol_version: description: "Synchronizer Protocol Version" required: true - oss_only: - description: "Restrict upstream dependencies (e.g. Canton) to OSS versions (the equivalent of OSS_ONLY=1 in local checkouts)" - required: false - default: "false" cache_version: description: "Cache version" required: true @@ -109,12 +99,7 @@ runs: with: test_name: ${{ inputs.test_suite_name }} with_sbt: false # we setup SBT later while canton is starting up - artifactory_user: ${{ inputs.artifactory_user }} - artifactory_password: ${{ inputs.artifactory_password }} - target: ${{ inputs.oss_only == true && 'oss' || 'default' }} - # The docs job saves the oss nix cache, here we save the non-oss one, but only in one runner to reduce contention - # TODO(#1296): When this runner stops using non-oss, move this to one that does - save_nix_cache: ${{ inputs.runner_index == 0 && inputs.test_suite_name == 'canton-enterprise' }} + target: 'default' - name: Wait for postgres uses: ./.github/actions/nix/run_bash_command_in_nix @@ -230,8 +215,6 @@ runs: uses: ./.github/actions/nix/run_bash_command_in_nix with: cmd: | - export ARTIFACTORY_USER="${{ inputs.artifactory_user }}" - export ARTIFACTORY_PASSWORD="${{ inputs.artifactory_password }}" export CIRCLE_REPOSITORY_URL="${{ github.repositoryUrl }}" export CIRCLE_SHA1="${{ github.sha }}" /usr/bin/sudo mkdir -p ~/.docker/buildx @@ -258,8 +241,6 @@ runs: uses: ./.github/actions/sbt/execute_sbt_command with: extra_env_vars: "POSTGRES_DB=postgres POSTGRES_HOST=localhost POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres INITIAL_PACKAGE_VERSIONS=${{ steps.daml_package_versions.outputs.initial_package_versions }} PROTOCOL_VERSION=${{ inputs.protocol_version }}" - artifactory_user: ${{ inputs.artifactory_user }} - artifactory_password: ${{ inputs.artifactory_password }} cmd: ${{ steps.list_tests.outputs.RUN_SPLITTED_TESTS_CMD }} additional_nix_args: "--keep GITHUB_ACTION" extra_parameters: -DAUTH0_MANAGEMENT_API_CLIENT_ID=${{ inputs.auth0_management_api_client_id }} -DAUTH0_MANAGEMENT_API_CLIENT_SECRET=${{ inputs.auth0_management_api_client_secret }} diff --git a/.github/actions/tests/skip_on_static/action.yml b/.github/actions/tests/skip_on_static/action.yml index 73e3b6f0d0..2111eb9689 100644 --- a/.github/actions/tests/skip_on_static/action.yml +++ b/.github/actions/tests/skip_on_static/action.yml @@ -24,10 +24,17 @@ runs: # as the latter is fixed when the job starts which for `env_hold` jobs # is _before_ the approval already e.g. when an external contributor # created the PR and not when the maintainer approved it after adding the static label. - pr_labels=$(curl -sSL --fail-with-body -H "Authorization: Bearer ${{ inputs.gh_token }}" \ - --retry 10 --retry-delay 10 --retry-all-errors \ - -H "Accept: application/vnd.github.v3+json" \ - "${{ github.event.pull_request.url }}" | jq '.labels') + # Write the response to a file instead of piping into jq: curl resets an + # -o output file between retries, but a pipe keeps the failed-attempt + # bodies (breaking jq) and dies once jq exits (curl error 23). + pr_json="$RUNNER_TEMP/pr.json" + curl -sSL --fail-with-body -o "$pr_json" \ + -H "Authorization: Bearer ${{ inputs.gh_token }}" \ + --retry 10 --retry-delay 10 --retry-all-errors \ + -H "Accept: application/vnd.github.v3+json" \ + "${{ github.event.pull_request.url }}" \ + || { echo "PR fetch failed; last response body:"; cat "$pr_json"; exit 1; } + pr_labels=$(jq '.labels' "$pr_json") echo "Pull request labels: $pr_labels" static_label=$(echo "$pr_labels" | jq -r '.[] | select(.name == "static") | .name' | grep -c 'static' || true) if [[ "$last_commit_msg" == *"[static]"* ]] || [[ "$static_label" -gt 0 ]]; then diff --git a/.github/runners/runner-container-hooks b/.github/runners/runner-container-hooks index 32b74210ef..8472c0f706 160000 --- a/.github/runners/runner-container-hooks +++ b/.github/runners/runner-container-hooks @@ -1 +1 @@ -Subproject commit 32b74210efb8c288ffd9e994ee6a90337ce156d3 +Subproject commit 8472c0f7060e0f2d956185eace05812c33bacbb5 diff --git a/.github/store-perf-thresholds.json b/.github/store-perf-thresholds.json index b69ca37bc3..aa2a4b14fc 100644 --- a/.github/store-perf-thresholds.json +++ b/.github/store-perf-thresholds.json @@ -1,52 +1,52 @@ { "SvDsoStoreIngestionPerformanceTest": { - "_comment_avg_item_time_ns": "median*(1+ noise_margin + safety_margin) of last 30d. noise_margin=(p95-median)/median, safety_margin=10%. calculated on 2026-May-07", + "_comment_avg_item_time_ns": "median*(1+ noise_margin + safety_margin) of last 30d. noise_margin=(p95-median)/median, safety_margin=10%. calculated on 2026-June-25", "splice_perf_ingestion_avg_item_time_ns": { - "max": 2500000 + "max": 3700000 }, - "_comment_total_time_ns": "our hard max is 1h, but we set it to 10m based on the past data collected on 2026-May-07 to detect the trends earlier", + "_comment_total_time_ns": "our hard max is 1h, but we set it to 10m based on the past data collected on 2026-June-25 to detect the trends earlier", "splice_perf_ingestion_total_time_ns": { "max": 600000000000 } }, "ScanStoreIngestionPerformanceTest": { - "_comment_avg_item_time_ns": "median*(1+ noise_margin + safety_margin) of last 30d. noise_margin=(p95-median)/median, safety_margin=10%. calculated on 2026-May-07", + "_comment_avg_item_time_ns": "median*(1+ noise_margin + safety_margin) of last 30d. noise_margin=(p95-median)/median, safety_margin=10%. calculated on 2026-June-25", "splice_perf_ingestion_avg_item_time_ns": { - "max": 2400000 + "max": 3000000 }, - "_comment_total_time_ns": "our hard max is 1h, but we set it to 10m based on the past data collected on 2026-May-07 to detect the trends earlier", + "_comment_total_time_ns": "our hard max is 1h, but we set it to 10m based on the past data collected on 2026-June-25 to detect the trends earlier", "splice_perf_ingestion_total_time_ns": { "max": 600000000000 } }, "UpdateHistoryIngestionPerformanceTest": { - "_comment_avg_item_time_ns": "median*(1+ noise_margin + safety_margin) of last 30d. noise_margin=(p95-median)/median, safety_margin=10%. calculated on 2026-May-07", + "_comment_avg_item_time_ns": "median*(1+ noise_margin + safety_margin) of last 30d. noise_margin=(p95-median)/median, safety_margin=10%. calculated on 2026-June-25", "splice_perf_ingestion_avg_item_time_ns": { - "max": 16800000 + "max": 27400000 }, - "_comment_total_time_ns": "our hard max is 1h, but we set it to 20m based on the past data collected on 2026-May-07 to detect the trends earlier", + "_comment_total_time_ns": "our hard max is 1h, but we set it to 20m based on the past data collected on 2026-June-25 to detect the trends earlier", "splice_perf_ingestion_total_time_ns": { "max": 1200000000000 } }, "UpdateHistoryReadPerformanceTest-getUpdate": { - "_comment_avg_item_time_ns": "median*(1+ noise_margin + safety_margin) of last 30d. noise_margin=(p95-median)/median, safety_margin=10%. calculated on 2026-May-07", + "_comment_avg_item_time_ns": "median*(1+ noise_margin + safety_margin) of last 30d. noise_margin=(p95-median)/median, safety_margin=10%. calculated on 2026-June-25", "splice_perf_read_avg_item_time_ns": { - "max": 753600000 + "max": 775400000 }, "_comment_total_time_ns": "hard max same as avg_item_time_ns, as we read 1 item", "splice_perf_read_total_time_ns": { - "max": 753600000 + "max": 775400000 } }, "UpdateHistoryReadPerformanceTest-encodeUpdate": { - "_comment_avg_item_time_ns": "median*(1+ noise_margin + safety_margin) of last 30d. noise_margin=(p95-median)/median, safety_margin=10%. calculated on 2026-May-07", + "_comment_avg_item_time_ns": "median*(1+ noise_margin + safety_margin) of last 30d. noise_margin=(p95-median)/median, safety_margin=10%. calculated on 2026-June-25", "splice_perf_read_avg_item_time_ns": { - "max": 337000000 + "max": 397400000 }, "_comment_total_time_ns": "hard max same as avg_item_time_ns, as we read 1 item", "splice_perf_read_total_time_ns": { - "max": 337000000 + "max": 397400000 } } } diff --git a/.github/workflows/build.daml_test.yml b/.github/workflows/build.daml_test.yml index 0ef5f6828c..016ba8c4d5 100644 --- a/.github/workflows/build.daml_test.yml +++ b/.github/workflows/build.daml_test.yml @@ -34,7 +34,6 @@ jobs: with: cache_version: 8 test_name: daml_test - target: 'oss' - name: Run Daml tests if: steps.skip.outputs.skip != 'true' diff --git a/.github/workflows/build.deployment_test.yml b/.github/workflows/build.deployment_test.yml index 80f4e14e0b..e7705bb0ed 100644 --- a/.github/workflows/build.deployment_test.yml +++ b/.github/workflows/build.deployment_test.yml @@ -25,10 +25,9 @@ jobs: - name: Setup uses: ./.github/actions/tests/common_test_setup with: - cache_version: 8 + cache_version: 9 test_name: deployment_test with_sbt: false - target: 'oss' - name: Helm tests uses: ./.github/actions/nix/run_bash_command_in_nix diff --git a/.github/workflows/build.docs.yml b/.github/workflows/build.docs.yml index 49c5e6c996..362a4cf039 100644 --- a/.github/workflows/build.docs.yml +++ b/.github/workflows/build.docs.yml @@ -25,19 +25,16 @@ jobs: id: setup uses: ./.github/actions/tests/common_test_setup with: - cache_version: 8 + cache_version: 9 test_name: docs save_nix_cache: true save_nix_cache_to_gcp: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }} - target: 'oss' upload_workload_identity_provider: ${{ secrets.GOOGLE_WORKLOAD_IDENTITY_PROVIDER_SPLICE }} upload_service_account: ${{ secrets.CACHE_UPLOADER_SA }} - name: Build docs uses: ./.github/actions/sbt/execute_sbt_command with: - artifactory_user: ${{ vars.ARTIFACTORY_USER }} - artifactory_password: ${{ secrets.ARTIFACTORY_PASSWORD }} # We Test/compile here as the docs job is the one that pushes to the SBT cache # as it finishes fastest and we want to ensure that it includes test sources. cmd: "Test/compile docs/bundle" @@ -51,7 +48,7 @@ jobs: - name: Post-SBT job uses: ./.github/actions/sbt/post_sbt with: - cache_version: 8 + cache_version: 9 setup_sbt_cache_hits: ${{ steps.setup.outputs.sbt_cache_hits }} - name: Report Failures on Slack & Github diff --git a/.github/workflows/build.scala_test.yml b/.github/workflows/build.scala_test.yml index 8ec6727935..5f0434d110 100644 --- a/.github/workflows/build.scala_test.yml +++ b/.github/workflows/build.scala_test.yml @@ -57,11 +57,6 @@ on: description: "Canton protocol version" type: string required: true - oss_only: - description: "Restrict upstream dependencies (e.g. Canton) to OSS versions (the equivalent of OSS_ONLY=1 in local checkouts)" - required: false - type: boolean - default: false postgres_image: description: "The image to use for PostgreSQL" required: false @@ -142,7 +137,7 @@ jobs: - name: Run Tests uses: ./.github/actions/tests/scala_test with: - cache_version: 8 + cache_version: 9 with_canton: ${{ inputs.with_canton }} start_canton_options: ${{ inputs.start_canton_options }} test_suite_name: ${{ inputs.test_name }} @@ -162,6 +157,3 @@ jobs: failure_notifications_slack_channel: ${{ secrets.FAILURE_NOTIFICATIONS_SLACK_CHANNEL }} daml_base_version: ${{ inputs.daml_base_version }} protocol_version: ${{ inputs.protocol_version }} - oss_only: ${{ inputs.oss_only }} - artifactory_user: ${{ inputs.oss_only && '' || vars.ARTIFACTORY_USER }} - artifactory_password: ${{ inputs.oss_only && '' || secrets.ARTIFACTORY_PASSWORD }} diff --git a/.github/workflows/build.scala_test_for_compose.yml b/.github/workflows/build.scala_test_for_compose.yml index 45b45b90df..b348d7723c 100644 --- a/.github/workflows/build.scala_test_for_compose.yml +++ b/.github/workflows/build.scala_test_for_compose.yml @@ -104,10 +104,8 @@ jobs: - name: Run Tests uses: ./.github/actions/tests/scala_test with: - cache_version: 8 + cache_version: 9 start_canton_options: ${{ inputs.start_canton_options }} - artifactory_user: ${{ vars.ARTIFACTORY_USER }} - artifactory_password: ${{ secrets.ARTIFACTORY_PASSWORD }} test_suite_name: ${{ inputs.test_name }} test_names: ${{ needs.split_tests.outputs.test_names }} runner_index: ${{ matrix.runner-index }} diff --git a/.github/workflows/build.scala_test_with_cometbft.yml b/.github/workflows/build.scala_test_with_cometbft.yml index b8f738c6a1..6434e79ee7 100644 --- a/.github/workflows/build.scala_test_with_cometbft.yml +++ b/.github/workflows/build.scala_test_with_cometbft.yml @@ -120,10 +120,8 @@ jobs: - name: Run Tests uses: ./.github/actions/tests/scala_test with: - cache_version: 8 + cache_version: 9 start_canton_options: -F -w - artifactory_user: ${{ vars.ARTIFACTORY_USER }} - artifactory_password: ${{ secrets.ARTIFACTORY_PASSWORD }} test_suite_name: ${{ inputs.test_name }} test_names: ${{ needs.split_tests.outputs.test_names }} runner_index: ${{ matrix.runner-index }} diff --git a/.github/workflows/build.static_tests.yml b/.github/workflows/build.static_tests.yml index 5690f886c8..1c0a138cca 100644 --- a/.github/workflows/build.static_tests.yml +++ b/.github/workflows/build.static_tests.yml @@ -13,15 +13,9 @@ on: type: boolean required: false default: false - oss_only: - type: boolean - required: false - default: false jobs: check_canton_consistency: - # Skipped for external contributors (fork PRs) due to container registry auth constraints - if: ${{ !inputs.oss_only }} runs-on: ${{ inputs.self_hosted && 'self-hosted-k8s-x-small' || 'ubuntu-24.04' }} timeout-minutes: 15 container: @@ -37,7 +31,7 @@ jobs: - name: Setup Nix uses: ./.github/actions/tests/common_test_setup with: - cache_version: 8 + cache_version: 9 test_name: canton_consistency target: 'static_tests' @@ -49,6 +43,9 @@ jobs: CURRENT_VERSION=$(jq -r '.version' nix/canton-sources.json) echo "Validating Docker digests for Canton version: $CURRENT_VERSION" + # TODO(#6581) to be removed + export CONTAINERS_REGISTRIES_CONF=$(mktemp) + function set_value() { local key="$1" local value="$2" @@ -58,7 +55,7 @@ jobs: for img in base participant mediator sequencer; do echo "Fetching image sha256 for canton-$img..." - sha=$(skopeo inspect --override-os linux --override-arch amd64 "docker://europe-docker.pkg.dev/da-images/public-all/docker/canton-$img:${CURRENT_VERSION}" --format '{{.Digest}}') + sha=$(skopeo inspect --no-creds --override-os linux --override-arch amd64 "docker://europe-docker.pkg.dev/da-images/public-all/docker/canton-$img:${CURRENT_VERSION}" --format '{{.Digest}}') set_value "canton_${img}_image_sha256" "$sha" done @@ -90,7 +87,7 @@ jobs: id: setup uses: ./.github/actions/tests/common_test_setup with: - cache_version: 8 + cache_version: 9 test_name: static_tests target: 'static_tests' diff --git a/.github/workflows/build.ts_cli_tests.yml b/.github/workflows/build.ts_cli_tests.yml index 6b0904cffd..1ddf0f3bf9 100644 --- a/.github/workflows/build.ts_cli_tests.yml +++ b/.github/workflows/build.ts_cli_tests.yml @@ -41,9 +41,8 @@ jobs: if: steps.skip.outputs.skip != 'true' uses: ./.github/actions/tests/common_test_setup with: - cache_version: 8 + cache_version: 9 test_name: ts_cli - target: 'oss' - name: Run Token Standard CLI tests if: steps.skip.outputs.skip != 'true' diff --git a/.github/workflows/build.ui_tests.yml b/.github/workflows/build.ui_tests.yml index 33ebfda8f5..aa1b5b5855 100644 --- a/.github/workflows/build.ui_tests.yml +++ b/.github/workflows/build.ui_tests.yml @@ -41,9 +41,8 @@ jobs: if: steps.skip.outputs.skip != 'true' uses: ./.github/actions/tests/common_test_setup with: - cache_version: 8 + cache_version: 9 test_name: ui_tests - target: 'oss' - name: Run UI tests if: steps.skip.outputs.skip != 'true' @@ -63,7 +62,7 @@ jobs: if: steps.skip.outputs.skip != 'true' uses: ./.github/actions/sbt/post_sbt with: - cache_version: 8 + cache_version: 9 setup_sbt_cache_hits: ${{ steps.setup.outputs.sbt_cache_hits }} - name: Upload logs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4676256eed..5dac6847c7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,11 +17,6 @@ on: type: string required: false default: "" - oss_only: - description: "Restrict upstream dependencies (e.g. Canton) to OSS versions (skips tests that do not support running in OSS)" - required: false - type: boolean - default: false workflow_dispatch: inputs: commit_sha: @@ -39,11 +34,6 @@ on: type: string required: false default: "35" - oss_only: - description: "Restrict upstream dependencies (e.g. Canton) to OSS versions (skips tests that do not support running in OSS)" - required: false - type: boolean - default: false permissions: id-token: write # Required for GCP Workload Identity for failure notifications @@ -93,7 +83,6 @@ jobs: with: commit_sha: ${{ inputs.commit_sha }} self_hosted: true - oss_only: ${{ inputs.oss_only }} deployment_test: uses: ./.github/workflows/build.deployment_test.yml @@ -118,7 +107,6 @@ jobs: commit_sha: ${{ inputs.commit_sha }} daml_base_version: ${{ inputs.daml_base_version }} protocol_version: ${{ inputs.protocol_version }} - oss_only: ${{ inputs.oss_only }} secrets: inherit scala_test_resource_intensive: @@ -132,7 +120,6 @@ jobs: commit_sha: ${{ inputs.commit_sha }} daml_base_version: ${{ inputs.daml_base_version }} protocol_version: ${{ inputs.protocol_version }} - oss_only: ${{ inputs.oss_only }} secrets: inherit scala_test_record_time_tolerance: @@ -146,7 +133,6 @@ jobs: commit_sha: ${{ inputs.commit_sha }} daml_base_version: ${{ inputs.daml_base_version }} protocol_version: ${{ inputs.protocol_version }} - oss_only: ${{ inputs.oss_only }} secrets: inherit scala_test_logical_sync_upgrade: @@ -160,7 +146,6 @@ jobs: commit_sha: ${{ inputs.commit_sha }} daml_base_version: ${{ inputs.daml_base_version }} protocol_version: ${{ inputs.protocol_version }} - oss_only: ${{ inputs.oss_only }} secrets: inherit scala_test_roll_forward_lsu: @@ -174,12 +159,10 @@ jobs: commit_sha: ${{ inputs.commit_sha }} daml_base_version: ${{ inputs.daml_base_version }} protocol_version: ${{ inputs.protocol_version }} - oss_only: ${{ inputs.oss_only }} secrets: inherit scala_test_with_cometbft: uses: ./.github/workflows/build.scala_test_with_cometbft.yml - if: ${{ ! inputs.oss_only }} with: runs_on: self-hosted-k8s-medium test_names_file: "test-cometbft-full-class-names.log" @@ -202,7 +185,6 @@ jobs: commit_sha: ${{ inputs.commit_sha }} daml_base_version: ${{ inputs.daml_base_version }} protocol_version: ${{ inputs.protocol_version }} - oss_only: ${{ inputs.oss_only }} secrets: inherit scala_test_wall_clock_time: @@ -219,7 +201,6 @@ jobs: commit_sha: ${{ inputs.commit_sha }} daml_base_version: ${{ inputs.daml_base_version }} protocol_version: ${{ inputs.protocol_version }} - oss_only: ${{ inputs.oss_only }} secrets: inherit scala_test_frontend_wall_clock_time: @@ -234,7 +215,6 @@ jobs: commit_sha: ${{ inputs.commit_sha }} daml_base_version: ${{ inputs.daml_base_version }} protocol_version: ${{ inputs.protocol_version }} - oss_only: ${{ inputs.oss_only }} secrets: inherit scala_test_frontend_simtime: @@ -249,12 +229,10 @@ jobs: commit_sha: ${{ inputs.commit_sha }} daml_base_version: ${{ inputs.daml_base_version }} protocol_version: ${{ inputs.protocol_version }} - oss_only: ${{ inputs.oss_only }} secrets: inherit scala_test_docker_compose: uses: ./.github/workflows/build.scala_test_for_compose.yml - if: ${{ ! inputs.oss_only }} with: runs_on: self-hosted-docker-large test_names_file: 'test-full-class-names-docker-based.log' @@ -268,7 +246,6 @@ jobs: scala_test_with_docker_no_canton: uses: ./.github/workflows/build.scala_test_for_compose.yml - if: ${{ ! inputs.oss_only }} with: runs_on: self-hosted-docker-large test_names_file: 'test-full-class-names-docker-no-canton.log' @@ -283,7 +260,6 @@ jobs: scala_test_with_docker_and_canton_simtime: uses: ./.github/workflows/build.scala_test_for_compose.yml - if: ${{ ! inputs.oss_only }} with: runs_on: self-hosted-docker-large test_names_file: 'test-full-class-names-sim-time-docker.log' @@ -312,22 +288,6 @@ jobs: commit_sha: ${{ inputs.commit_sha }} daml_base_version: ${{ inputs.daml_base_version }} protocol_version: ${{ inputs.protocol_version }} - oss_only: ${{ inputs.oss_only }} - secrets: inherit - - scala_test_canton_enterprise: - uses: ./.github/workflows/build.scala_test.yml - if: ${{ ! inputs.oss_only }} - with: - runs_on: self-hosted-k8s-large - test_names_file: 'test-full-class-names-canton-enterprise.log' - start_canton_options: -w - parallelism: 1 - test_name: canton-enterprise - with_gcp_creds: true - commit_sha: ${{ inputs.commit_sha }} - daml_base_version: ${{ inputs.daml_base_version }} - protocol_version: ${{ inputs.protocol_version }} secrets: inherit ui_tests: @@ -360,7 +320,6 @@ jobs: commit_sha: ${{ inputs.commit_sha }} daml_base_version: ${{ inputs.daml_base_version }} protocol_version: ${{ inputs.protocol_version }} - oss_only: true secrets: inherit final_result: @@ -383,7 +342,6 @@ jobs: - scala_test_with_docker_no_canton - scala_test_with_docker_and_canton_simtime - scala_test_app_upgrade - - scala_test_canton_enterprise - scala_test_without_canton - ui_tests - ts_cli_tests @@ -430,5 +388,4 @@ jobs: uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2 with: jobs: ${{ env.JOBS }} - # Allow skipping the enterprise-only jobs - allowed-skips: scala_test_with_cometbft, scala_test_docker_compose, scala_test_local_net, scala_test_canton_enterprise + allowed-skips: scala_test_with_cometbft, scala_test_docker_compose, scala_test_local_net diff --git a/.github/workflows/bump_gha_runner_version.yml b/.github/workflows/bump_gha_runner_version.yml index abaf4fb34e..b6e3838256 100644 --- a/.github/workflows/bump_gha_runner_version.yml +++ b/.github/workflows/bump_gha_runner_version.yml @@ -27,9 +27,7 @@ jobs: - name: Set up Nix (Self hosted) uses: ./.github/actions/nix/setup_nix with: - cache_version: 8 - artifactory_user: dummy - artifactory_password: dummy + cache_version: 9 target: default - name: Check for the latest version and create a PR to splice diff --git a/.github/workflows/canton_oss_test.yml b/.github/workflows/canton_oss_test.yml deleted file mode 100644 index ee5503e713..0000000000 --- a/.github/workflows/canton_oss_test.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Integration tests against canton oss -on: - schedule: - - cron: '0 4 * * *' - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - id-token: write # Required for GCP Workload Identity for failure notifications - contents: read - pull-requests: read # Required for the static tests - issues: read # Required for the static tests - actions: write # To cancel itself if not opted in - -jobs: - build: - uses: ./.github/workflows/build.yml - with: - oss_only: true - secrets: inherit diff --git a/.github/workflows/daml_compat_test.yml b/.github/workflows/daml_compat_test.yml index 3d01694abe..c051a2a4c3 100644 --- a/.github/workflows/daml_compat_test.yml +++ b/.github/workflows/daml_compat_test.yml @@ -24,7 +24,10 @@ jobs: id: get_mainnet_version run: | set -eou pipefail - version="$(curl -sSL --fail-with-body https://docs.global.canton.network.sync.global/info | jq -r '.sv.version')" + info_json="$RUNNER_TEMP/info.json" + curl -sSL --fail-with-body -o "$info_json" https://docs.global.canton.network.sync.global/info \ + || { echo "Fetch failed; response body:"; cat "$info_json"; exit 1; } + version="$(jq -r '.sv.version' "$info_json")" echo "MainNet version is $version" echo "version=$version" >> "$GITHUB_OUTPUT" @@ -33,6 +36,4 @@ jobs: uses: ./.github/workflows/build.yml with: daml_base_version: ${{ needs.get_mainnet_version.outputs.version }} - # TODO(#5433) - remove after adopted on mainnet - protocol_version: "34" secrets: inherit diff --git a/.github/workflows/monthly-schedule.yml b/.github/workflows/monthly-schedule.yml new file mode 100644 index 0000000000..b10f37d386 --- /dev/null +++ b/.github/workflows/monthly-schedule.yml @@ -0,0 +1,53 @@ +name: Monthly Schedule + +on: + workflow_dispatch: + inputs: + version: + description: "Splice version, e.g. 0.8" + required: true + type: string + + month: + description: "Month in YYYY-MM format, e.g. 2026-08" + required: true + type: string + + dry_run: + description: "Dry run only — do not modify Monday" + required: true + default: true + type: boolean + +permissions: + contents: read + +jobs: + create-schedule: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + + - name: Create monthly schedule + env: + MONDAY_API_TOKEN: ${{ secrets.MONDAY_API_TOKEN }} + MONDAY_BOARD_ID: ${{ secrets.MONDAY_BOARD_ID }} + shell: bash + run: | + ARGS=( + "${{ inputs.version }}" + "${{ inputs.month }}" + ) + + if [[ "${{ inputs.dry_run }}" == "true" ]]; then + ARGS+=("--dry-run") + fi + + python3 scripts/monthly-schedule.py "${ARGS[@]}" diff --git a/.github/workflows/performance_tests.yml b/.github/workflows/performance_tests.yml index 1e02b76c8b..caf66cf18d 100644 --- a/.github/workflows/performance_tests.yml +++ b/.github/workflows/performance_tests.yml @@ -34,9 +34,8 @@ jobs: id: setup uses: ./.github/actions/tests/common_test_setup with: - cache_version: 8 - test_name: oss - target: 'oss' + cache_version: 9 + test_name: ingestion_performance_tests # Authenticate to GCP for read access to GCS - name: Authenticate to GCP (mainnet-history-dumps) @@ -138,9 +137,8 @@ jobs: id: setup uses: ./.github/actions/tests/common_test_setup with: - cache_version: 8 - test_name: oss - target: 'oss' + cache_version: 9 + test_name: read_performance_tests # Authenticate to GCP for read access to GCS - name: Authenticate to GCP (mainnet-history-dumps) diff --git a/.github/workflows/pr_check_github_scripts.yml b/.github/workflows/pr_check_github_scripts.yml index 997641a4a2..23f6125797 100644 --- a/.github/workflows/pr_check_github_scripts.yml +++ b/.github/workflows/pr_check_github_scripts.yml @@ -15,12 +15,8 @@ jobs: - name: Set up Nix uses: ./.github/actions/nix/setup_nix with: - cache_version: 8 - artifactory_user: dummy - artifactory_password: dummy - target: oss + cache_version: 9 - name: Check github scripts uses: ./.github/actions/nix/run_bash_command_in_nix with: cmd: bash gha-scripts/scripts/check-build.sh - \ No newline at end of file diff --git a/.github/workflows/pr_non_contributors.yml b/.github/workflows/pr_non_contributors.yml index ff44bef249..4453efa321 100644 --- a/.github/workflows/pr_non_contributors.yml +++ b/.github/workflows/pr_non_contributors.yml @@ -2,7 +2,7 @@ name: CI on PRs from forks on: pull_request_target: types: [ opened, synchronize, reopened ] - branches: [ main ] # Only run on PRs with `main` as their base + branches: [ main, 'staging-*' ] # Only run on PRs with `main` or `staging` branches as their base concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.head.ref }} diff --git a/.github/workflows/pr_static_checks.yml b/.github/workflows/pr_static_checks.yml index ca5b57a4b1..2a53c7ee55 100644 --- a/.github/workflows/pr_static_checks.yml +++ b/.github/workflows/pr_static_checks.yml @@ -21,4 +21,3 @@ jobs: self_hosted: false commit_sha: ${{ github.event.pull_request.head.sha }} skip_todo_check: true # runs from forks with on: pull_request run in context of the fork, so issue references will be broken - oss_only: true diff --git a/.gitignore b/.gitignore index 2c56be64b4..fae017aaa8 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ _build/ **/metals.sbt **/.scala-build/* +daml/daml-ide-mono/daml/ +daml/daml-ide-mono/.vscode/ .vscode/* !.vscode/settings.json # Make sure test files are checkedin diff --git a/CANTON_CODE_CHANGES.md b/CANTON_CODE_CHANGES.md index e88bc5523c..c4621b206e 100644 --- a/CANTON_CODE_CHANGES.md +++ b/CANTON_CODE_CHANGES.md @@ -26,6 +26,7 @@ to know which and/or what changes we'll need to upstream before the switch. * `ActiveContract.loadFromByteString` made public * ``PositiveFiniteDuration` config reader and writer made public * `ProofOfOwnership` made public +* `JcePureCrypto#signBytes` overridden to expand to public ## Misc * Added support for interface filters in ledger api ACS commands. TODO (#638): This should be upstreamed. * Generalization of `MetricsFactory` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 31e337f501..7bd392189e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,9 +46,9 @@ Splice maintainers may use the following GitHub issue labels to highlight issues Note that not all `good first issue`s are also `help wanted`; some may require access to infrastructure (CI, test deployments) that is not openly available. If you are planning to work on an issue please assign yourself to it (if you are able to) or leave a comment, to -avoid duplicate work across contributors. If the issue is not new, it is also a good idea to reach out to the -core contributors before working on it, to check how relevant it still is, and whether it is something worth -working on. +avoid duplicate work across contributors. + +For any contribution, first check with a maintainer on an issue (either an existing one or a new one) that this makes sense to work on. Contributions that have not gotten explicit agreement from a maintainer on an issue beforehand may be closed without further comment. ## Opening PRs @@ -322,4 +322,3 @@ grant write permissions to the main Splice repo. - CI assumes the branch for the latest release line, so after every Splice release (assuming you rebase/merge the fork's main to Splice main), you will need to pull the corresponding release-line from splice and push it to the fork. In the future we might change CI to pull the release line from main Splice rather than look for it in the fork, but as of now, it assumes it exists in the same repo on which it is running. - diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 504b28161b..43c40e7805 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -19,6 +19,7 @@ - [Troubleshooting](#troubleshooting) - [Nix Issues on MacOS](#nix-issues-on-macos) - [NPM Lock file issues](#npm-lock-file-issues) +- [Staging Branches for Minor Releases](#staging-branches-for-minor-releases) # Setting up Your Development Environment @@ -36,49 +37,6 @@ direnv: error /home/moritz/daml-projects/canton-amulet/.envrc is blocked. Run `direnv allow` to approve its content ``` 1. Run `direnv allow`. You should see a bunch of output including `direnv: using nix`. -1. (Optional) Configure artifactory credentials - A few tests rely on Enterprise canton features. To be able to run those locally, you will require access to - Digital Asset's enterprise artifactory. If you need to run those, please contact the Maintainers of this repo, - per MAINTAINERS.md. - Once you have access to artifactory, you can generate an artifactory Identity Token [here](https://digitalasset.jfrog.io/ui/admin/artifactory/user_profile). - Your username is shown at the top of the page (under "User profile: XX"). - 1. Add the following to `/etc/nix/netrc` (you might need to create that directory as root): - ``` - machine digitalasset.jfrog.io - login yourartifactoryusername - password yourartifactoryidentitytoken - ``` - 1. In addition, add your artifactory user and password to `.envrc.private` file like so: - ``` - export ARTIFACTORY_USER="yourartifactoryusername" - export ARTIFACTORY_PASSWORD="yourartifactoryidentitytoken" - ``` - Once added, reload direnv by typing `direnv reload` in your terminal. -1. (Optional) Configure artifactory credentials - troubleshooting - If you defined Artifactory access, and are getting an authorization exception, like the following: - ``` - direnv: using nix - error: unable to download 'https://digitalasset.jfrog.io/artifactory/canton-enterprise/canton-enterprise-2.7.0-snapshot.20230614.10547.0.v03419b62.tar.gz': HTTP error 401 ('Unauthorized') - ``` - 1. Check that your access token is valid by running the following sample command: - ``` - curl -vvv -L -u: "https://digitalasset.jfrog.io/artifactory/canton-enterprise/canton-enterprise-2.7.0-snapshot.20230614.10547.0.v03419b62.tar.gz" -o canton-enterprise-2.7.0-snapshot.20230614.10547.0.v03419b62.tar.gz - ``` - If the download fails, check that your access token matches what is set in [Artifactory](https://digitalasset.jfrog.io/ui/admin/artifactory/user_profile). - Also, check you have visability via the UI [here](https://digitalasset.jfrog.io/ui/repos/tree/General/canton-enterprise). - If you don't have visibility via the UI then check with the repo Maintainers. - 1. If the artifact successfully downloaded, check the access rights of the file `/etc/nix/netrc`. - If the access rights are more restrictive than `-rw-rw-r--`, update them: - ``` - chmod 664 /etc/nix/netrc - ``` - Note - `sudo` may be required to run the above command. - 1. Switch to the Splice repo directory. - 1. If the authorization exception isn't resolved, investigate further with additional logging - by running the following command at the root of the repo: - ``` - nix develop --debug --verbose path:nix - **Important:** start your IDE and other development tools from a console that has this `direnv` loaded; and thus has the proper version of all the project dependencies on its `PATH`. @@ -144,7 +102,7 @@ and upload them to the dev GHCR registry. In order to do so, you will need to fo There are a number of environment variables managed with `direnv` that are used to contain private information. This includes credentials to -a range of external services, including Auth0 and Artifactory. To keep +a range of external services, such as Auth0. To keep this private information private, they are stored in a specific file in the root of the project repository: `.envrc.private`. This file is listed in `.gitignore` to prevent accidental commit to the repository. @@ -155,12 +113,6 @@ be present in `.envrc.private` are in fact present. Missing definitions will cause a warning to be reported when `.envrc` is executed. -A list of expected environment definitions is as follows: - -* Artifactory credentials - * `ARTIFACTORY_USER`: your username at digitalasset.jfrog.io (can be seen in the top-right corner after logging in with Google SSO) - * `ARTIFACTORY_PASSWORD`: Your identity token at digitalasset.jfrog.io (can be obtained by generating an identity token in your user profile) - If you are a Splice Contributor (see CONTRIBUTOR.md) and wish to push Docker images and deploy to test clusters from your local machine, you will need also the following: @@ -522,3 +474,20 @@ Caused by: java.lang.NullPointerException: Cannot invoke "jdk.internal.platform. at java.base/jdk.internal.platform.cgroupv2.CgroupV2Subsystem.getInstance(CgroupV2Subsystem.java:80) ``` in start-canton.sh, try adding: `export ADDITIONAL_JAVA_TOOLS_OPTIONS="-XX:-UseContainerSupport"` to .envrc.private + +## Staging Branches for Minor Releases + +Daml changes, breaking API changes and new Canton protocol versions +must only be included in new minor releases. To manage changes that +are ready but cannot be merged to main until the next minor version, +create a ``staging-X.Y.0`` branch for the next minor release based off +main and merge changes for that release into that. You likely want to +regularly merge main into that to avoid it going too far out of sync. + +Note: This is intended for changes that are complete and could be +released not for incomplete changes that then may need to get backed +out again before the release in case we do not manage to finish them +completely. + +Once the time is reached where the minor should be the next weekly +release, merge the staging branch back into main. diff --git a/LATEST_RELEASE b/LATEST_RELEASE index 45a346dba8..0a1ffad4b4 100644 --- a/LATEST_RELEASE +++ b/LATEST_RELEASE @@ -1 +1 @@ -0.6.11 +0.7.4 diff --git a/MAINTENANCE.md b/MAINTENANCE.md index 3f0d560cbe..1d878b63ef 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -13,12 +13,14 @@ ## Bumping Canton -1. Choose the Canton version you wish to upgrade to. The currently published versions on - Artifactory can be found [here](https://digitalasset.jfrog.io/ui/repos/tree/General/canton-enterprise). +1. Choose the Canton version you wish to upgrade to. 2. Update the hashes in `nix/canton-sources.json` by running: `build-tools/bump-canton.sh ` 3. In case you have also made configuration changes to Canton in `simple-topology-canton.conf`, remember to also make the corresponding changes for our cluster deployments. It is recommended to test any configuration changes on scratchnet first. +4. Make sure to run full CI with `[ci]` for a "bump canton" PR. +5. An upgrade cluster test will be requested automatically for PRs bumping the Canton version. + Feel free to skip it if you didn't make any config changes as Canton already has sufficient testing on their side. ## Bumping Daml Compiler & SDK version @@ -44,7 +46,7 @@ Initial setup: 1. Check out the [Canton **Open Source** repo](https://github.com/digital-asset/canton) 2. Define the environment variable used in the commands below using `export PATH_TO_CANTON_OSS=`. This can be added to your private env vars. -Current Canton commit: `9b95fd4b486ccd8e436c64fa703453dd8350b92f` +Current Canton commit: `2fc931e1c8c4e7743e69f966d7c1b72f2373b3ff` 1. Checkout the **current Canton commit listed above** in the Canton open source repo from above, so we can diff our current fork against this checkout. 2. Change to your checkout of the Splice repo and execute the following steps: @@ -55,13 +57,15 @@ Current Canton commit: `9b95fd4b486ccd8e436c64fa703453dd8350b92f` 4. Create a commit to ease review, `git add canton/ && git commit -s -m"Undo our changes" --no-verify` 3. Checkout the commit of the Canton OSS repo to which you have decided to upgrade in Step 1.1 1. Learn the Daml SDK version used by Canton from `head -n15 $PATH_TO_CANTON_OSS/project/project/DamlVersions.scala`. + 2. The OSS repo commit will mention a "Reference commit". In Splice repo run `scripts/search-canton-snapshot.py` with this hash. 5. Execute the following steps in your Splice repo: 1. Copy the Canton changes: `./scripts/copy-canton.sh $PATH_TO_CANTON_OSS` 2. Create a commit to ease review, `git add canton/ && git commit -s -m"Bump Canton commit" --no-verify` 3. Reapply our changes `git apply '--exclude=canton/community/app/src/test/resources/examples/*' --directory=canton --reject canton.patch`. 4. Create a commit to ease review `git add canton/ && git reset '*.rej' && git commit -s -m"Reapply our changes" --no-verify` 5. Bump the SDK/Canton versions in the following places: - 1. The current Canton commit in this `README.md` + 1. The current Canton OSS commit in this `README.md` + 2. The `canton_library_version` in `CantonDependencies.scala` to the value produced by `search-canton-snapshot.py` above 6. Create another commit, `git add -A && git reset '*.rej' && git commit -s -m"Bump Canton commit" --no-verify` 6. Check if the `protocolVersions` in our `BuildInfoKeys` in `BuildCommon.scala` needs to be bumped. - One way to do this is to run `start-canton.sh -w` with an updated Canton binary, and check `ProtocolVersion.latest` in the console. diff --git a/PERFORMANCE.md b/PERFORMANCE.md index 255ed6a142..6909d8572d 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -7,10 +7,10 @@ See the [design document](https://docs.google.com/document/d/1rvAec6BuKx61TdJ6sY The performance tests cover three stores. The Tests ingest update data relevant to each store and measure the time taken for the ingestion process. -| Store | Description | Content | -|---------------------|-------------|-------------------| -| **`SvDsoStore`** | DSO's internal governance data | ACS contracts | -| **`ScanStore`** | DSO's public queryable data | ACS contracts | +| Store | Description | Content | +|---------------------|-------------|---------------------------------------------------| +| **`SvDsoStore`** | DSO's internal governance data | ACS contracts | +| **`ScanStore`** | DSO's public queryable data | ACS contracts and Tx Log | | **`UpdateHistory`** | DSO's append-only audit log | Updates and associated events (creates/exercises) | # How to run a test on branch diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 65307ac462..95bdd015ac 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -5,7 +5,6 @@ This guide outlines steps to resolve common issues that a contributor might enco ## Prerequisites - Install **`nix`**, **`direnv`**, and **`sbt`** as per [DEVELOPMENT.md](./DEVELOPMENT.md) -- Ensure access to JFrog Artifactory (`splice-developers` team). Contact your team lead if access is denied. ## Steps @@ -156,5 +155,4 @@ If you see **`Environment variable VERSION must be set`** or **`locale.Error: un ### Additional notes - Always run SBT commands from a terminal with `direnv` enabled to ensure the `nix` environment is correctly set up. -- If JFrog access issues persist, confirm with your team lead that all necessary team members have been added to the `splice-developers` group. - For persistent issues, consult the Splice repository’s documentation or raise an issue in the repository for further assistance. diff --git a/VERSION b/VERSION index 592e815ea9..8bd6ba8c5c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.12 +0.7.5 diff --git a/apps/app/src/main/resources/application.conf b/apps/app/src/main/resources/application.conf index ef8ce7ae4b..52931b943c 100644 --- a/apps/app/src/main/resources/application.conf +++ b/apps/app/src/main/resources/application.conf @@ -2,7 +2,8 @@ pekko.http.server.request-timeout = 38 seconds pekko.http.server.parsing.error-handler="org.lfdecentralizedtrust.splice.http.PekkoHttpParsingErrorHandler$" pekko.http.server.parsing.ignore-illegal-header-for = - [ "authorization" + [ "accept-language" + "authorization" "cookie" "origin" "proxy-authorization" diff --git a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/config/SpliceConfig.scala b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/config/SpliceConfig.scala index fad206d8f9..7d76d69eb7 100644 --- a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/config/SpliceConfig.scala +++ b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/config/SpliceConfig.scala @@ -31,9 +31,13 @@ import org.lfdecentralizedtrust.splice.splitwell.config.{ SplitwellSynchronizerConfig, } import org.lfdecentralizedtrust.splice.sv.config.* -import org.lfdecentralizedtrust.splice.sv.{SvAppClientConfig} +import org.lfdecentralizedtrust.splice.sv.SvAppClientConfig import org.lfdecentralizedtrust.splice.sv.config.SvOnboardingConfig.FoundDso -import org.lfdecentralizedtrust.splice.util.{Codec, SpliceRateLimitConfig} +import org.lfdecentralizedtrust.splice.util.{ + Codec, + PerAttributeRateLimitConfig, + SpliceRateLimitConfig, +} import org.lfdecentralizedtrust.splice.validator.config.* import org.lfdecentralizedtrust.splice.wallet.config.{ AppRewardBeneficiaryConfig, @@ -69,8 +73,8 @@ import com.typesafe.config.{Config, ConfigRenderOptions} import com.typesafe.config.ConfigException.UnresolvedSubstitution import org.slf4j.{Logger, LoggerFactory} import pureconfig.configurable.{genericMapReader, genericMapWriter} -import pureconfig.generic.FieldCoproductHint -import pureconfig.{ConfigReader, ConfigWriter} +import pureconfig.generic.{CoproductHint, FieldCoproductHint, ProductHint} +import pureconfig.{ConfigCursor, ConfigReader, ConfigWriter} import pureconfig.error.{CannotConvert, FailureReason} import pureconfig.module.cats.{nonEmptyListReader, nonEmptyListWriter} import io.circe.parser.* @@ -427,10 +431,15 @@ object SpliceConfig { deriveReader[SpliceCachingConfigs] implicit val spliceParametersConfig: ConfigReader[SpliceParametersConfig] = deriveReader[SpliceParametersConfig] + implicit val spliceRateLimiterSimpleConfig: ConfigReader[SpliceRateLimitConfig.Simple] = + deriveReader[SpliceRateLimitConfig.Simple] + implicit val clientIpRateLimitConfig: ConfigReader[PerAttributeRateLimitConfig] = + deriveReader[PerAttributeRateLimitConfig] + implicit val spliceRateLimiterWithPerClientIpConfig + : ConfigReader[SpliceRateLimitConfig.WithPerClientIp] = + deriveReader[SpliceRateLimitConfig.WithPerClientIp] implicit val rateLimitersConfig: ConfigReader[RateLimitersConfig] = deriveReader[RateLimitersConfig] - implicit val spliceRateLimiterConfig: ConfigReader[SpliceRateLimitConfig] = - deriveReader[SpliceRateLimitConfig] implicit val enabledFeaturesConfigReader: ConfigReader[EnabledFeaturesConfig] = deriveReader[EnabledFeaturesConfig] implicit val splicePostgresConfigReader: ConfigReader[SplicePostgresConfig] = @@ -695,8 +704,34 @@ object SpliceConfig { deriveReader[AutoAcceptTransfersConfig] implicit val appRewardBeneficiaryConfigReader: ConfigReader[AppRewardBeneficiaryConfig] = deriveReader[AppRewardBeneficiaryConfig] + + implicit val rewardSharingConfigHint: FieldCoproductHint[RewardSharingConfig] = + new FieldCoproductHint[RewardSharingConfig]("type") { + override def from( + cursor: ConfigCursor, + options: Seq[String], + ): ConfigReader.Result[CoproductHint.Action] = { + cursor.asObjectCursor.flatMap { objCur => + if (objCur.atKeyOrUndefined("type").isUndefined) { + options + .find(fieldValue(_) == "built-in") + .fold(super.from(cursor, options))(opt => Right(CoproductHint.Use(objCur, opt))) + } else { + super.from(cursor, options) + } + } + } + } + + implicit val rewardSharingBuiltInReader: ConfigReader[RewardSharingConfig.BuiltIn] = + deriveReader[RewardSharingConfig.BuiltIn] + implicit val rewardSharingExternalHint: ProductHint[RewardSharingConfig.External] = + ProductHint[RewardSharingConfig.External](allowUnknownKeys = false) + implicit val rewardSharingExternalReader: ConfigReader[RewardSharingConfig.External] = + deriveReader[RewardSharingConfig.External] implicit val rewardSharingConfigReader: ConfigReader[RewardSharingConfig] = deriveReader[RewardSharingConfig] + implicit val validatorDecentralizedSynchronizerConfigReader : ConfigReader[ValidatorDecentralizedSynchronizerConfig] = deriveReader[ValidatorDecentralizedSynchronizerConfig].emap(config => { @@ -827,29 +862,33 @@ object SpliceConfig { case (Left(err), _) => Left(err) case (Right(()), (party, sharingConfig)) => for { - _ <- Either.cond( - sharingConfig.beneficiaries.forall(b => - b.percentage > 0 && b.percentage <= BigDecimal(1.0) - ), - (), - ConfigValidationFailed( - s"Reward sharing percentages for $party must be in (0.0, 1.0]" - ), - ) - _ <- Either.cond( - sharingConfig.beneficiaries.map(_.percentage).sum <= BigDecimal(1.0), - (), - ConfigValidationFailed( - s"Reward sharing percentages for $party must sum to at most 1.0" - ), - ) _ <- Either.cond( sharingConfig.batchSize > 0, (), - ConfigValidationFailed( - s"Reward sharing batchSize for $party must be positive" - ), + ConfigValidationFailed(s"Reward sharing batchSize for $party must be positive"), ) + _ <- sharingConfig match { + case RewardSharingConfig.External(_) => Right(()) + case builtIn: RewardSharingConfig.BuiltIn => + for { + _ <- Either.cond( + builtIn.beneficiaries.forall(b => + b.percentage > 0 && b.percentage <= BigDecimal(1.0) + ), + (), + ConfigValidationFailed( + s"Reward sharing percentages for $party must be in (0.0, 1.0]" + ), + ) + _ <- Either.cond( + builtIn.beneficiaries.map(_.percentage).sum <= BigDecimal(1.0), + (), + ConfigValidationFailed( + s"Reward sharing percentages for $party must sum to at most 1.0" + ), + ) + } yield () + } } yield () } } yield conf @@ -918,10 +957,15 @@ object SpliceConfig { implicit val spliceParametersConfig: ConfigWriter[SpliceParametersConfig] = deriveWriter[SpliceParametersConfig] + implicit val spliceRateLimiterSimpleConfig: ConfigWriter[SpliceRateLimitConfig.Simple] = + deriveWriter[SpliceRateLimitConfig.Simple] + implicit val clientIpRateLimitConfig: ConfigWriter[PerAttributeRateLimitConfig] = + deriveWriter[PerAttributeRateLimitConfig] + implicit val spliceRateLimiterWithPerClientIpConfig + : ConfigWriter[SpliceRateLimitConfig.WithPerClientIp] = + deriveWriter[SpliceRateLimitConfig.WithPerClientIp] implicit val rateLimitersConfig: ConfigWriter[RateLimitersConfig] = deriveWriter[RateLimitersConfig] - implicit val spliceRateLimiterConfig: ConfigWriter[SpliceRateLimitConfig] = - deriveWriter[SpliceRateLimitConfig] implicit val enabledFeaturesConfigWriter: ConfigWriter[EnabledFeaturesConfig] = deriveWriter[EnabledFeaturesConfig] @@ -1137,8 +1181,16 @@ object SpliceConfig { deriveWriter[AutoAcceptTransfersConfig] implicit val appRewardBeneficiaryConfigWriter: ConfigWriter[AppRewardBeneficiaryConfig] = deriveWriter[AppRewardBeneficiaryConfig] + + implicit val rewardSharingConfigHint: FieldCoproductHint[RewardSharingConfig] = + new FieldCoproductHint[RewardSharingConfig]("type") + implicit val rewardSharingConfigBuiltInWriter: ConfigWriter[RewardSharingConfig.BuiltIn] = + deriveWriter[RewardSharingConfig.BuiltIn] + implicit val rewardSharingConfigExternalWriter: ConfigWriter[RewardSharingConfig.External] = + deriveWriter[RewardSharingConfig.External] implicit val rewardSharingConfigWriter: ConfigWriter[RewardSharingConfig] = deriveWriter[RewardSharingConfig] + implicit val validatorDecentralizedSynchronizerConfigWriter : ConfigWriter[ValidatorDecentralizedSynchronizerConfig] = deriveWriter[ValidatorDecentralizedSynchronizerConfig] diff --git a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/ScanAppReference.scala b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/ScanAppReference.scala index 11d3a7a6a1..8e6d76a748 100644 --- a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/ScanAppReference.scala +++ b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/ScanAppReference.scala @@ -33,6 +33,7 @@ import org.lfdecentralizedtrust.splice.http.v0.definitions.{ UpdateHistoryItemV2, } import org.lfdecentralizedtrust.splice.scan.{ScanApp, ScanAppBootstrap} +import org.lfdecentralizedtrust.splice.store.VoteResultsFilters import org.lfdecentralizedtrust.splice.scan.automation.ScanAutomationService import org.lfdecentralizedtrust.splice.scan.admin.api.client.commands.HttpScanAppClient import org.lfdecentralizedtrust.splice.scan.admin.api.client.commands.HttpScanAppClient.TransferContextWithInstances @@ -367,20 +368,6 @@ abstract class ScanAppReference( httpCommand(HttpScanAppClient.GetRewardAccountingBatch(roundNumber, batchHash)) } - import org.lfdecentralizedtrust.splice.http.v0.definitions.TransactionHistoryResponseItem - import org.lfdecentralizedtrust.splice.http.v0.definitions.TransactionHistoryRequest.SortOrder - - def listTransactions( - pageEndEventId: Option[String], - sortOrder: SortOrder, - pageSize: Int, - ): Seq[TransactionHistoryResponseItem] = - consoleEnvironment.run { - httpCommand( - HttpScanAppClient.ListTransactions(pageEndEventId, sortOrder, pageSize) - ) - } - def getAcsSnapshot(party: PartyId, recordTime: Option[Instant]): ByteString = consoleEnvironment.run { httpCommand( @@ -465,6 +452,31 @@ abstract class ScanAppReference( ) } + def getAcsSnapshotAtV2( + at: CantonTimestamp, + migrationId: Long, + recordTimeMatch: Option[definitions.AcsRequestV2.RecordTimeMatch] = Some( + definitions.AcsRequestV2.RecordTimeMatch.Exact + ), + after: Option[String] = None, + pageSize: Int = 100, + partyIds: Option[Vector[PartyId]] = None, + templates: Option[Vector[PackageQualifiedName]] = None, + ) = + consoleEnvironment.run { + httpCommand( + HttpScanAppClient.GetAcsSnapshotAtV2( + at.toInstant.atOffset(java.time.ZoneOffset.UTC), + migrationId, + recordTimeMatch, + after, + pageSize, + partyIds, + templates, + ) + ) + } + def getHoldingsStateAt( at: CantonTimestamp, migrationId: Long, @@ -846,22 +858,14 @@ abstract class ScanAppReference( @Help.Summary("List vote results") def listVoteRequestResults( - actionName: Option[String], - accepted: Option[Boolean], - requester: Option[String], - effectiveFrom: Option[String], - effectiveTo: Option[String], + filters: VoteResultsFilters, limit: BigInt, pageToken: Option[BigInt] = None, ): (Seq[DsoRules_CloseVoteRequestResult], Option[BigInt]) = { consoleEnvironment.run { httpCommand( HttpScanAppClient.ListVoteRequestResults( - actionName, - accepted, - requester, - effectiveFrom, - effectiveTo, + filters, limit, pageToken, ) @@ -915,6 +919,18 @@ abstract class ScanAppReference( ) } + @Help.Summary( + "Get checksums for a list of bulk storage objects (using both staging and committed objects)" + ) + def getBulkObjectChecksums( + objectKeys: Seq[String] + ): definitions.GetBulkObjectChecksumsResponse = + consoleEnvironment.run { + httpCommand( + HttpScanAppClient.GetBulkObjectChecksums(objectKeys) + ) + } + @Help.Summary("Download a bulk storage object") def bulkStorageDownload(objectKey: String, output: OutputStream)(implicit ec: ExecutionContext, diff --git a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/SvAppReference.scala b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/SvAppReference.scala index 926b03bbc9..63b7eb0d47 100644 --- a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/SvAppReference.scala +++ b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/SvAppReference.scala @@ -23,6 +23,7 @@ import org.lfdecentralizedtrust.splice.environment.{ SpliceStatus, } import org.lfdecentralizedtrust.splice.http.v0.definitions +import org.lfdecentralizedtrust.splice.store.VoteResultsFilters import org.lfdecentralizedtrust.splice.sv.{SvApp, SvAppBootstrap, SvAppClientConfig} import org.lfdecentralizedtrust.splice.sv.admin.api.client.commands.{ HttpSvAdminAppClient, @@ -133,7 +134,7 @@ abstract class SvAppReference( @Help.Summary("Cancel a running logical synchronizer upgrade by removing its LSU announcement") def cancelLogicalSynchronizerUpgrade(): Unit = consoleEnvironment.run { - httpCommand(HttpSvAdminAppClient.CancelLogicalSynchronizerUpgrade()) + httpCommand(HttpSvOperatorAppClient.CancelLogicalSynchronizerUpgrade()) } @Help.Summary("Get identities of all domain node components") @@ -197,22 +198,14 @@ abstract class SvAppReference( } def listVoteRequestResults( - actionName: Option[String], - accepted: Option[Boolean], - requester: Option[String], - effectiveFrom: Option[String], - effectiveTo: Option[String], + filters: VoteResultsFilters, limit: BigInt, pageToken: Option[BigInt] = None, ): (Seq[DsoRules_CloseVoteRequestResult], Option[BigInt]) = { consoleEnvironment.run { httpCommand( HttpSvOperatorAppClient.ListVoteRequestResults( - actionName, - accepted, - requester, - effectiveFrom, - effectiveTo, + filters, limit, pageToken, ) @@ -220,6 +213,16 @@ abstract class SvAppReference( } } + def countVoteRequestResults( + filters: VoteResultsFilters + ): Long = { + consoleEnvironment.run { + httpCommand( + HttpSvOperatorAppClient.CountVoteRequestResults(filters) + ) + } + } + @Help.Summary("Cast a vote") def castVote( trackingCid: VoteRequest.ContractId, @@ -289,13 +292,10 @@ class SvAppBackendReference( def appState: SvApp.State = _appState[SvApp.State, SvApp] @Help.Summary( - "Returns the current delegate based automation. Do not keep references to the result, as this automation gets replaced whenever the DSO delegate changes." + "Returns the delegate based automation. The reference is stable for the lifetime of the app." ) - def dsoDelegateBasedAutomation: DsoDelegateBasedAutomationService = { - appState.dsoAutomation.restartDsoDelegateBasedAutomationTrigger.epochState - .getOrElse(throw new RuntimeException("LeaderBasedAutomation is not fully started up")) - .dsoDelegateBasedAutomation - } + def dsoDelegateBasedAutomation: DsoDelegateBasedAutomationService = + appState.dsoAutomation.dsoDelegateBasedAutomation @Help.Summary( "Returns the current DSO automation." diff --git a/apps/app/src/pack/examples/sv-helm/cometbft-values.yaml b/apps/app/src/pack/examples/sv-helm/cometbft-values.yaml index 785f80b028..57ffcd06d8 100644 --- a/apps/app/src/pack/examples/sv-helm/cometbft-values.yaml +++ b/apps/app/src/pack/examples/sv-helm/cometbft-values.yaml @@ -39,6 +39,11 @@ stateSync: # Note that the port number is significant in the list of rpcServers rpcServers: "https://sv.sv-2.TARGET_HOSTNAME:443/api/sv/v0/admin/domain/cometbft/json-rpc,https://sv.sv-2.TARGET_HOSTNAME:443/api/sv/v0/admin/domain/cometbft/json-rpc" +watchdog: + enabled: true + sequencerMetricsUrl: http://global-domain-SERIAL_ID-sequencer:10013/metrics + mediatorMetricsUrl: http://global-domain-SERIAL_ID-mediator:10013/metrics + # k8s affinity for all deployed pods (optional) # affinity: # nodeAffinity: diff --git a/apps/app/src/pack/examples/sv-helm/global-domain-values.yaml b/apps/app/src/pack/examples/sv-helm/global-domain-values.yaml index e1b5b1c390..a1008a5571 100644 --- a/apps/app/src/pack/examples/sv-helm/global-domain-values.yaml +++ b/apps/app/src/pack/examples/sv-helm/global-domain-values.yaml @@ -12,7 +12,7 @@ sequencer: # Enable when using CantonBFT instead of CometBFT # driver: # type: "cantonbft" - # externalAddress: "sequencer-p2p-15.SERIAL_ID.sv.YOUR_HOSTNAME" + # externalAddress: "sequencer-p2p-SERIAL_ID.sv.YOUR_HOSTNAME" # externalPort: 443 # persistence: # databaseName: sequencer_SERIAL_ID_cantonbft diff --git a/apps/app/src/pack/examples/sv-helm/kms-participant-aws-values.yaml b/apps/app/src/pack/examples/sv-helm/kms-participant-aws-values.yaml index 30ddb0440e..9a8b9d01d0 100644 --- a/apps/app/src/pack/examples/sv-helm/kms-participant-aws-values.yaml +++ b/apps/app/src/pack/examples/sv-helm/kms-participant-aws-values.yaml @@ -23,3 +23,8 @@ additionalEnvVars: secretKeyRef: name: aws-credentials key: secretAccessKey + # Session signing keys reduce KMS load and cost on KMS-backed participants by signing + # most messages with short-lived in-memory keys instead of calling the KMS each time. + # This config setting only works on KMS participants. + - name: ADDITIONAL_CONFIG_SESSION_SIGNING_KEYS + value: canton.participants.participant.crypto.session-signing-keys.enabled = true diff --git a/apps/app/src/pack/examples/sv-helm/kms-participant-gcp-values.yaml b/apps/app/src/pack/examples/sv-helm/kms-participant-gcp-values.yaml index b8598761d2..ea4b871a90 100644 --- a/apps/app/src/pack/examples/sv-helm/kms-participant-gcp-values.yaml +++ b/apps/app/src/pack/examples/sv-helm/kms-participant-gcp-values.yaml @@ -19,6 +19,11 @@ kms: additionalEnvVars: - name: GOOGLE_APPLICATION_CREDENTIALS value: "/app/gcp-credentials.json" + # Session signing keys reduce KMS load and cost on KMS-backed participants by signing + # most messages with short-lived in-memory keys instead of calling the KMS each time. + # This config setting only works on KMS participants. + - name: ADDITIONAL_CONFIG_SESSION_SIGNING_KEYS + value: canton.participants.participant.crypto.session-signing-keys.enabled = true extraVolumeMounts: - name: gcp-credentials mountPath: "/app/gcp-credentials.json" diff --git a/apps/app/src/test/resources/include/scans/_scan.conf b/apps/app/src/test/resources/include/scans/_scan.conf index 147faf2366..b623e19de3 100644 --- a/apps/app/src/test/resources/include/scans/_scan.conf +++ b/apps/app/src/test/resources/include/scans/_scan.conf @@ -31,8 +31,14 @@ getAcsSnapshot = 1 minute } rate-limiting { - default { - rate-per-second = 200 + # the global limiter caps the total request rate across all operations; disabled in + # tests so that it doesn't interfere with the per-operation limits exercised here. + global { + rate-per-second = 1 + enabled = false + per-client-ip { + enabled = false + } } rate-limiters { getAcsSnapshot.rate-per-second = 2 diff --git a/apps/app/src/test/resources/include/sequencers.conf b/apps/app/src/test/resources/include/sequencers.conf index e8bf77998b..85858a350d 100644 --- a/apps/app/src/test/resources/include/sequencers.conf +++ b/apps/app/src/test/resources/include/sequencers.conf @@ -35,6 +35,8 @@ _sequencer_reference_template { config { storage = ${_shared.storage} storage.config.properties.databaseName = "sequencer_driver" + consensus-empty-block-creation-timeout = 500.milliseconds + output-fetch-how-many-recipients = 2 } type = reference block { diff --git a/apps/app/src/test/resources/include/svs/_sv.conf b/apps/app/src/test/resources/include/svs/_sv.conf index a08a5c59c4..f13c2b775f 100644 --- a/apps/app/src/test/resources/include/svs/_sv.conf +++ b/apps/app/src/test/resources/include/svs/_sv.conf @@ -64,8 +64,14 @@ onboardSvPartyMigrationAuthorize = 5 minutes } rate-limiting { - default { - rate-per-second = 200 + # the global limiter caps the total request rate across all operations; disabled in + # tests so that it doesn't interfere with the per-operation limits exercised here. + global { + rate-per-second = 1 + enabled = false + per-client-ip { + enabled = false + } } rate-limiters { prepareValidatorOnboarding.rate-per-second = 2 diff --git a/apps/app/src/test/resources/localnet-reassign-topology.conf b/apps/app/src/test/resources/localnet-reassign-topology.conf new file mode 100644 index 0000000000..426525f88e --- /dev/null +++ b/apps/app/src/test/resources/localnet-reassign-topology.conf @@ -0,0 +1,38 @@ +include required("include/canton-basic.conf") +canton { + validator-app-clients { + userValidatorClient { + admin-api { + url = "http://wallet.localhost:2000" + } + } + providerValidatorClient { + admin-api { + url = "http://wallet.localhost:3000" + } + } + } + remote-participants { + app-provider { + ledger-api { + address = "grpc-ledger-api.localhost" + port = 3000 + } + admin-api { + address = "localhost" + port = 3902 + } + } + app-user { + ledger-api { + address = "grpc-ledger-api.localhost" + port = 2000 + } + admin-api { + address = "localhost" + port = 2902 + } + } + } +} + diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/config/SpliceConfigTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/config/SpliceConfigTest.scala index db5150864f..041b6cc1e7 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/config/SpliceConfigTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/config/SpliceConfigTest.scala @@ -87,8 +87,8 @@ class SpliceConfigTest extends AsyncWordSpec with BaseTest { } // Shared helper for RewardSharingConfig tests - private def mkSharingCfg(percentages: BigDecimal*): RewardSharingConfig = - RewardSharingConfig( + private def mkSharingCfg(percentages: BigDecimal*): RewardSharingConfig.BuiltIn = + RewardSharingConfig.BuiltIn( minTtlAfterSharing = NonNegativeFiniteDuration.ofHours(30), beneficiaries = percentages.zipWithIndex.map { case (pct, i) => AppRewardBeneficiaryConfig( @@ -165,6 +165,7 @@ class SpliceConfigTest extends AsyncWordSpec with BaseTest { s""" |canton.validator-apps.aliceValidator.reward-sharing-config-by-party = { | "alice::1220abc" = { + | type = "built-in" | beneficiaries = [$beneficiaries] | min-ttl-after-sharing = 30h | } @@ -222,6 +223,7 @@ class SpliceConfigTest extends AsyncWordSpec with BaseTest { """ |canton.validator-apps.aliceValidator.reward-sharing-config-by-party = { | "alice::1220abc" = { + | type = "built-in" | beneficiaries = [{ beneficiary = "bob::1220", percentage = 0.4 }] | min-ttl-after-sharing = 30h | batch-size = 50 @@ -238,6 +240,7 @@ class SpliceConfigTest extends AsyncWordSpec with BaseTest { """ |canton.validator-apps.aliceValidator.reward-sharing-config-by-party = { | "alice::1220abc" = { + | type = "built-in" | beneficiaries = [{ beneficiary = "bob::1220", percentage = 0.4 }] | min-ttl-after-sharing = 30h | batch-size = 0 @@ -252,5 +255,88 @@ class SpliceConfigTest extends AsyncWordSpec with BaseTest { .value .toString should include("batchSize") } + + def sharingConfigOf(cfg: SpliceConfig): RewardSharingConfig = + cfg.validatorApps.values + .flatMap(_.rewardSharingConfigByParty.get("alice::1220abc")) + .loneElement + + "accept type = external with no beneficiaries and custom batch size" in { + val overwrite = ConfigFactory.parseString( + """ + |canton.validator-apps.aliceValidator.reward-sharing-config-by-party = { + | "alice::1220abc" = { + | type = "external" + | batch-size = 500 + | } + |} + """.stripMargin + ) + val validConfig = CantonConfig.mergeConfigs(config, Seq(overwrite)) + val loaded = SpliceConfig.loadAndValidate(validConfig).value + sharingConfigOf(loaded) shouldBe RewardSharingConfig.External(batchSize = 500) + } + + "accept explicit type = built-in with beneficiaries" in { + val overwrite = ConfigFactory.parseString( + """ + |canton.validator-apps.aliceValidator.reward-sharing-config-by-party = { + | "alice::1220abc" = { + | type = "built-in" + | beneficiaries = [{ beneficiary = "bob::1220", percentage = 0.4 }] + | min-ttl-after-sharing = 30h + | } + |} + """.stripMargin + ) + val validConfig = CantonConfig.mergeConfigs(config, Seq(overwrite)) + val loaded = SpliceConfig.loadAndValidate(validConfig).value + sharingConfigOf(loaded) shouldBe a[RewardSharingConfig.BuiltIn] + } + + "reject type = external, with beneficiaries" in { + val overwrite = ConfigFactory.parseString( + """ + |canton.validator-apps.aliceValidator.reward-sharing-config-by-party = { + | "alice::1220abc" = { + | type = "external" + | beneficiaries = [{ beneficiary = "bob::1220", percentage = 0.4 }] + | } + |} + """.stripMargin + ) + val validConfig = CantonConfig.mergeConfigs(config, Seq(overwrite)) + SpliceConfig.loadAndValidate(validConfig) shouldBe a[Left[?, ?]] + } + + "reject an invalid type value" in { + val overwrite = ConfigFactory.parseString( + """ + |canton.validator-apps.aliceValidator.reward-sharing-config-by-party = { + | "alice::1220abc" = { + | type = "bogus" + | } + |} + """.stripMargin + ) + val buggyConfig = CantonConfig.mergeConfigs(config, Seq(overwrite)) + SpliceConfig.loadAndValidate(buggyConfig) shouldBe a[Left[?, ?]] + } + + "default to built-in when type is omitted (legacy config shape)" in { + val overwrite = ConfigFactory.parseString( + """ + |canton.validator-apps.aliceValidator.reward-sharing-config-by-party = { + | "alice::1220abc" = { + | beneficiaries = [{ beneficiary = "bob::1220", percentage = 0.4 }] + | min-ttl-after-sharing = 30h + | } + |} + """.stripMargin + ) + val loaded = + SpliceConfig.loadAndValidate(CantonConfig.mergeConfigs(config, Seq(overwrite))).value + sharingConfigOf(loaded) shouldBe a[RewardSharingConfig.BuiltIn] + } } } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/EnvironmentDefinition.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/EnvironmentDefinition.scala index 2b1c85b275..a6105b98fa 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/EnvironmentDefinition.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/EnvironmentDefinition.scala @@ -217,7 +217,7 @@ case class EnvironmentDefinition( } if ( existing.item.featureFlags - .contains(ParticipantTopologyFeatureFlag.EnableAlphaMultiSynchronizer) + .contains(ParticipantTopologyFeatureFlag.EnableMultiSynchronizer) ) { logger.info( s"Participant ${validator.participantClient.id} already has multi synchronizer feature flag enabled for ${sync.synchronizerId}" @@ -230,7 +230,7 @@ case class EnvironmentDefinition( validator.participantClient.id, sync.synchronizerId, featureFlags = Seq( - ParticipantTopologyFeatureFlag.EnableAlphaMultiSynchronizer + ParticipantTopologyFeatureFlag.EnableMultiSynchronizer ), ) } @@ -278,6 +278,17 @@ case class EnvironmentDefinition( ) } + def withReducedAmuletRulesCacheTTL( + duration: NonNegativeFiniteDuration = NonNegativeFiniteDuration.ofSeconds(1) + ): EnvironmentDefinition = + this + .addConfigTransform((_, conf) => + ConfigTransforms.updateAllValidatorAppConfigs_(c => + // Reduce the cache TTL. Otherwise alice validator takes forever to see the new amulet rules version + c.copy(scanClient = c.scanClient.setAmuletRulesCacheTimeToLive(duration)) + )(conf) + ) + /** Use exactly this setup and replace any previously existing setup. */ def withThisSetup(setup: SpliceTestConsoleEnvironment => Unit): EnvironmentDefinition = copy(setup = setup) @@ -493,6 +504,13 @@ case class EnvironmentDefinition( ) } + def withTransferCommandSupport: EnvironmentDefinition = + this.addConfigTransform((_, conf) => + ConfigTransforms.updateAllValidatorAppConfigs_( + _.copy(enableDeprecatedTransferCommandSupport = true) + )(conf) + ) + def clearConfigTransforms(): EnvironmentDefinition = copy(configTransformsWithContext = _ => Seq()) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/plugins/EventHistorySanityCheckPlugin.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/plugins/EventHistorySanityCheckPlugin.scala index 232d7113a8..c985cb6683 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/plugins/EventHistorySanityCheckPlugin.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/plugins/EventHistorySanityCheckPlugin.scala @@ -16,6 +16,7 @@ import org.lfdecentralizedtrust.splice.http.v0.definitions.UpdateHistoryReassign import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.SpliceTestConsoleEnvironment import org.scalatest.concurrent.Eventually import org.scalatest.matchers.should.Matchers +import org.scalatest.time.{Millis, Seconds, Span} import org.scalatest.{Inspectors, LoneElement} import scala.annotation.tailrec @@ -34,7 +35,16 @@ class EventHistorySanityCheckPlugin( ): Unit = { val initializedScans = environment.scans.local.filter(_.is_initialized) if (initializedScans.nonEmpty) { - compareEventHistories(initializedScans) + // getEventHistory only serves events up to min(update, verdict) ingestion cursor + // (ScanEventStore.getCurrentMigrationCap), and verdict ingestion from the mediator lags + // behind update ingestion. At teardown this can hide even long-ingested updates, such as + // the DsoRules_AddSv exercise that compareEventHistories requires to appear in the founder + // history, so retry: each attempt re-fetches the histories until the cursors catch up. + eventually(compareEventHistories(initializedScans))( + PatienceConfig(timeout = Span(20, Seconds), interval = Span(500, Millis)), + implicitly[org.scalatest.enablers.Retrying[Unit]], + implicitly[org.scalactic.source.Position], + ) } } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/plugins/UpdateHistorySanityCheckPlugin.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/plugins/UpdateHistorySanityCheckPlugin.scala index d162d1df9f..08ec0c57a9 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/plugins/UpdateHistorySanityCheckPlugin.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/plugins/UpdateHistorySanityCheckPlugin.scala @@ -5,7 +5,7 @@ import org.lfdecentralizedtrust.splice.config.ConfigTransforms.updateAllScanAppC import org.lfdecentralizedtrust.splice.config.SpliceConfig import org.lfdecentralizedtrust.splice.console.ScanAppBackendReference import org.lfdecentralizedtrust.splice.http.v0.definitions.DamlValueEncoding.members.CompactJson -import org.lfdecentralizedtrust.splice.http.v0.definitions.{AcsResponseV1, UpdateHistoryItemV2} +import org.lfdecentralizedtrust.splice.http.v0.definitions.{AcsResponseV2, UpdateHistoryItemV2} import org.lfdecentralizedtrust.splice.http.v0.definitions.UpdateHistoryItemV2.members import org.lfdecentralizedtrust.splice.http.v0.definitions.UpdateHistoryReassignment.Event.members as reassignmentMembers import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.SpliceTestConsoleEnvironment @@ -217,14 +217,14 @@ class UpdateHistorySanityCheckPlugin( private def getAllSnapshots( scan: ScanAppBackendReference, before: CantonTimestamp, - acc: List[AcsResponseV1], - ): List[AcsResponseV1] = { + acc: List[AcsResponseV2], + ): List[AcsResponseV2] = { val acsSnapshotPeriodHours = scanStorageConfigV1.dbAcsSnapshotPeriodHours val migrationId = scan.getMigrationId() scan.getDateOfMostRecentSnapshotBefore(before, migrationId) match { case Some(snapshotDate) => val snapshot = scan - .getAcsSnapshotAtV1( + .getAcsSnapshotAtV2( CantonTimestamp.assertFromInstant(snapshotDate.toInstant), migrationId, pageSize = 1000, diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/plugins/UseToxiproxy.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/plugins/UseToxiproxy.scala index 2734c2d90b..8d352abc67 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/plugins/UseToxiproxy.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/plugins/UseToxiproxy.scala @@ -11,6 +11,7 @@ import monocle.macros.syntax.lens.* import org.apache.pekko.http.scaladsl.model.Uri import scala.collection.mutable.Map +import scala.util.Try /** A test plugin which injects toxiproxy to certain connections, a much-simplified version of the equivalent plugin in Canton. * At the moment, we support only the SV apps' ledger api connections and the scan app's HTTP connections, but as we need to add more - we will generalize the code below. @@ -252,7 +253,11 @@ case class UseToxiproxy( override def afterEnvironmentDestroyed(config: SpliceConfig): Unit = { logger.debug("deleting all proxies. ") - proxies.foreach { case (_, p) => p.delete() } + // Delete every proxy even if one fails: leftovers in the shared toxiproxy daemon keep + // their listen ports bound and cause name conflicts for the next suite. + proxies.foreach { case (name, p) => + Try(p.delete()).failed.foreach(e => logger.warn(s"Failed to delete proxy $name", e)) + } } def disableConnectionViaProxy(connection: String): Unit = { diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/AppUpgradeIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/AppUpgradeIntegrationTest.scala index 0ec02124a1..e0e1c85eb4 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/AppUpgradeIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/AppUpgradeIntegrationTest.scala @@ -30,7 +30,6 @@ import com.digitalasset.canton.topology.admin.grpc.TopologyStoreId import com.digitalasset.canton.topology.store.TimeQuery.HeadState import monocle.macros.syntax.lens.* import org.lfdecentralizedtrust.splice.console.ParticipantClientReference -import com.digitalasset.canton.config.NonNegativeFiniteDuration import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletrules.AmuletRules_SetConfig import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.amuletrules_actionrequiringconfirmation.CRARC_SetConfig @@ -84,14 +83,7 @@ class AppUpgradeIntegrationTest // Makes the test a bit faster and easier to debug. See #11488 ConfigTransforms.useDecentralizedSynchronizerSplitwell()(config) ) - .addConfigTransform((_, conf) => - ConfigTransforms.updateAllValidatorAppConfigs_(c => - // Reduce the cache TTL so package upgrades are picked up quickly. - c.copy(scanClient = - c.scanClient.setAmuletRulesCacheTimeToLive(NonNegativeFiniteDuration.ofSeconds(1)) - ) - )(conf) - ) + .withReducedAmuletRulesCacheTTL() .addConfigTransform((_, config) => { config .focus(_.validatorApps) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/AutoIgnoreUnresponsivePartiesIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/AutoIgnoreUnresponsivePartiesIntegrationTest.scala index 7386391182..2a4f8aea60 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/AutoIgnoreUnresponsivePartiesIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/AutoIgnoreUnresponsivePartiesIntegrationTest.scala @@ -191,7 +191,7 @@ class AutoIgnoreUnresponsivePartiesIntegrationTest )( "Alice is added to the ignored parties store after mediator timeout", _ => { - sv1Backend.dsoDelegateBasedAutomation.expiredAmuletIgnoredPartiesStore.getAll should contain( + sv1Backend.dsoDelegateBasedAutomation.unavailablePartiesStore.getAll should contain( aliceParty ) }, diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/BftScanConnectionIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/BftScanConnectionIntegrationTest.scala index 6e60e173a5..e67b6943af 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/BftScanConnectionIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/BftScanConnectionIntegrationTest.scala @@ -90,8 +90,6 @@ class BftScanConnectionIntegrationTest ) or include("Encountered 4 consecutive transient failures") or include( "Failed to connect to scan of FAILED Seed URL #0 (http://localhost:5112)." - ) or include( - "Failed to read bft sequencers list from scan http://localhost:5112" )) ), ) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/BootstrapPackageConfigIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/BootstrapPackageConfigIntegrationTest.scala index 33ea2e4f0f..636cee7873 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/BootstrapPackageConfigIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/BootstrapPackageConfigIntegrationTest.scala @@ -4,7 +4,6 @@ package org.lfdecentralizedtrust.splice.integration.tests import com.digitalasset.canton.admin.api.client.data.TemplateId -import com.digitalasset.canton.config.NonNegativeFiniteDuration import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.topology.{ParticipantId, PartyId} import com.digitalasset.canton.topology.admin.grpc.TopologyStoreId @@ -75,14 +74,7 @@ class BootstrapPackageConfigIntegrationTest _.copy(initialPackageConfig = initialPackageConfig) )(config) ) - .addConfigTransform((_, conf) => - ConfigTransforms.updateAllValidatorAppConfigs_(c => - // Reduce the cache TTL. Otherwise alice validator takes forever to see the new amulet rules version - c.copy(scanClient = - c.scanClient.setAmuletRulesCacheTimeToLive(NonNegativeFiniteDuration.ofSeconds(1)) - ) - )(conf) - ) + .withReducedAmuletRulesCacheTTL() .addConfigTransform((_, config) => ConfigTransforms.useDecentralizedSynchronizerSplitwell()(config) ) @@ -485,12 +477,12 @@ class BootstrapPackageConfigIntegrationTest ) ) .filter(_.metadata.version <= bootstrapPackage.metadata.version) - expectedToBeVettedVersions.foreach { expectedVettedVersion => + forEvery(expectedToBeVettedVersions) { expectedVettedVersion => val newVettedPackage = vettingState.packages .find(_.packageId == expectedVettedVersion.packageId) - .value - newVettedPackage.validFromInclusive should ( - equal(scheduledTimeO) or equal(scheduledTime1) or equal(scheduledTime2) + .value withClue "newVettedPackage" + Seq(scheduledTimeO, scheduledTime1, scheduledTime2) should contain( + newVettedPackage.validFromInclusive ) } } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DevelopmentFundCouponIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DevelopmentFundCouponIntegrationTest.scala index 6bb2557909..a8a1bae694 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DevelopmentFundCouponIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DevelopmentFundCouponIntegrationTest.scala @@ -31,6 +31,7 @@ import org.lfdecentralizedtrust.splice.wallet.store.{ } import java.time.Duration +import scala.concurrent.duration.DurationInt @org.lfdecentralizedtrust.splice.util.scalatesttags.SpliceDsoGovernance_0_1_21 class DevelopmentFundCouponIntegrationTest @@ -507,7 +508,11 @@ class DevelopmentFundCouponIntegrationTest clue( "The coupon is expired" ) { - eventually() { + // The expiry trigger cannot act before expiresAt (5s after allocation) plus the + // clockSkewAutomationDelay grace period (5s), so 10s of this budget are always + // consumed before the DsoRules_ExpireDevelopmentFundCoupon submission can even + // start; leave enough headroom for slow sequencing on loaded CI runners. + eventually(30.seconds) { aliceValidatorWalletClient .listActiveDevelopmentFundCoupons() shouldBe empty withClue "alice coupons" } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DistributedDomainIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DistributedDomainIntegrationTest.scala index 40b9e4d6f5..78281baaf3 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DistributedDomainIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DistributedDomainIntegrationTest.scala @@ -117,7 +117,7 @@ class DistributedDomainIntegrationTest // Check that things work for external validators clue("Alice can tap") { - onboardWalletUser(aliceWalletClient, aliceValidatorBackend) + eventuallySucceeds()(onboardWalletUser(aliceWalletClient, aliceValidatorBackend)) aliceWalletClient.tap(1000) } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DynamicSynchronizerParamsReconciliationTimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DynamicSynchronizerParamsReconciliationTimeBasedIntegrationTest.scala index 8cba8a2422..fea29008fb 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DynamicSynchronizerParamsReconciliationTimeBasedIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DynamicSynchronizerParamsReconciliationTimeBasedIntegrationTest.scala @@ -1,6 +1,6 @@ package org.lfdecentralizedtrust.splice.integration.tests -import com.digitalasset.canton.config.NonNegativeFiniteDuration +import com.digitalasset.canton.config.{NonNegativeFiniteDuration, PositiveFiniteDuration} import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.IntegrationTestWithIsolatedEnvironment import org.lfdecentralizedtrust.splice.util.{TimeTestUtil, WalletTestUtil} @@ -23,7 +23,12 @@ class DynamicSynchronizerParamsReconciliationTimeBasedIntegrationTest svApps = config.svApps + (InstanceName.tryCreate("sv1Local") -> config - .svApps(InstanceName.tryCreate(s"sv1"))) + + .svApps(InstanceName.tryCreate(s"sv1")) + .copy( + // Non-default value (Canton's default is 2min) to check that the + // reconciliation trigger applies it to the synchronizer. + setBalanceRequestSubmissionWindowSize = PositiveFiniteDuration.ofMinutes(4) + )) + (InstanceName.tryCreate("sv1") -> config .svApps(InstanceName.tryCreate(s"sv1")) @@ -60,6 +65,12 @@ class DynamicSynchronizerParamsReconciliationTimeBasedIntegrationTest .trafficControl .getOrElse(throw new RuntimeException("Traffic control parameters not found")) .freeConfirmationResponses shouldBe false + // FoundDso does not set the submission window size, so bootstrapping leaves it at Canton's default + sv1Backend.participantClient.topology.synchronizer_parameters + .get_dynamic_synchronizer_parameters(synchronizerId) + .trafficControl + .getOrElse(throw new RuntimeException("Traffic control parameters not found")) + .setBalanceRequestSubmissionWindowSize shouldBe PositiveFiniteDuration.ofMinutes(2) sv1Backend.stop() sv1LocalBackend.startSync() @@ -76,6 +87,12 @@ class DynamicSynchronizerParamsReconciliationTimeBasedIntegrationTest .trafficControl .getOrElse(throw new RuntimeException("Traffic control parameters not found")) .freeConfirmationResponses shouldBe true + // sv1Local is configured with a non-default window size which the trigger applies. + sv1Backend.participantClient.topology.synchronizer_parameters + .get_dynamic_synchronizer_parameters(synchronizerId) + .trafficControl + .getOrElse(throw new RuntimeException("Traffic control parameters not found")) + .setBalanceRequestSubmissionWindowSize shouldBe PositiveFiniteDuration.ofMinutes(4) } // We go slightly above 48h as time is not actually completely still in simtime, the microseconds still advance. diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/AmuletExpiryWithOldPackageIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExpiryWithMinimalVettedPackagesIntegrationTest.scala similarity index 54% rename from apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/AmuletExpiryWithOldPackageIntegrationTest.scala rename to apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExpiryWithMinimalVettedPackagesIntegrationTest.scala index ceb4cfa41a..5211596688 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/AmuletExpiryWithOldPackageIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExpiryWithMinimalVettedPackagesIntegrationTest.scala @@ -5,14 +5,20 @@ package org.lfdecentralizedtrust.splice.integration.tests import com.digitalasset.canton.config.CantonRequireTypes.InstanceName import com.digitalasset.canton.config.NonNegativeFiniteDuration +import com.digitalasset.canton.discard.Implicits.DiscardOps import com.digitalasset.canton.logging.SuppressionRule -import com.digitalasset.canton.topology.PartyId import com.digitalasset.canton.topology.transaction.ParticipantPermission -import com.digitalasset.daml.lf.data.Ref.{PackageName, PackageVersion} +import com.digitalasset.canton.topology.{ForceFlag, ForceFlags, PartyId} +import com.digitalasset.daml.lf.data.Ref.{PackageId, PackageName, PackageVersion} +import org.lfdecentralizedtrust.splice.codegen.java.da.time.types.RelTime import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.{ AppRewardCoupon, FeaturedAppActivityMarker, } +import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletrules.TransferPreapproval +import org.lfdecentralizedtrust.splice.codegen.java.splice.ans.{AnsEntry, AnsEntryContext} +import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.payment.{PaymentAmount, Unit} +import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.subscriptions.* import org.lfdecentralizedtrust.splice.config.ConfigTransforms import org.lfdecentralizedtrust.splice.config.ConfigTransforms.{ ConfigurableApp, @@ -25,21 +31,16 @@ import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.{ SpliceTestConsoleEnvironment, } import org.lfdecentralizedtrust.splice.store.db.DbMultiDomainAcsStore -import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.{ - AdvanceOpenMiningRoundTrigger, - ExpireRewardCouponsTrigger, - ExpiredAmuletTrigger, - ExpiredLockedAmuletTrigger, - FeaturedAppActivityMarkerTrigger, - UpdateExternalPartyConfigStateTrigger, -} +import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.* import org.lfdecentralizedtrust.splice.util.* +import org.lfdecentralizedtrust.splice.validator.automation.ValidatorPackageVettingTrigger +import org.lfdecentralizedtrust.splice.wallet.automation.SubscriptionReadyForPaymentTrigger import org.slf4j.event.Level -import scala.concurrent.duration.* import java.time.Duration +import scala.concurrent.duration.* -abstract class AmuletExpiryWithOldPackageIntegrationTestBase +abstract class ExpiryWithMinimalVettedPackagesIntegrationTestBase extends IntegrationTestWithIsolatedEnvironment with WalletTestUtil with TimeTestUtil @@ -92,11 +93,17 @@ abstract class AmuletExpiryWithOldPackageIntegrationTestBase .withPausedTrigger[UpdateExternalPartyConfigStateTrigger] .withPausedTrigger[ExpireRewardCouponsTrigger] .withPausedTrigger[FeaturedAppActivityMarkerTrigger] + .withPausedTrigger[ExpireTransferPreapprovalsTrigger] + .withPausedTrigger[ExpiredAnsEntryTrigger] + .withPausedTrigger[ExpiredAnsSubscriptionTrigger] + .withPausedTrigger[ExpiredAmuletTrigger] + .withPausedTrigger[ExpiredLockedAmuletTrigger] )(c) ) .addConfigTransforms((_, c) => updateAutomationConfig(ConfigurableApp.Validator)( _.copy(enableAutomaticRewardsCollectionAndAmuletMerging = false) + .withPausedTrigger[SubscriptionReadyForPaymentTrigger] )(c) ) .addConfigTransforms((_, c) => @@ -110,7 +117,27 @@ abstract class AmuletExpiryWithOldPackageIntegrationTestBase )(c) ) - def setupAliceWithDustAmulets()(implicit env: SpliceTestConsoleEnvironment): PartyId = { + protected val danglingSubscriptionCid = new Subscription.ContractId("00" * 33 + "01") + protected val danglingSubscriptionRequestCid = + new SubscriptionRequest.ContractId("00" * 33 + "02") + + protected def createAsDso[T](signatories: PartyId*)( + update: com.daml.ledger.javaapi.data.codegen.Update[T] + )(implicit env: SpliceTestConsoleEnvironment) = { + sv1Backend.participantClientWithAdminToken.ledger_api_extensions.commands + .submitWithResult( + userId = sv1Backend.config.ledgerApiUser, + actAs = dsoParty +: signatories, + readAs = Seq.empty, + update = update, + ) + .discard + } + + protected def dsoAcs(implicit env: SpliceTestConsoleEnvironment) = + sv1Backend.participantClientWithAdminToken.ledger_api_extensions.acs + + protected def setupAliceWithDustAmulets()(implicit env: SpliceTestConsoleEnvironment): PartyId = { val synchronizerId = decentralizedSynchronizerId clue("aliceValidator has not vetted splice-amulet 0.1.17 and 0.1.18") { @@ -206,8 +233,8 @@ abstract class AmuletExpiryWithOldPackageIntegrationTestBase /** Tests that expiry triggers fall back to V1 choices when alice's validator * has only vetted minimal package versions (not splice-amulet 0.1.17+). */ -class AmuletExpiryWithMinimalPackageIntegrationTest - extends AmuletExpiryWithOldPackageIntegrationTestBase { +class AmuletExpiryV1FallbackIntegrationTest + extends ExpiryWithMinimalVettedPackagesIntegrationTestBase { "Amulet expiry falls back to V1 choices when alice's validator has not vetted splice-amulet 0.1.17" in { implicit env => @@ -235,49 +262,81 @@ class AmuletExpiryWithMinimalPackageIntegrationTest /** Tests that expiry triggers skip batches when the task's amulet preferred package version * is listed in `ignoredAmuletVersions`, adding the party to the ignored-parties store. */ -class AmuletBasedExpiryWithIgnoredPackageIntegrationTest - extends AmuletExpiryWithOldPackageIntegrationTestBase { +class ExpiryWithIgnoredAmuletVersionIntegrationTest + extends ExpiryWithMinimalVettedPackagesIntegrationTestBase { override val ignoredAmuletVersions: Set[String] = Set( DarResources.amulet_0_1_15.metadata.version.toString ) - "Triggers expiring amulet, locked amulet, and reward coupons and featured app markers skip parties when their preferred amulet package version is marked as ignored" in { + private val entryName = "alice.unverified.ans" + private val entryDescription = "expired ans entry" + + "Expiry triggers skip parties whose preferred amulet package version is ignored" in { implicit env => - val aliceParty: PartyId = setupAliceWithDustAmulets() + val alice = setupAliceWithDustAmulets() + val aliceId = alice.toProtoPrimitive + val dsoId = dsoParty.toProtoPrimitive + advanceRoundsByOneTickViaAutomation() advanceRoundsByOneTickViaAutomation() val (openRounds, _) = sv1ScanBackend.getOpenAndIssuingMiningRounds() val currentRound = openRounds.toList.headOption.value.payload.round + val now = env.environment.clock.now.toInstant + val expired = now.minus(Duration.ofSeconds(1)) - sv1Backend.participantClientWithAdminToken.ledger_api_extensions.commands - .submitWithResult( - userId = sv1Backend.config.ledgerApiUser, - actAs = Seq(dsoParty), - readAs = Seq.empty, - update = new AppRewardCoupon( - dsoParty.toProtoPrimitive, - aliceParty.toProtoPrimitive, + clue("Create dust contracts owned or referenced by alice") { + createAsDso()( + new AppRewardCoupon( + dsoId, + aliceId, false, BigDecimal(10.0).bigDecimal, currentRound, java.util.Optional.empty(), - ).create, + ).create ) - - sv1Backend.participantClientWithAdminToken.ledger_api_extensions.commands - .submitWithResult( - userId = sv1Backend.config.ledgerApiUser, - actAs = Seq(dsoParty), - readAs = Seq.empty, - update = new FeaturedAppActivityMarker( - dsoParty.toProtoPrimitive, - aliceParty.toProtoPrimitive, - aliceParty.toProtoPrimitive, - BigDecimal(1.0).bigDecimal, - ).create, + createAsDso()( + new FeaturedAppActivityMarker(dsoId, aliceId, aliceId, BigDecimal(1.0).bigDecimal).create + ) + createAsDso(alice)( + new TransferPreapproval( + dsoId, + aliceId, // receiver + aliceId, // provider + now.minus(Duration.ofHours(1)), // validFrom + now.minus(Duration.ofHours(1)), // lastRenewedAt + expired, + ).create + ) + createAsDso(alice)( + new AnsEntry(aliceId, dsoId, entryName, "", entryDescription, expired).create ) + createAsDso(alice)( + new AnsEntryContext( + dsoId, + aliceId, + entryName, + "", + entryDescription, + danglingSubscriptionRequestCid, + ).create + ) + createAsDso(alice)( + new SubscriptionIdleState( + danglingSubscriptionCid, + new SubscriptionData(aliceId, dsoId, dsoId, dsoId, entryDescription), + new SubscriptionPayData( + new PaymentAmount(BigDecimal(1.0).bigDecimal, Unit.AMULETUNIT), + new RelTime(1_000_000_000L), + new RelTime(1_000_000L), + ), + expired, // nextPaymentDueAt -> overdue + danglingSubscriptionRequestCid, + ).create + ) + } actAndCheck(timeUntilSuccess = 60.seconds)( "Advance 4 rounds and resume expiry triggers", { @@ -289,29 +348,105 @@ class AmuletBasedExpiryWithIgnoredPackageIntegrationTest sv.dsoDelegateBasedAutomation.trigger[ExpiredLockedAmuletTrigger].resume() sv.dsoDelegateBasedAutomation.trigger[ExpireRewardCouponsTrigger].resume() sv.dsoDelegateBasedAutomation.trigger[FeaturedAppActivityMarkerTrigger].resume() + sv.dsoDelegateBasedAutomation.trigger[ExpireTransferPreapprovalsTrigger].resume() + sv.dsoDelegateBasedAutomation.trigger[ExpiredAnsEntryTrigger].resume() + sv.dsoDelegateBasedAutomation.trigger[ExpiredAnsSubscriptionTrigger].resume() } }, )( - "Dust contracts remain because preferred version 0.1.15 is in ignoredAmuletVersions", + s"All dust contracts remain because alice's preferred version is in ignoredAmuletVersions", _ => { - sv1Backend.dsoDelegateBasedAutomation.expiredAmuletIgnoredPartiesStore.getAll should contain( - aliceParty - ) - aliceWalletClient.list().amulets should have length 2L withClue "amulets should remain" - aliceWalletClient - .list() - .lockedAmulets should have length 2L withClue "locked amulets should remain" - sv1Backend.participantClientWithAdminToken.ledger_api_extensions.acs - .filterJava(AppRewardCoupon.COMPANION)( - dsoParty, - co => co.data.provider == aliceParty.toProtoPrimitive, - ) should have size 1L withClue "app reward coupon should remain" - sv1Backend.participantClientWithAdminToken.ledger_api_extensions.acs - .filterJava(FeaturedAppActivityMarker.COMPANION)( - dsoParty, - co => co.data.provider == aliceParty.toProtoPrimitive, - ) should have size 1L withClue "featured app activity marker should remain" + sv1Backend.dsoDelegateBasedAutomation.unavailablePartiesStore.getAll should + contain(alice) + + aliceWalletClient.list().amulets should have length 2L withClue "amulets" + aliceWalletClient.list().lockedAmulets should have length 2L withClue "locked amulets" + + dsoAcs.filterJava(AppRewardCoupon.COMPANION)( + dsoParty, + _.data.provider == aliceId, + ) should have size 1L withClue "app reward coupon" + + dsoAcs.filterJava(FeaturedAppActivityMarker.COMPANION)( + dsoParty, + _.data.provider == aliceId, + ) should have size 1L withClue "featured app activity marker" + + dsoAcs.filterJava(TransferPreapproval.COMPANION)( + dsoParty, + _.data.receiver == aliceId, + ) should have size 1L withClue "transfer preapproval" + + dsoAcs.filterJava(AnsEntry.COMPANION)( + dsoParty, + _.data.user == aliceId, + ) should have size 1L withClue "ans entry" + + dsoAcs.filterJava(SubscriptionIdleState.COMPANION)( + dsoParty, + _.data.subscriptionData.sender == aliceId, + ) should have size 1L withClue "ans subscription" }, ) } } + +/** Tests that expiry triggers ignore parties whose participant has no vetted amulet. + * Only Amulet contracts are covered in this test, as the ignore logic is shared across expiry triggers. + */ +class ExpiryWithNoVettedAmuletVersionIntegrationTest + extends ExpiryWithMinimalVettedPackagesIntegrationTestBase { + + "Amulet expiry ignores parties with no vetted amulet version" in { implicit env => + val alice = setupAliceWithDustAmulets() + + clue("Alice unvets every amulet version") { + aliceValidatorBackend.validatorAutomation + .trigger[ValidatorPackageVettingTrigger] + .pause() + .futureValue + aliceValidatorBackend.participantClient.topology.vetted_packages.propose_delta( + aliceValidatorBackend.participantClient.id, + store = decentralizedSynchronizerId, + removes = DarResources.amulet.all.map(p => PackageId.assertFromString(p.packageId)), + force = ForceFlags(ForceFlag.AllowUnvettedDependencies), + ) + eventually() { + val vetted = getVettedPackageIds( + aliceValidatorBackend.appState.participantAdminConnection, + decentralizedSynchronizerId, + ).toSet + DarResources.amulet.all.foreach(p => vetted should not contain p.packageId) + } + } + + loggerFactory.assertLogsSeq( + SuppressionRule.forLogger[ExpiredAmuletTrigger] && SuppressionRule.Level(Level.WARN) + )( + actAndCheck(timeUntilSuccess = 60.seconds)( + "Advance 4 rounds and resume the amulet expiry trigger", { + (1 to 4).foreach(_ => advanceRoundsByOneTickViaAutomation()) + updateExternalPartyConfigStatesViaAutomation() + updateExternalPartyConfigStatesViaAutomation() + env.svs.local.foreach( + _.dsoDelegateBasedAutomation.trigger[ExpiredAmuletTrigger].resume() + ) + }, + )( + "Alice is ignored and her dust amulets are not expired", + _ => { + val ignored = sv1Backend.dsoDelegateBasedAutomation.unavailablePartiesStore.getAll + ignored should contain(alice) + ignored should not contain dsoParty + aliceWalletClient.list().amulets should have length 2L withClue "dust amulets" + }, + ), + entries => + forAtLeast(1, entries) { entry => + entry.warningMessage should include("No vetted Amulet version") + entry.warningMessage should include(alice.uid.identifier.str) + entry.warningMessage should include("ignoring 1 parties") + }, + ) + } +} diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternalPartySetupProposalIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternalPartySetupProposalIntegrationTest.scala index a6b4b8fab9..6ab887197c 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternalPartySetupProposalIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternalPartySetupProposalIntegrationTest.scala @@ -103,6 +103,7 @@ class ExternalPartySetupProposalIntegrationTest NonNegativeFiniteDuration.ofMillis(500) )(config), ) + .withTransferCommandSupport } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternallySignedPartyTestUtil.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternallySignedPartyTestUtil.scala index 93aec5e955..baff747368 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternallySignedPartyTestUtil.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternallySignedPartyTestUtil.scala @@ -1,10 +1,18 @@ package org.lfdecentralizedtrust.splice.integration.tests +import com.daml.metrics.api.noop.NoOpMetricsFactory +import com.daml.metrics.api.{MetricName, MetricsContext, HistogramInventory} import com.digitalasset.canton.config.RequireTypes.PositiveInt import com.digitalasset.canton.config.{CachingConfigs, CryptoProvider, CryptoSchemeConfig} import com.digitalasset.canton.crypto.* import com.digitalasset.canton.crypto.provider.jce.JcePureCrypto import com.digitalasset.canton.crypto.v30 as cryptoProto +import com.digitalasset.canton.metrics.{ + SigningHistograms, + DecryptionMetrics, + SigningMetrics, + DecryptionHistograms, +} import com.digitalasset.canton.topology.PartyId import com.digitalasset.canton.util.HexString import com.digitalasset.canton.version.ProtocolVersion @@ -133,6 +141,20 @@ trait ExternallySignedPartyTestUtil extends TestCommon { ) } + private[this] val noOpMetricsFactory = new NoOpMetricsFactory + private[this] val histogramInventory = new HistogramInventory() + private[this] implicit val metricsContext: MetricsContext = new MetricsContext(Map.empty) + + private[this] val signingMetrics = new SigningMetrics( + new SigningHistograms(MetricName("splice-test"))(histogramInventory), + noOpMetricsFactory, + )(metricsContext) + + private[this] val decryptionMetrics = new DecryptionMetrics( + new DecryptionHistograms(MetricName("splice-test"))(histogramInventory), + noOpMetricsFactory, + )(metricsContext) + // The parameters here are just defaults so don't really matter def crypto(implicit ec: ExecutionContext) = new JcePureCrypto( CryptoProvider.Jce.symmetric.default, @@ -150,6 +172,8 @@ trait ExternallySignedPartyTestUtil extends TestCommon { CachingConfigs.defaultPublicKeyConversionCache, None, PositiveInt.tryCreate(1), + signingMetrics, + decryptionMetrics, loggerFactory, ) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternallySignedTxTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternallySignedTxTest.scala index 1517b15167..cddcb116a5 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternallySignedTxTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternallySignedTxTest.scala @@ -21,7 +21,7 @@ trait ExternallySignedTxTest override def environmentDefinition: SpliceEnvironmentDefinition = { EnvironmentDefinition.simpleTopology1Sv(this.getClass.getSimpleName) - } + }.withTransferCommandSupport def prepareAndSubmitTransfer(keyName: String, sender: PartyId, receiver: PartyId)(implicit env: SpliceTestConsoleEnvironment diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternallySignedTxsTimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternallySignedTxsTimeBasedIntegrationTest.scala index f279098757..afda9edb61 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternallySignedTxsTimeBasedIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ExternallySignedTxsTimeBasedIntegrationTest.scala @@ -26,6 +26,7 @@ class ExternallySignedTxsTimeBasedIntegrationTest override def environmentDefinition: SpliceEnvironmentDefinition = EnvironmentDefinition .simpleTopology1SvWithSimTime(this.getClass.getSimpleName) + .withTransferCommandSupport "Externally signed transactions can tolerate a preparation/submission skew larger than ledgerTimeRecordTimeTolerance" in { implicit env => diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/LocalNetReassignIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/LocalNetReassignIntegrationTest.scala new file mode 100644 index 0000000000..39c9e1560c --- /dev/null +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/LocalNetReassignIntegrationTest.scala @@ -0,0 +1,196 @@ +package org.lfdecentralizedtrust.splice.integration.tests + +import com.daml.ledger.api.v2.transaction_filter.CumulativeFilter.IdentifierFilter +import com.daml.ledger.api.v2.transaction_filter.{ + CumulativeFilter, + EventFormat, + Filters, + WildcardFilter, +} +import com.digitalasset.canton.protocol.LfContractId +import com.digitalasset.canton.topology.SynchronizerId +import monocle.Monocle.toAppliedFocusOps +import org.lfdecentralizedtrust.splice.auth.AuthUtil +import org.lfdecentralizedtrust.splice.codegen.java.splice.api.token.test.dummyholding.DummyHolding +import org.lfdecentralizedtrust.splice.console.ParticipantClientReference +import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition +import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.IntegrationTestWithIsolatedEnvironment +import org.lfdecentralizedtrust.splice.util.JavaDecodeUtil + +import java.nio.file.Paths +import scala.jdk.CollectionConverters.* +import scala.sys.process.* + +/** Verifies the actual capability unlocked by the multi-synchronizer topology feature flag set in + * cluster/compose/localnet/conf/console/app-synchronizer.sc: reassigning a contract between the + * global synchronizer and the app-synchronizer. Without the flag, the unassignment is rejected + * with MultiSynchronizerIsNotEnabled. + * + * This spins up the docker-compose localnet with the `multi-sync` profile enabled (-M) + */ +class LocalNetReassignIntegrationTest extends IntegrationTestWithIsolatedEnvironment { + + override def environmentDefinition: SpliceEnvironmentDefinition = + EnvironmentDefinition + .fromResources(Seq("localnet-reassign-topology.conf"), this.getClass.getSimpleName) + .updateTestingConfig( + _.focus(_.participantsWithoutLapiVerification).replace( + Set( + "app-provider", + "app-user", + ) + ) + ) + .withManualStart + + // These do nothing as the clients will not actually be connected to the compose setup. + override protected def runTokenStandardCliSanityCheck: Boolean = false + override lazy val resetRequiredTopologyState = false + + // The user all localnet nodes use for their ledger API access, see + // cluster/compose/localnet/env/*-auth-on.env + private val ledgerApiUserId = "ledger-api-user" + + private val token = AuthUtil.testToken(AuthUtil.testAudience, ledgerApiUserId, "unsafe") + + private val dummyHoldingDarPath = Paths + .get( + "token-standard/examples/splice-token-test-dummy-holding/.daml/dist/splice-token-test-dummy-holding-current.dar" + ) + .toAbsolutePath + .toString + + private def withLocalNet( + )(f: FixtureParam => Any)(implicit env: FixtureParam): Unit = + try { + val ret = (Seq("build-tools/splice-localnet-compose.sh", "start") ++ Seq("-M")).! + if (ret != 0) { + fail("Failed to start docker-compose SV and validator") + } + f(env) + } finally { + (Seq("build-tools/splice-localnet-compose.sh", "stop", "-D") ++ Seq("-M")).! + } + + private def participantClient(name: String)(implicit env: FixtureParam) = { + val remoteParticipant = + env.participants.remote + .find(_.name == name) + .getOrElse(fail(s"$name participant not found")) + new ParticipantClientReference( + env, + remoteParticipant.name, + remoteParticipant.config.copy(token = Some(token)), + ) + } + + private def synchronizerId( + participant: ParticipantClientReference, + alias: String, + ): SynchronizerId = + participant.synchronizers + .list_connected() + .find(_.synchronizerAlias.unwrap == alias) + .getOrElse(fail(s"${participant.name} is not connected to $alias")) + .synchronizerId + + private def testReassignment(participantName: String, validatorClientName: String)(implicit + env: FixtureParam + ): Unit = + clue(s"Reassign a contract between global and app-synchronizer on $participantName") { + val participant = participantClient(participantName) + val party = vc(validatorClientName).copy(token = Some(token)).getValidatorPartyId() + val globalSynchronizerId = synchronizerId(participant, "global") + val appSynchronizerId = synchronizerId(participant, "app-synchronizer") + + participant.upload_dar_unless_exists(dummyHoldingDarPath) + + val createdContract = clue("Create a DummyHolding on the global synchronizer") { + val tx = participant.ledger_api_extensions.commands.submitJava( + actAs = Seq(party), + commands = new DummyHolding( + party.toProtoPrimitive, + party.toProtoPrimitive, + BigDecimal(42).bigDecimal, + ).create().commands().asScala.toSeq, + synchronizerId = Some(globalSynchronizerId), + userId = ledgerApiUserId, + ) + JavaDecodeUtil.decodeAllCreated(DummyHolding.COMPANION)(tx).loneElement + } + val contractId = createdContract.id.contractId + val lfContractId = LfContractId.assertFromString(contractId) + + def contractSynchronizerId(): Option[String] = + participant.ledger_api.state.acs + .active_contracts_of_party(party = party) + .find(_.createdEvent.value.contractId == contractId) + .map(_.synchronizerId) + + contractSynchronizerId() shouldBe Some(globalSynchronizerId.toProtoPrimitive) + + // Scope the reassignment event format to `party` + val eventFormat = + EventFormat( + filtersByParty = Map( + party.toProtoPrimitive -> Filters( + Seq( + CumulativeFilter( + IdentifierFilter.WildcardFilter( + WildcardFilter(includeCreatedEventBlob = false) + ) + ) + ) + ) + ), + filtersForAnyParty = None, + verbose = true, + ) + + def reassign(source: SynchronizerId, target: SynchronizerId): Unit = { + val unassigned = participant.ledger_api.commands + .submit_unassign_with_format( + submitter = party, + contractIds = Seq(lfContractId), + source = source, + target = target, + userId = ledgerApiUserId, + eventFormat = Some(eventFormat), + timeout = None, + ) + .unassignedWrapper + val _ = participant.ledger_api.commands.submit_assign_with_format( + submitter = party, + reassignmentId = unassigned.reassignmentId, + source = source, + target = target, + userId = ledgerApiUserId, + eventFormat = Some(eventFormat), + timeout = None, + ) + } + + actAndCheck( + "Reassign the contract to the app-synchronizer", + reassign(globalSynchronizerId, appSynchronizerId), + )( + "The contract is now assigned to the app-synchronizer", + _ => contractSynchronizerId() shouldBe Some(appSynchronizerId.toProtoPrimitive), + ) + + actAndCheck( + "Reassign the contract back to the global synchronizer", + reassign(appSynchronizerId, globalSynchronizerId), + )( + "The contract is assigned to the global synchronizer again", + _ => contractSynchronizerId() shouldBe Some(globalSynchronizerId.toProtoPrimitive), + ) + } + + "docker-compose based localnet supports reassignment between synchronizers" in { implicit env => + withLocalNet() { implicit env => + testReassignment("app-provider", "providerValidatorClient") + testReassignment("app-user", "userValidatorClient") + } + } +} diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/LsuIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/LsuIntegrationTest.scala index 9c32e60cfe..59e3baa1f1 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/LsuIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/LsuIntegrationTest.scala @@ -29,7 +29,6 @@ import org.lfdecentralizedtrust.splice.environment.{ SequencerAdminConnection, } import org.lfdecentralizedtrust.splice.http.v0.definitions -import org.lfdecentralizedtrust.splice.http.v0.definitions.TransactionHistoryRequest import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.{ IntegrationTest, @@ -81,12 +80,10 @@ class LsuIntegrationTest override protected def beforeAll(): Unit = { super.beforeAll() - SynchronizerUpgradeUtil.migrationDumpDir.delete() + SynchronizerUpgradeUtil.migrationDumpDir.delete(swallowIOExceptions = true) } - // always set the successor PV to 35 - // thus with the daily run with PV34 we will run a PV34 -> PV35 LSU - // otherwise we will run a PV35 -> PV35 LSU - val successorPv = ProtocolVersion.v35 + + private val successorPv = ProtocolVersion.v35 override def environmentDefinition: SpliceEnvironmentDefinition = EnvironmentDefinition @@ -112,10 +109,11 @@ class LsuIntegrationTest physicalSynchronizerExpiration = NonNegativeFiniteDuration.ofSeconds(1) ), // sv-4 is intentionally a late-joining node in this test, which means the - // sequencer spends some time catching up. This can cause the sv-app's + // sequencer spends some time catching up. This can cause the sv-4 apps' // circuit breakers to trip, which makes annoying logs and delays init. // The circuit breakers tripping a bit during catchup would be just fine - // IRL, so as a simple fix to this test we disable them for sv-4. + // IRL, so as a simple fix to this test we disable them for all sv-4 apps + // (see also the scan and validator app transforms below). circuitBreakers = if (name == "sv4") CircuitBreakersConfig.never else config.parameters.circuitBreakers, @@ -124,7 +122,7 @@ class LsuIntegrationTest } .andThen( ConfigTransforms - .updateAllScanAppConfigs { (_, config) => + .updateAllScanAppConfigs { (name, config) => config.copy( synchronizerNodes = config.synchronizerNodes.copy( successor = Some(config.synchronizerNodes.current) @@ -132,10 +130,25 @@ class LsuIntegrationTest parameters = config.parameters.copy( spliceCachingConfigs = config.parameters.spliceCachingConfigs.copy( physicalSynchronizerExpiration = NonNegativeFiniteDuration.ofSeconds(1) - ) + ), + circuitBreakers = + if (name == "sv4Scan") CircuitBreakersConfig.never + else config.parameters.circuitBreakers, ), ) } + ) + .andThen( + ConfigTransforms + .updateAllValidatorAppConfigs { (name, config) => + config.copy( + parameters = config.parameters.copy( + circuitBreakers = + if (name == "sv4Validator") CircuitBreakersConfig.never + else config.parameters.circuitBreakers + ) + ) + } )(config) }) .withBftSequencersSuccessor @@ -206,6 +219,7 @@ class LsuIntegrationTest .withSvBftSequencerConnectionDisabled() .withAmuletPrice(walletAmuletPrice) .withManualStart + .withTransferCommandSupport override def walletAmuletPrice: java.math.BigDecimal = SpliceUtil.damlDecimal(1.0) @@ -216,25 +230,96 @@ class LsuIntegrationTest "cancel a scheduled logical synchronizer upgrade" in { implicit env => initDso(includeLocal = false) startAllSync(aliceValidatorBackend, splitwellValidatorBackend) + val topologyFreezeTime = CantonTimestamp.now() - val upgradeTime = CantonTimestamp.now().plusSeconds(120) + // Use an upgrade time 1h in the future so that the upgrade nodes have enough time to be + // started, initialized and to publish their sequencer successors before we cancel. + val upgradeTime = CantonTimestamp.now().plus(Duration.ofHours(1)) - clue("Schedule logical synchronizer upgrade") { - scheduleLsu(topologyFreezeTime, upgradeTime, 1L) - } + val newSynchronizerSerial = decentralizedSynchronizerPSId.serial + NonNegativeInt.one + val successorPsid = decentralizedSynchronizerPSId.copy( + serial = newSynchronizerSerial, + protocolVersion = successorPv, + ) + val svNodesDoingTheLsu = Seq(sv1Backend, sv2Backend, sv3Backend, sv4Backend) - clue("Wait for LSU announcement to be proposed") { - waitForLsuAnnouncement() - } + withCantonSvNodes( + ( + None, + None, + None, + None, + ), + participants = false, + enableBftSequencer = true, + logSuffix = "cancel-global-synchronizer-upgrade", + )( + ProcessTestUtil.javaToolOptionsKey -> "-Xms8g -Xmx10g" + ) { + clue(s"Schedule logical synchronizer upgrade at $upgradeTime") { + scheduleLsu(topologyFreezeTime, upgradeTime, newSynchronizerSerial.value.toLong) + } - clue("Cancel LSU from all SVs") { - Seq(sv1Backend, sv2Backend, sv3Backend, sv4Backend).par.foreach { sv => - sv.cancelLogicalSynchronizerUpgrade() + clue("Wait for LSU announcement to be proposed") { + waitForLsuAnnouncement() } - } - clue("LSU announcement has been removed from topology state") { - eventually() { + clue("Upgrade nodes are started and initialized before cancelling") { + svNodesDoingTheLsu.foreach { backend => + val upgradeSequencerClient = backend.sequencerClientFor(_.successor.value) + val upgradeMediatorClient = backend.mediatorClientFor(_.successor.value) + clue(s"check ${backend.name} initialized sequencer from synchronizer predecessor") { + eventuallySucceeds(3.minutes) { + upgradeSequencerClient.physical_synchronizer_id shouldBe successorPsid + } + } + clue(s"check ${backend.name} initialized mediator") { + eventuallySucceeds(3.minutes) { + upgradeMediatorClient.health.initialized() shouldBe true + } + } + } + } + + clue("Sequencer successors were published for all upgrade nodes") { + eventually() { + val successors = + sv1Backend.participantClientWithAdminToken.topology.lsu.sequencer_successors + .list(store = Some(Synchronizer(decentralizedSynchronizerId))) + successors should have size svNodesDoingTheLsu.size.toLong + successors.map(_.item.successorPsid).toSet shouldBe Set(successorPsid) + } + } + + clue("Cancel LSU from all SVs") { + svNodesDoingTheLsu.par.foreach { sv => + sv.cancelLogicalSynchronizerUpgrade() + } + } + + clue("LSU announcement has been removed from topology state") { + eventually() { + sv1Backend.participantClientWithAdminToken.topology.lsu.announcement + .list( + store = Some(Synchronizer(decentralizedSynchronizerId)), + operation = Some(TopologyChangeOp.Replace), + ) shouldBe empty + } + } + + clue("Removal transaction exists in topology history") { + val removals = sv1Backend.participantClientWithAdminToken.topology.lsu.announcement + .list( + store = Some(Synchronizer(decentralizedSynchronizerId)), + timeQuery = TimeQuery.Range(None, None), + operation = Some(TopologyChangeOp.Remove), + ) + removals should not be empty + } + + clue("Trigger does not re-create the cancelled announcement") { + // Wait long enough for the trigger to have run multiple times + Threading.sleep(10_000) sv1Backend.participantClientWithAdminToken.topology.lsu.announcement .list( store = Some(Synchronizer(decentralizedSynchronizerId)), @@ -243,158 +328,32 @@ class LsuIntegrationTest } } - clue("Removal transaction exists in topology history") { - val removals = sv1Backend.participantClientWithAdminToken.topology.lsu.announcement - .list( - store = Some(Synchronizer(decentralizedSynchronizerId)), - timeQuery = TimeQuery.Range(None, None), - operation = Some(TopologyChangeOp.Remove), - ) - removals should not be empty - } - - clue("Trigger does not re-create the cancelled announcement") { - // Wait long enough for the trigger to have run multiple times - Threading.sleep(10_000) - sv1Backend.participantClientWithAdminToken.topology.lsu.announcement - .list( - store = Some(Synchronizer(decentralizedSynchronizerId)), - operation = Some(TopologyChangeOp.Replace), - ) shouldBe empty - } + checkSerial1Sequencers() } - "upgrade synchronizer to new physical synchronizer without downtime" in { implicit env => - val allNodes = Seq[AppBackendReference]( - sv1ScanBackend, - sv2ScanBackend, - sv3ScanBackend, - sv4ScanBackend, - sv1Backend, - sv1LocalBackend, - sv1NoLegacyLocalBackend, - sv2Backend, - sv3Backend, - sv4Backend, - sv1ValidatorBackend, - sv2ValidatorBackend, - sv3ValidatorBackend, - sv4ValidatorBackend, - ) - actAndCheck("Create some transaction history", sv1WalletClient.tap(1337))( - "Scan transaction history is recorded and wallet balance is updated", - _ => { - // buffer to account for domain fee payments - assertInRange( - sv1WalletClient.balance().unlockedQty, - (walletUsdToAmulet(1000), walletUsdToAmulet(2000)), - ) - countTapsFromScan(sv1ScanBackend, walletUsdToAmulet(1337)) shouldBe 1 - }, - ) - + def checkSerial1Sequencers()(implicit env: SpliceTestConsoleEnvironment) = { clue("All sequencers are registered") { - eventually() { + eventually(timeUntilSuccess = 1.minute) { inside(sv1ScanBackend.listDsoSequencers()) { case Seq(DomainSequencers(synchronizerId, sequencers)) => synchronizerId shouldBe decentralizedSynchronizerId - sequencers should have size 8 - sequencers.foreach { sequencer => - sequencer.serial match { - case Some(serial) => - serial shouldBe 0 - sequencer.migrationId shouldBe -1 - case None => - sequencer.migrationId shouldBe 0 - } + sequencers should have size 12 + forExactly(4, sequencers) { + _.serial.value shouldBe 0 + } + forExactly(4, sequencers) { + _.serial.value shouldBe 1 + } + forExactly(4, sequencers) { + _.serial should be(empty) } } } } + } - def onboardUserAndTapAmulet( - validatorBackend: ValidatorAppBackendReference, - walletClient: WalletAppClientReference, - tapAmount: BigDecimal = 50.0, - expectedAmulets: Range = 50 to 50, - ) = { - val walletUserParty = onboardWalletUser(walletClient, validatorBackend) - eventuallySucceeds() { - walletClient.tap(tapAmount) - } - clue(s"${validatorBackend.name} has tapped a amulet") { - checkWallet( - walletUserParty, - walletClient, - Seq((walletUsdToAmulet(expectedAmulets.start), walletUsdToAmulet(expectedAmulets.end))), - ) - } - walletUserParty - } - - def createExternalParty( - validatorBackend: ValidatorAppBackendReference, - walletClient: WalletAppClientReference, - ) = { - val onboarding @ OnboardingResult(externalParty, _, _) = - onboardExternalParty(validatorBackend) - walletClient.tap(50.0) - createTransferPreapprovalEnsuringItExists(walletClient, validatorBackend) - createAndAcceptExternalPartySetupProposal(validatorBackend, onboarding) - validatorBackend - .getExternalPartyBalance(externalParty) - .totalUnlockedCoin shouldBe "0.0000000000" - // can still fail with no preapproval depending on what scan subset is used - eventuallySucceeds() { - walletClient.transferPreapprovalSend(externalParty, 40.0, UUID.randomUUID.toString) - } - eventually() { - validatorBackend - .getExternalPartyBalance(externalParty) - .totalUnlockedCoin shouldBe "40.0000000000" - } - onboarding - } - - onboardUserAndTapAmulet(aliceValidatorBackend, aliceValidatorWalletClient) - - // account for the cancellation - val newSynchronizerSerial = decentralizedSynchronizerPSId.serial + NonNegativeInt.two - val successorPsid = decentralizedSynchronizerPSId.copy( - serial = newSynchronizerSerial, - protocolVersion = successorPv, - ) - // Upload after starting validator which connects to global - // synchronizers as upload_dar_unless_exists vets on all - // connected synchronizers. - aliceValidatorBackend.participantClient.upload_dar_unless_exists(splitwellDarPath) - val externalPartyOnboarding = clue("Create external party and transfer 40 amulet to it") { - createExternalParty(aliceValidatorBackend, aliceValidatorWalletClient) - } - - val bobValidatorWalletLocal = wc( - "bobValidatorWalletLocal" - ) - clue("Start bob validator local, onboard and tap before upgrade") { - runBobValidatorWithStandaloneParticipant("before-upgrade")( - onboardUserAndTapAmulet( - bobValidatorLocal, - bobValidatorWalletLocal, - ) - ) - } - - val lateJoiningNode = sv4Nodes - lateJoiningNode.par.foreach(_.stop()) - val topologyFreezeTime = CantonTimestamp.now() - // We need to give enough time for the new Canton instance to startup - // and finish sequencer initialization so we can then publish the sequencer announcement before the upgrade time. - val upgradeTime = CantonTimestamp.now().plusSeconds(150) - clue(s"Schedule logical synchronizer upgrade at $upgradeTime") { - scheduleLsu(topologyFreezeTime, upgradeTime, newSynchronizerSerial.value.toLong) - } - val allBackends = Seq(sv1Backend, sv2Backend, sv3Backend, sv4Backend) - val initialSvNodesDoingTheLsu = Seq(sv1Backend, sv2Backend, sv3Backend) + "upgrade synchronizer to new physical synchronizer without downtime" in { implicit env => + // start the nodes early so that the sv app can remove the existing successor physical synchronizer state withCantonSvNodes( ( None, @@ -405,8 +364,128 @@ class LsuIntegrationTest participants = false, enableBftSequencer = true, logSuffix = "global-synchronizer-upgrade", - )() { + )( + ProcessTestUtil.javaToolOptionsKey -> "-Xms8g -Xmx10g" + ) { + + val allNodes = Seq[AppBackendReference]( + sv1ScanBackend, + sv2ScanBackend, + sv3ScanBackend, + sv4ScanBackend, + sv1Backend, + sv1LocalBackend, + sv1NoLegacyLocalBackend, + sv2Backend, + sv3Backend, + sv4Backend, + sv1ValidatorBackend, + sv2ValidatorBackend, + sv3ValidatorBackend, + sv4ValidatorBackend, + ) + // restart to clear any caches + allNodes.par.foreach(_.stop()) + initDso(includeLocal = false) + startAllSync(aliceValidatorBackend, splitwellValidatorBackend) + actAndCheck("Create some transaction history", sv1WalletClient.tap(1337))( + "Wallet balance is updated", + _ => { + // buffer to account for domain fee payments + assertInRange( + sv1WalletClient.balance().unlockedQty, + (walletUsdToAmulet(1000), walletUsdToAmulet(2000)), + ) + }, + ) + + // The serial 1 sequencers are still registered at this point as we only unregister them when the successors give us back a response + // and they are still uninitialized here. + checkSerial1Sequencers() + def onboardUserAndTapAmulet( + validatorBackend: ValidatorAppBackendReference, + walletClient: WalletAppClientReference, + tapAmount: BigDecimal = 50.0, + expectedAmulets: Range = 50 to 50, + ) = { + val walletUserParty = onboardWalletUser(walletClient, validatorBackend) + eventuallySucceeds() { + walletClient.tap(tapAmount) + } + clue(s"${validatorBackend.name} has tapped a amulet") { + checkWallet( + walletUserParty, + walletClient, + Seq((walletUsdToAmulet(expectedAmulets.start), walletUsdToAmulet(expectedAmulets.end))), + ) + } + walletUserParty + } + + def createExternalParty( + validatorBackend: ValidatorAppBackendReference, + walletClient: WalletAppClientReference, + ) = { + val onboarding @ OnboardingResult(externalParty, _, _) = + onboardExternalParty(validatorBackend) + walletClient.tap(50.0) + createTransferPreapprovalEnsuringItExists(walletClient, validatorBackend) + createAndAcceptExternalPartySetupProposal(validatorBackend, onboarding) + validatorBackend + .getExternalPartyBalance(externalParty) + .totalUnlockedCoin shouldBe "0.0000000000" + // can still fail with no preapproval depending on what scan subset is used + eventuallySucceeds() { + walletClient.transferPreapprovalSend(externalParty, 40.0, UUID.randomUUID.toString) + } + eventually() { + validatorBackend + .getExternalPartyBalance(externalParty) + .totalUnlockedCoin shouldBe "40.0000000000" + } + onboarding + } + + onboardUserAndTapAmulet(aliceValidatorBackend, aliceValidatorWalletClient) + + // account for the cancellation + val newSynchronizerSerial = decentralizedSynchronizerPSId.serial + NonNegativeInt.two + val successorPsid = decentralizedSynchronizerPSId.copy( + serial = newSynchronizerSerial, + protocolVersion = successorPv, + ) + // Upload after starting validator which connects to global + // synchronizers as upload_dar_unless_exists vets on all + // connected synchronizers. + aliceValidatorBackend.participantClient.upload_dar_unless_exists(splitwellDarPath) + val externalPartyOnboarding = clue("Create external party and transfer 40 amulet to it") { + createExternalParty(aliceValidatorBackend, aliceValidatorWalletClient) + } + + val bobValidatorWalletLocal = wc( + "bobValidatorWalletLocal" + ) + clue("Start bob validator local, onboard and tap before upgrade") { + runBobValidatorWithStandaloneParticipant("before-upgrade")( + onboardUserAndTapAmulet( + bobValidatorLocal, + bobValidatorWalletLocal, + ) + ) + } + + val lateJoiningNode = sv4Nodes + lateJoiningNode.par.foreach(_.stop()) + val topologyFreezeTime = CantonTimestamp.now() + // We need to give enough time for the new Canton instance to startup + // and finish sequencer initialization so we can then publish the sequencer announcement before the upgrade time. + val upgradeTime = CantonTimestamp.now().plusSeconds(150) + clue(s"Schedule logical synchronizer upgrade at $upgradeTime") { + scheduleLsu(topologyFreezeTime, upgradeTime, newSynchronizerSerial.value.toLong) + } + val allBackends = Seq(sv1Backend, sv2Backend, sv3Backend, sv4Backend) + val initialSvNodesDoingTheLsu = Seq(sv1Backend, sv2Backend, sv3Backend) clue( "Pause traffic transfer trigger on sv2 to simulate a participant that is connected to a non initialized sequencer past upgrade tiem" ) { @@ -593,7 +672,7 @@ class LsuIntegrationTest inside(sv1ScanBackend.listDsoSequencers()) { case Seq(DomainSequencers(synchronizerId, sequencers)) => synchronizerId shouldBe decentralizedSynchronizerId - sequencers should have size 11 + sequencers should have size 12 sequencers.groupBy(_.svName).foreach { case (sv, sequencers) => clue(s"check sequencers for $sv") { forExactly(1, sequencers) { sequencer => @@ -605,6 +684,13 @@ class LsuIntegrationTest sequencer.serial.value shouldBe newSynchronizerSerial.value.toLong sequencer.migrationId shouldBe -1 } + else { + // sv4 still reports the old serial until it upgrades + forExactly(1, sequencers) { sequencer => + sequencer.serial.value shouldBe 1 + sequencer.migrationId shouldBe -1 + } + } forExactly(1, sequencers) { sequencer => sequencer.serial should be(empty) sequencer.migrationId shouldBe 0 @@ -891,16 +977,6 @@ class LsuIntegrationTest retryProvider, ) - private def countTapsFromScan(scan: ScanAppBackendReference, tapAmount: BigDecimal) = { - listTransactionsFromScan(scan).count( - _.tap.map(a => BigDecimal(a.amuletAmount)).contains(tapAmount) - ) - } - - private def listTransactionsFromScan(scan: ScanAppBackendReference) = { - scan.listTransactions(None, TransactionHistoryRequest.SortOrder.Asc, 100) - } - private def getSequencerUrlsConfiguredForTheSync( participantConnection: ParticipantClientReference, synchronizerAlias: SynchronizerAlias, diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ManualSignatureIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ManualSignatureIntegrationTest.scala index 9c6ec90f0f..60d50c974e 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ManualSignatureIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ManualSignatureIntegrationTest.scala @@ -6,7 +6,6 @@ import com.digitalasset.canton.crypto.SigningPublicKey import com.digitalasset.canton.topology.Namespace import com.digitalasset.canton.topology.admin.grpc.TopologyStoreId import com.digitalasset.canton.topology.store.TimeQuery -import org.lfdecentralizedtrust.splice.config.ConfigTransforms.updateAllScanAppConfigs import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.IntegrationTest import org.lfdecentralizedtrust.splice.util.WalletTestUtil @@ -25,12 +24,6 @@ class ManualSignatureIntegrationTest .withManualStart .withoutAliceValidatorConnectingToSplitwell .withSequencerConnectionsFromScanDisabled() - .addConfigTransforms((_, config) => - updateAllScanAppConfigs((_, config) => - // Sequencer is returning TRAFFIC_CONTROL_DISABLED when looking up traffic summaries. - config.copy(enableAppActivityRecordAndTrafficIngestion = false) - )(config) - ) } "synchronizer" should { diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/MultiHostValidatorOperatorIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/MultiHostValidatorOperatorIntegrationTest.scala index 163e7a43d4..277c7d057f 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/MultiHostValidatorOperatorIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/MultiHostValidatorOperatorIntegrationTest.scala @@ -1,11 +1,9 @@ package org.lfdecentralizedtrust.splice.integration.tests import com.digitalasset.canton.topology.transaction.* -import org.lfdecentralizedtrust.splice.http.v0.definitions.TransactionHistoryRequest import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.IntegrationTest import org.lfdecentralizedtrust.splice.util.WalletTestUtil -import org.lfdecentralizedtrust.splice.store.Limit import java.nio.file.Files import java.util.UUID @@ -212,19 +210,8 @@ class MultiHostValidatorOperatorIntegrationTest extends IntegrationTest with Wal )( "The send succeeds despite alice's validator being disconnected and stopped", _ => { - // Fees eat up quite a bit splitwellWalletClient.balance().unlockedQty should be(60) - // Alice's wallet is stopped, so we confirm the transaction via scan - sv1ScanBackend - .listTransactions( - None, - TransactionHistoryRequest.SortOrder.Desc, - Limit.DefaultMaxPageSize, - ) - .flatMap(_.transfer) - .filter(tf => - tf.description == transferDescription - ) should not be empty withClue "transfers splitwell to alice" + // don't check on alice's wallet as it's stopped. the transaction going through on splitwell is enough signal. }, ) } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ParticipantKmsIdentitiesEnterpriseIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ParticipantKmsIdentitiesIntegrationTest.scala similarity index 99% rename from apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ParticipantKmsIdentitiesEnterpriseIntegrationTest.scala rename to apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ParticipantKmsIdentitiesIntegrationTest.scala index fd73c1fbcc..fdb541a237 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ParticipantKmsIdentitiesEnterpriseIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ParticipantKmsIdentitiesIntegrationTest.scala @@ -16,7 +16,7 @@ import org.lfdecentralizedtrust.splice.util.StandaloneCanton import java.nio.file.{Path, Paths} -class ParticipantKmsIdentitiesEnterpriseIntegrationTest +class ParticipantKmsIdentitiesIntegrationTest extends IntegrationTestWithIsolatedEnvironment with StandaloneCanton { diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ReconcileBftSequencingParametersIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ReconcileBftSequencingParametersIntegrationTest.scala index 47ec306f39..4eb222f4b8 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ReconcileBftSequencingParametersIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ReconcileBftSequencingParametersIntegrationTest.scala @@ -1,10 +1,15 @@ package org.lfdecentralizedtrust.splice.integration.tests import com.digitalasset.canton.config.CantonRequireTypes.InstanceName -import com.digitalasset.canton.synchronizer.sequencer.block.bftordering.framework.data.topology.SequencingParameters +import com.digitalasset.canton.config.PositiveFiniteDuration +import com.digitalasset.canton.synchronizer.sequencer.block.bftordering.framework.data.topology.{ + BlacklistLeaderSelectionPolicyConfig, + SequencingParameters, +} import org.lfdecentralizedtrust.splice.config.ConfigTransforms import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.IntegrationTest +import org.lfdecentralizedtrust.splice.sv.config.BftSequencingParameters import org.lfdecentralizedtrust.splice.util.StandaloneCanton class SvReconcileBftSequencingParametersIntegrationTest @@ -27,13 +32,25 @@ class SvReconcileBftSequencingParametersIntegrationTest (InstanceName.tryCreate("sv1Local") -> c.svApps(InstanceName.tryCreate("sv1")) .copy( - cantonBftSequencingParameters = None + cantonBftSequencingParameters = Some( + BftSequencingParameters( + pbftViewChangeTimeout = PositiveFiniteDuration.ofSeconds(5), + segmentLength = SequencingParameters.DefaultSegmentLength.length, + blacklistLeaderSelectionPolicyConfig = + SequencingParameters.DefaultLeaderSelectionPolicyConfig.copy( + howLongToBlacklist = + BlacklistLeaderSelectionPolicyConfig.HowLongToBlacklist.Linear( + maximumEpochBlacklisted = Some(250L) + ) + ), + ) + ) )) ), ) .withManualStart - "SV automation can set and unset bft sequencing parameters" in { implicit env => + "SV automation can modify bft sequencing parameters" in { implicit env => withCantonSvNodes( ( Some(sv1Backend), @@ -56,12 +73,26 @@ class SvReconcileBftSequencingParametersIntegrationTest .value bftParameters.pbftViewChangeTimeout shouldBe com.digitalasset.canton.time.PositiveFiniteDuration .tryOfSeconds(5) + bftParameters.blacklistLeaderSelectionPolicyConfig.howLongToBlacklist shouldBe a[ + BlacklistLeaderSelectionPolicyConfig.HowLongToBlacklist.Exponential + ] sv1Backend.stop() - actAndCheck("Restart with sequencing parameters unset", sv1LocalBackend.startSync())( - "sequencing parameters are unset", + actAndCheck( + "Restart with modified bft sequencing parameters", + sv1LocalBackend.startSync(), + )( + "sequencing parameters are modified", _ => { - sv1LocalBackend.participantClient.topology.sequencing_parameters - .list(decentralizedSynchronizerId) shouldBe empty + val parameters = sv1Backend.participantClient.topology.sequencing_parameters + .list(decentralizedSynchronizerId) + .loneElement + val bytes = parameters.item.payload.value + val bftParameters = SequencingParameters + .fromByteString(sv1Backend.config.localSynchronizerNodes.current.protocolVersion, bytes) + .value + bftParameters.blacklistLeaderSelectionPolicyConfig.howLongToBlacklist shouldBe a[ + BlacklistLeaderSelectionPolicyConfig.HowLongToBlacklist.Linear + ] }, ) sv1LocalBackend.stop() diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/RecoverExternalPartyIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/RecoverExternalPartyIntegrationTest.scala index d08ded7642..fbacabfd1a 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/RecoverExternalPartyIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/RecoverExternalPartyIntegrationTest.scala @@ -31,7 +31,7 @@ class RecoverExternalPartyIntegrationTest with WalletTestUtil { override def environmentDefinition: EnvironmentDefinition = - EnvironmentDefinition.simpleTopology1Sv(this.getClass.getSimpleName) + EnvironmentDefinition.simpleTopology1Sv(this.getClass.getSimpleName).withTransferCommandSupport override protected lazy val sanityChecksIgnoredRootCreates = Seq( ValidatorRewardCoupon.TEMPLATE_ID_WITH_PACKAGE_ID diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/RollForwardLsuDRIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/RollForwardLsuDRIntegrationTest.scala index 7c358bf7bb..12e65d9f18 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/RollForwardLsuDRIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/RollForwardLsuDRIntegrationTest.scala @@ -85,7 +85,7 @@ class RollForwardLsuDRIntegrationTest val trafficExportTimeFile = File.newTemporaryFile() // This needs to be long enough in the future that we can start Canton and initialize for SVs. - val maxSequencingTime = CantonTimestamp.now().plusSeconds(210) + val maxSequencingTime = CantonTimestamp.now().plusSeconds(240) // 5s chosen by fair dice roll val lowerBoundSequencingTimeExclusive = maxSequencingTime.plusSeconds(5) val upgradeTime = lowerBoundSequencingTimeExclusive diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/RollForwardLsuIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/RollForwardLsuIntegrationTest.scala index 8788c3338d..f1ed840381 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/RollForwardLsuIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/RollForwardLsuIntegrationTest.scala @@ -18,7 +18,6 @@ import org.lfdecentralizedtrust.splice.environment.{ MediatorAdminConnection, SequencerAdminConnection, } -import org.lfdecentralizedtrust.splice.http.v0.definitions.TransactionHistoryRequest import monocle.macros.syntax.lens.* import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.IntegrationTest @@ -164,14 +163,13 @@ class RollForwardLsuIntegrationTest startAllSync(allNodes*) actAndCheck("Create some transaction history", sv1WalletClient.tap(1337))( - "Scan transaction history is recorded and wallet balance is updated", + "Wallet balance is updated", _ => { // buffer to account for domain fee payments assertInRange( sv1WalletClient.balance().unlockedQty, (walletUsdToAmulet(1000), walletUsdToAmulet(2000)), ) - countTapsFromScan(sv1ScanBackend, walletUsdToAmulet(1337)) shouldBe 1 }, ) @@ -429,16 +427,6 @@ class RollForwardLsuIntegrationTest retryProvider, ) - private def countTapsFromScan(scan: ScanAppBackendReference, tapAmount: BigDecimal) = { - listTransactionsFromScan(scan).count( - _.tap.map(a => BigDecimal(a.amuletAmount)).contains(tapAmount) - ) - } - - private def listTransactionsFromScan(scan: ScanAppBackendReference) = { - scan.listTransactions(None, TransactionHistoryRequest.SortOrder.Asc, 100) - } - private def getSequencerUrlSet( participantConnection: ParticipantClientReference, synchronizerAlias: SynchronizerAlias, diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanHistoryBackfillingIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanHistoryBackfillingIntegrationTest.scala index 7eb0263a7e..619432c55e 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanHistoryBackfillingIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanHistoryBackfillingIntegrationTest.scala @@ -1,6 +1,9 @@ package org.lfdecentralizedtrust.splice.integration.tests import com.daml.ledger.javaapi.data.Transaction +import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.* +import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.actionrequiringconfirmation.* +import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.dsorules_actionrequiringconfirmation.* import org.lfdecentralizedtrust.splice.config.ConfigTransforms import org.lfdecentralizedtrust.splice.config.ConfigTransforms.{ ConfigurableApp, @@ -20,7 +23,11 @@ import org.lfdecentralizedtrust.splice.scan.automation.{ DeleteCorruptAcsSnapshotTrigger, ScanHistoryBackfillingTrigger, } -import org.lfdecentralizedtrust.splice.store.{PageLimit, TreeUpdateWithMigrationId} +import org.lfdecentralizedtrust.splice.store.{ + PageLimit, + TreeUpdateWithMigrationId, + VoteResultsFilters, +} import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.AdvanceOpenMiningRoundTrigger import org.lfdecentralizedtrust.splice.util.{EventId, UpdateHistoryTestUtil, WalletTestUtil} import com.digitalasset.canton.config.NonNegativeFiniteDuration @@ -29,12 +36,12 @@ import com.digitalasset.canton.data.CantonTimestamp import scala.math.BigDecimal.javaBigDecimal2bigDecimal import com.digitalasset.canton.{HasActorSystem, HasExecutionContext} import org.lfdecentralizedtrust.splice.automation.TxLogBackfillingTrigger -import org.lfdecentralizedtrust.splice.http.v0.definitions.TransactionHistoryRequest.SortOrder import org.lfdecentralizedtrust.splice.scan.store.TxLogEntry import org.lfdecentralizedtrust.splice.store.MultiDomainAcsStore.TxLogBackfillingState import org.lfdecentralizedtrust.splice.store.UpdateHistory.BackfillingState import org.scalactic.source.Position +import java.util.Optional import scala.annotation.nowarn import scala.jdk.CollectionConverters.* import scala.jdk.OptionConverters.* @@ -129,6 +136,39 @@ class ScanHistoryBackfillingIntegrationTest }, ) + // we create votes as they produce txlog entries that can be backfilled + actAndCheck( + "Create vote", { + val action: ActionRequiringConfirmation = + new ARC_DsoRules( + new SRARC_SetConfig( + new DsoRules_SetConfig( + sv1Backend + .getDsoInfo() + .dsoRules + .payload + .config, + Optional.empty(), + ) + ) + ) + + sv1Backend.createVoteRequest( + sv1Backend.getDsoInfo().svParty.toProtoPrimitive, + action, + "url", + "description", + sv1Backend.getDsoInfo().dsoRules.payload.config.voteRequestTimeout, + None, + ) + }, + )( + "Vote has been executed", + _ => { + sv1ScanBackend.listVoteRequestResults(VoteResultsFilters(), 100)._1 should have size (1) + }, + ) + // The current round, as seen by the given scan service (reflects the state of the scan app store) def currentRoundInScan(backend: ScanAppBackendReference): Long = backend.getLatestOpenMiningRound(CantonTimestamp.now()).contract.payload.round.number @@ -466,18 +506,9 @@ class ScanHistoryBackfillingIntegrationTest .loneElement shouldBe a[TxLogBackfillingTrigger.InitializeBackfillingTask] } - clue("TxLog based historical queries differ") { - val sv1Transactions = - sv1ScanBackend.listTransactions(None, SortOrder.Asc, 1000).map(shortDebugDescription) - val sv2Transactions = - sv2ScanBackend.listTransactions(None, SortOrder.Asc, 1000).map(shortDebugDescription) - - // We tapped 4 times before SV2 joined, and once after - sv1Transactions.size should be >= 5 withClue "SV1 txns" - sv2Transactions.size should be >= 1 withClue "SV2 txns" - sv1Transactions.size should be > sv2Transactions.size withClue "SV1 txns" - sv1Transactions should contain allElementsOf sv2Transactions withClue "sv1 transactions" - sv2Transactions should not contain sv1Transactions.headOption.value withClue "sv2 transactions" + clue("TxLog based vote result result differ") { + sv1ScanBackend.listVoteRequestResults(VoteResultsFilters(), 100)._1 should have size (1) + sv2ScanBackend.listVoteRequestResults(VoteResultsFilters(), 100)._1 should be(empty) } actAndCheck( @@ -524,14 +555,13 @@ class ScanHistoryBackfillingIntegrationTest }, ) - clue("TxLog based historical queries return same results") { - val sv1Transactions = - sv1ScanBackend.listTransactions(None, SortOrder.Asc, 1000).map(shortDebugDescription) - val sv2Transactions = - sv2ScanBackend.listTransactions(None, SortOrder.Asc, 1000).map(shortDebugDescription) - - // TODO(#666): switch to theSameElementsInOrderAs once the endpoint sorts by record time instead of row id. - sv1Transactions should contain theSameElementsAs sv2Transactions withClue "SV1/2 txns" + clue("TxLog based vote result queries return same results") { + // Not quite sure why we need the eventually given that we sync on backfilling completing but without that sv2ScanBackend can still return an empty list. + eventually() { + sv1ScanBackend.listVoteRequestResults(VoteResultsFilters(), 100)._1 shouldBe sv2ScanBackend + .listVoteRequestResults(VoteResultsFilters(), 100) + ._1 + } } } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanIntegrationTest.scala index 2bbbb82560..0bbcb18f67 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanIntegrationTest.scala @@ -3,9 +3,7 @@ package org.lfdecentralizedtrust.splice.integration.tests import com.digitalasset.canton.concurrent.Threading import com.digitalasset.canton.config.NonNegativeFiniteDuration import com.digitalasset.canton.config.RequireTypes.NonNegativeInt -import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.discard.Implicits.DiscardOps -import com.digitalasset.canton.topology.PartyId import org.apache.pekko.http.scaladsl.Http import org.apache.pekko.http.scaladsl.client.RequestBuilding.{Get, Post} import org.apache.pekko.http.scaladsl.model.{ContentTypes, HttpEntity, StatusCodes} @@ -19,10 +17,6 @@ import org.lfdecentralizedtrust.splice.config.ConfigTransforms.{ updateAutomationConfig, ConfigurableApp, } -import org.lfdecentralizedtrust.splice.http.v0.definitions.{ - TransactionHistoryRequest, - TransactionHistoryResponseItem, -} import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.{ IntegrationTestWithIsolatedEnvironment, @@ -82,9 +76,10 @@ class ScanIntegrationTest // used for the rate limit test rateLimiting = config.parameters.rateLimiting.copy( rateLimiters = - config.parameters.rateLimiting.rateLimiters + ("listAnsEntries" -> SpliceRateLimitConfig( - ratePerSecond = 5 - )) + config.parameters.rateLimiting.rateLimiters + ("listAnsEntries" -> SpliceRateLimitConfig + .WithPerClientIp( + ratePerSecond = 5 + )) ), ), ) @@ -166,107 +161,6 @@ class ScanIntegrationTest spliceInstanceNames.nameServiceNameAcronym should be("ANS") } - "list transaction pages in ascending and descending order" in { implicit env => - val aliceWalletUser = onboardWalletUser(aliceWalletClient, aliceValidatorBackend) - def tapsForAlice = (t: TransactionHistoryResponseItem) => - t.tap.exists { tap => - PartyId.tryFromProtoPrimitive(tap.amuletOwner) == aliceWalletUser - } - - val nrTaps = 10 - val amuletAmounts = (1 to nrTaps).map(walletUsdToAmulet(_)) - val pageSize = nrTaps / 2 - // filtering for Alice to avoid interference by the top up taps - def collectAllTapPagesForAlice(sortOrder: TransactionHistoryRequest.SortOrder) = { - LazyList - .iterate(sv1ScanBackend.listTransactions(None, sortOrder, pageSize)) { page => - sv1ScanBackend.listTransactions(page.lastOption.map(_.eventId), sortOrder, pageSize) - } - .takeWhile(_.nonEmpty) - .foldLeft(Seq.empty[TransactionHistoryResponseItem])(_ ++ _) - .filter(tapsForAlice) - } - - def toAmuletAmounts(page: Seq[TransactionHistoryResponseItem]) = - page.flatMap(_.tap.map(t => BigDecimal(t.amuletAmount))) - - actAndCheck( - "Tap amulets for Alice", { - (1 to nrTaps).foreach { i => - aliceWalletClient.tap(BigDecimal(i)) - } - }, - )( - "Amulets should appear in Alice's wallet", - _ => { - aliceWalletClient.list().amulets should have length nrTaps.toLong - }, - ) - - eventually() { - val latestRound = - sv1ScanBackend.getLatestOpenMiningRound(CantonTimestamp.now()).contract.payload.round.number - val asc = TransactionHistoryRequest.SortOrder.Asc - val desc = TransactionHistoryRequest.SortOrder.Desc - val allPagesAsc = collectAllTapPagesForAlice(asc) - val allPagesDesc = collectAllTapPagesForAlice(desc) - allPagesAsc.map(_.round) should contain only Some( - latestRound - ) withClue "alice tap pages' rounds" - - val tapsFirstPageAscending = allPagesAsc.take(pageSize) - - toAmuletAmounts(tapsFirstPageAscending) should be( - amuletAmounts.take(pageSize) - ) - - val firstPageEndEventId = tapsFirstPageAscending.last.eventId - val tapsSecondPageAscending = allPagesAsc.slice(pageSize, pageSize + pageSize) - sv1ScanBackend - .listTransactions( - Some(firstPageEndEventId), - TransactionHistoryRequest.SortOrder.Asc, - pageSize.toInt, - ) - .filter(tapsForAlice) - - toAmuletAmounts(tapsSecondPageAscending) should be( - amuletAmounts.slice(pageSize, pageSize + pageSize) - ) - - sv1ScanBackend - .listTransactions( - Some(tapsSecondPageAscending.last.eventId), - asc, - pageSize.toInt, - ) - .filter(tapsForAlice) should be(empty) - - val tapsFirstPageDescending = allPagesDesc.take(pageSize) - toAmuletAmounts(tapsFirstPageDescending) should be( - amuletAmounts.reverse.take(pageSize) - ) - - val tapsSecondPageDescending = - allPagesDesc.slice(pageSize, pageSize + pageSize) - - sv1ScanBackend - .listTransactions( - Some(tapsSecondPageDescending.last.eventId), - TransactionHistoryRequest.SortOrder.Desc, - pageSize.toInt, - ) - .filter(tapsForAlice) should be(empty) - - toAmuletAmounts(tapsSecondPageDescending) should be( - amuletAmounts.reverse.slice(pageSize, pageSize + pageSize) - ) - toAmuletAmounts( - tapsFirstPageAscending ++ tapsSecondPageAscending - ) should be(toAmuletAmounts((tapsFirstPageDescending ++ tapsSecondPageDescending).reverse)) - } - } - "getUpdateHistory should return 400 for invalid after timestamp" in { implicit env => import env.{actorSystem, executionContext} registerHttpConnectionPoolsCleanup(env) @@ -300,10 +194,14 @@ class ScanIntegrationTest bftSequencers should have size 2 val expectedSequencerId = sv1Backend.appState.localSynchronizerNodes.current.sequencerAdminConnection.getSequencerId.futureValue - val currentSequencer = bftSequencers.find(_.url == "http://testUrl:8081").value - currentSequencer.id shouldBe expectedSequencerId - val legacySequencer = bftSequencers.find(_.url == "http://legacyUrl:8082").value - legacySequencer.id shouldBe expectedSequencerId + forExactly(1, bftSequencers) { sequencer => + sequencer.url shouldBe "http://testurl:8081" + sequencer.id shouldBe expectedSequencerId + } + forExactly(1, bftSequencers) { sequencer => + sequencer.url shouldBe "http://legacyurl:8082" + sequencer.id shouldBe expectedSequencerId + } } "respect rate limit" in { implicit env => @@ -337,7 +235,11 @@ class ScanIntegrationTest // then 5 every second // first second is 5 (full capacity) + 5 (capacity added after consumption) // then 5 every second - val maxAccepted = 30 + // The 50 calls are emitted at 10/s, so ~5s of refill gives 30 in the ideal case. Allow one + // more second of refill: throttle jitter or a slow first call stretches the window past 5s + // and lets a further batch through (seen accepting 31). This is still far below the 50 + // attempted, so the assertion keeps proving that the limiter rejects. + val maxAccepted = 35 // account for bursts in the stream used to rate limit the calls in `runRateLimited` val minAccepted = 10 results.count(identity) should (be >= minAccepted and be <= maxAccepted) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanTimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanTimeBasedIntegrationTest.scala index ea4371ab30..a38a4968a1 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanTimeBasedIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanTimeBasedIntegrationTest.scala @@ -22,7 +22,7 @@ import org.lfdecentralizedtrust.splice.http.v0.definitions.DamlValueEncoding.mem import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.IntegrationTestWithIsolatedEnvironment import org.lfdecentralizedtrust.splice.scan.admin.http.CompactJsonScanHttpEncodings -import org.lfdecentralizedtrust.splice.scan.config.BulkStorageConfig +import org.lfdecentralizedtrust.splice.scan.config.{BulkStorageConfig, ScanStorageConfig} import org.lfdecentralizedtrust.splice.scan.config.ScanStorageConfigs.scanStorageConfigV1 import org.lfdecentralizedtrust.splice.store.{HasS3Mock, S3BucketConnectionForTests} import org.lfdecentralizedtrust.splice.store.UpdateHistory.BackfillingState @@ -71,6 +71,8 @@ class ScanTimeBasedIntegrationTest updatesPollingInterval = NonNegativeFiniteDuration.ofSeconds(5), staging = Some(s3ConfigMock("staging")), committed = Some(s3ConfigMock("committed")), + bftCheckEnabled = + false, // bft checks don't work with a single scan. The bft functionality is tested in the unit test. ), publicUrl = Some(Uri("http://foo.bar.com")), ) @@ -252,7 +254,7 @@ class ScanTimeBasedIntegrationTest .getDateOfFirstSnapshotAfter(CantonTimestamp.tryFromInstant(snapshot1.value.toInstant), 0) .value shouldBe snapshotAfter.value - val snapshotAfterData = sv1ScanBackend.getAcsSnapshotAtV1( + val snapshotAfterData = sv1ScanBackend.getAcsSnapshotAtV2( CantonTimestamp.assertFromInstant(snapshotAfter.value.toInstant), migrationId, templates = Some( @@ -269,10 +271,10 @@ class ScanTimeBasedIntegrationTest val atOrBefore = getLedgerTime // afOrBefore should return the same ACS snapshot as the exact time given by snapshotAfter - val snapshotAtOrBeforeAfterData = sv1ScanBackend.getAcsSnapshotAtV1( + val snapshotAtOrBeforeAfterData = sv1ScanBackend.getAcsSnapshotAtV2( CantonTimestamp.assertFromInstant(atOrBefore.toInstant), migrationId, - recordTimeMatch = Some(definitions.AcsRequest.RecordTimeMatch.AtOrBefore), + recordTimeMatch = Some(definitions.AcsRequestV2.RecordTimeMatch.AtOrBefore), templates = Some( Vector( PackageQualifiedName.fromJavaCodegenCompanion(Amulet.COMPANION), @@ -285,10 +287,10 @@ class ScanTimeBasedIntegrationTest snapshotAfterData shouldBe snapshotAtOrBeforeAfterData snapshotAtOrBeforeAfterData.value.recordTime shouldBe snapshotAfter.value - sv1ScanBackend.getAcsSnapshotAtV1( + sv1ScanBackend.getAcsSnapshotAtV2( CantonTimestamp.assertFromInstant(atOrBefore.toInstant), migrationId, - recordTimeMatch = Some(definitions.AcsRequest.RecordTimeMatch.Exact), + recordTimeMatch = Some(definitions.AcsRequestV2.RecordTimeMatch.Exact), templates = Some( Vector( PackageQualifiedName.fromJavaCodegenCompanion(Amulet.COMPANION), @@ -412,6 +414,10 @@ class ScanTimeBasedIntegrationTest migrationId, ownerPartyIds = Vector(aliceUserParty), recordTimeMatch = Some(definitions.HoldingsSummaryRequest.RecordTimeMatch.AtOrBefore), + // as_of_round defaults to the earliest open mining round at request time, and the + // advanceTime above lets the rounds advance, so pin it to the round the exact query + // resolved. Otherwise the holding fees derived from it differ by one round's worth. + asOfRound = holdingsSummary.map(_.computedAsOfRound), ) holdingsSummaryAtOrBefore shouldBe holdingsSummary @@ -442,7 +448,8 @@ class ScanTimeBasedIntegrationTest val endTime = getLedgerTime val lastMidnight = endTime.toInstant.truncatedTo(ChronoUnit.DAYS); val nextMidnight = lastMidnight.plus(1, ChronoUnit.DAYS) - val expectedAcsSnapshotKey = s"$lastMidnight~$nextMidnight/ACS_0.zstd" + val expectedAcsSnapshotKey = + s"$lastMidnight~$nextMidnight/${ScanStorageConfig.Encoding.CompactJson.storageKey("ACS", 0)}" val committedBucketConnection = new S3BucketConnectionForTests(s3ConfigMock("committed"), loggerFactory) @@ -470,12 +477,16 @@ class ScanTimeBasedIntegrationTest // at last midnight committedS3Objs .map(_.key()) - .filter(_.endsWith(s"~$lastMidnight/updates_0.zstd")) should not be empty + .filter( + _.endsWith( + s"~$lastMidnight/${ScanStorageConfig.Encoding.CompactJson.storageKey("updates", 0)}" + ) + ) should not be empty // Compare bulk storage data to hot storage data from scan // TODO(#4788): for now, bulk storage still uses v0, so we use that here as well val acsAtMidnightFromScan = sv1ScanBackend - .getAcsSnapshotAtV1(CantonTimestamp.assertFromInstant(lastMidnight), 0) + .getAcsSnapshotAtV2(CantonTimestamp.assertFromInstant(lastMidnight), 0) .value .createdEvents val acsObjUrl = getSnapshotResponse.objectRefs.head.url diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SplitwellFrontendIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SplitwellFrontendIntegrationTest.scala index ea28a43723..c257e5de95 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SplitwellFrontendIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SplitwellFrontendIntegrationTest.scala @@ -31,10 +31,6 @@ class SplitwellFrontendIntegrationTest EnvironmentDefinition .simpleTopology1Sv(this.getClass.getSimpleName) .withAdditionalSetup(implicit env => { - EnvironmentDefinition - .simpleTopology1Sv(this.getClass.getSimpleName) - .setup(env) - aliceValidatorBackend.participantClient.upload_dar_unless_exists(splitwellDarPath) bobValidatorBackend.participantClient.upload_dar_unless_exists(splitwellDarPath) }) @@ -178,11 +174,21 @@ class SplitwellFrontendIntegrationTest } } - eventually() { - // Check final amounts in the wallets - checkWallet(aliceUserParty, aliceWalletClient, Seq((400.0, 400))) - checkWallet(bobUserParty, bobWalletClient, Seq((39.0, 39.0))) - checkWallet(charlieUserParty, charlieWalletClient, Seq((111.0, 111.0))) + withClue("check final amounts in the wallets") { + val waitTime = 20.seconds + checkWallet( + aliceUserParty, + aliceWalletClient, + Seq((400.0, 400)), + timeUntilSuccess = waitTime, + ) + checkWallet(bobUserParty, bobWalletClient, Seq((39.0, 39.0)), timeUntilSuccess = waitTime) + checkWallet( + charlieUserParty, + charlieWalletClient, + Seq((111.0, 111.0)), + timeUntilSuccess = waitTime, + ) } } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvFrontendIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvFrontendIntegrationTest.scala index c23c7114c3..20be650e0b 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvFrontendIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvFrontendIntegrationTest.scala @@ -22,6 +22,7 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.{ import org.lfdecentralizedtrust.splice.config.ConfigTransforms import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.SpliceTestConsoleEnvironment +import org.lfdecentralizedtrust.splice.store.VoteResultsFilters import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.CloseVoteRequestTrigger import org.lfdecentralizedtrust.splice.util.SpliceUtil.defaultDsoRulesConfig import org.lfdecentralizedtrust.splice.util.* @@ -30,6 +31,7 @@ import org.openqa.selenium.support.ui.Select import org.slf4j.event.Level import scala.jdk.CollectionConverters.* +import scala.jdk.OptionConverters.* import java.util.Optional class SvFrontendIntegrationTest @@ -61,7 +63,7 @@ class SvFrontendIntegrationTest }, )( "logged in in the sv ui", - _ => find(id("app-title")).value.text should matchText("SUPER VALIDATOR OPERATIONS"), + _ => find(id("app-title")).value.text should matchText("Supervalidator Operations"), ) } } @@ -1417,9 +1419,10 @@ class SvFrontendIntegrationTest } } - "NEW UI: Grant and Revoke Featured App Right" in { implicit env => + "NEW UI: Grant, Update and Revoke Featured App Right" in { implicit env => val providerParty = sv3Backend.getDsoInfo().svParty val providerPartyId = providerParty.toProtoPrimitive + val activityWeight = BigDecimal("2.5") // First, create a Grant proposal for the provider. val grantProposalContractId = assertCreateProposal( @@ -1427,27 +1430,13 @@ class SvFrontendIntegrationTest "grant-featured-app", ) { implicit webDriver => fillOutTextField("grant-featured-app-idValue", providerPartyId) + fillOutTextField("grant-featured-app-activityWeight", activityWeight.toString) } clue("vote the grant request to execution before creating revoke request") { val grantTrackingCid = eventually() { - val voteRequest: Contract[VoteRequest.ContractId, VoteRequest] = sv1Backend - .listVoteRequests() - .find { request => - val requestCid = request.contractId.contractId - val trackingCid = - if (request.payload.trackingCid.isPresent) { - Some(request.payload.trackingCid.get.contractId) - } else { - None - } - requestCid == grantProposalContractId || trackingCid.contains(grantProposalContractId) - } - .getOrElse( - fail( - s"Could not find vote request for grant proposal contract id: $grantProposalContractId" - ) - ) + val voteRequest: Contract[VoteRequest.ContractId, VoteRequest] = + getVoteRequestForProposal(grantProposalContractId) if (voteRequest.payload.trackingCid.isPresent) voteRequest.payload.trackingCid.get else voteRequest.contractId @@ -1462,7 +1451,42 @@ class SvFrontendIntegrationTest } eventually() { - sv1ScanBackend.lookupFeaturedAppRight(providerParty) shouldBe a[Some[?]] + val featuredAppRight = sv1ScanBackend.lookupFeaturedAppRight(providerParty) + featuredAppRight shouldBe a[Some[?]] + featuredAppRight.value.payload.activityWeight.toScala.map( + BigDecimal(_) + ) shouldBe Some(activityWeight) + } + } + + val newActivityWeight = BigDecimal("3.0") + + val updateProposalContractId = assertCreateProposal( + "SRARC_UpdateFeaturedAppRight", + "update-featured-app", + ) { implicit webDriver => + fillOutTextField("update-featured-app-partyId", providerPartyId) + selectFirstMuiOption("update-featured-app-rightCid-dropdown") + fillOutTextField("update-featured-app-activityWeight", newActivityWeight.toString) + } + + clue("vote the update request to execution") { + val updateTrackingCid = eventually() { + val voteRequest = getVoteRequestForProposal(updateProposalContractId) + if (voteRequest.payload.trackingCid.isPresent) voteRequest.payload.trackingCid.get + else voteRequest.contractId + } + + eventuallySucceeds() { + sv3Backend.castVote(updateTrackingCid, isAccepted = true, "", "") + } + + eventually() { + val featuredAppRight = sv1ScanBackend.lookupFeaturedAppRight(providerParty) + featuredAppRight shouldBe a[Some[?]] + featuredAppRight.value.payload.activityWeight.toScala.map( + BigDecimal(_) + ) shouldBe Some(newActivityWeight) } } @@ -1553,7 +1577,7 @@ class SvFrontendIntegrationTest .listVoteRequests() .filter(_.payload.reason.body == "first request") shouldBe empty sv1Backend - .listVoteRequestResults(None, Some(false), None, None, None, 10) + .listVoteRequestResults(VoteResultsFilters(accepted = Some(false)), 10) ._1 .exists(_.request.reason.body == "first request") shouldBe true }, @@ -1591,7 +1615,7 @@ class SvFrontendIntegrationTest .listVoteRequests() .filter(_.payload.reason.body == "second request") shouldBe empty sv1Backend - .listVoteRequestResults(None, Some(false), None, None, None, 10) + .listVoteRequestResults(VoteResultsFilters(accepted = Some(false)), 10) ._1 .count(r => r.request.reason.body == "first request" || r.request.reason.body == "second request" @@ -1601,7 +1625,7 @@ class SvFrontendIntegrationTest // Verify ordering via backend API: most recently completed first clue("vote results are ordered by completion time descending") { - val (results, _) = sv1Backend.listVoteRequestResults(None, None, None, None, None, 10) + val (results, _) = sv1Backend.listVoteRequestResults(VoteResultsFilters(), 10) val ourResults = results.filter(r => r.request.reason.body == "first request" || r.request.reason.body == "second request" ) @@ -1613,13 +1637,13 @@ class SvFrontendIntegrationTest // Verify cursor-based pagination via backend API with limit=1 clue("pagination returns correct pages") { val (firstPage, firstPageToken) = - sv1Backend.listVoteRequestResults(None, None, None, None, None, 1) + sv1Backend.listVoteRequestResults(VoteResultsFilters(), 1) firstPage.size shouldBe 1 firstPage.head.request.reason.body shouldBe "second request" firstPageToken shouldBe defined val (secondPage, _) = - sv1Backend.listVoteRequestResults(None, None, None, None, None, 1, firstPageToken) + sv1Backend.listVoteRequestResults(VoteResultsFilters(), 1, firstPageToken) secondPage.size shouldBe 1 secondPage.head.request.reason.body shouldBe "first request" } @@ -1647,6 +1671,29 @@ class SvFrontendIntegrationTest } } + def getVoteRequestForProposal( + proposalContractId: String + )(implicit env: SpliceTestConsoleEnvironment) = { + val voteRequest: Contract[VoteRequest.ContractId, VoteRequest] = sv1Backend + .listVoteRequests() + .find { request => + val requestCid = request.contractId.contractId + val trackingCid = + if (request.payload.trackingCid.isPresent) { + Some(request.payload.trackingCid.get.contractId) + } else { + None + } + requestCid == proposalContractId || trackingCid.contains(proposalContractId) + } + .getOrElse( + fail( + s"Could not find vote request for proposal contract id: $proposalContractId" + ) + ) + voteRequest + } + def changeAction(actionName: String)(implicit webDriver: WebDriverType) = { eventually() { find( diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvOnboardingAddlIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvOnboardingAddlIntegrationTest.scala index cfccc75f9c..bff3b008fc 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvOnboardingAddlIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvOnboardingAddlIntegrationTest.scala @@ -13,6 +13,7 @@ import org.lfdecentralizedtrust.splice.sv.util.{SvOnboardingToken, SvUtil} import scala.jdk.OptionConverters.* import org.lfdecentralizedtrust.splice.sv.admin.api.client.commands.HttpSvPublicAppClient.SvOnboardingStatus import org.lfdecentralizedtrust.splice.util.{SvTestUtil, WalletTestUtil} +import com.digitalasset.canton.console.CommandFailure import com.digitalasset.canton.logging.SuppressionRule import com.digitalasset.canton.topology.transaction.ParticipantPermission import org.slf4j.event.Level @@ -319,8 +320,7 @@ class SvOnboardingAddlIntegrationTest forAll(lines)(line => line.message should include("Unexpected amulet create event")) // Error emitted by every ScanTxLogParser plus the one UserWalletTxLogParser // associated with the owner of the coin. - lines should have size 2 withClue "ScanTxLogParser + UserWalletTxLogParser error" - forExactly(1, lines)(line => line.loggerName should include("sv1Scan")) + lines should have size 1 withClue "UserWalletTxLogParser error" forExactly(1, lines)(line => line.loggerName should include("sv1Validator")) }, ) @@ -350,7 +350,11 @@ class SvOnboardingAddlIntegrationTest } clue("create a amulet again with actAs = DSO") { withCommandRetryPolicy(_ => _ => false) { - assertThrowsAndLogsCommandFailures( + // Suppress at ERROR level only: background WARNs (e.g. the SV app failing to read the + // bft sequencers list while sv2's scan is unavailable) must not fail the log assertion. + loggerFactory.assertThrowsAndLogsSuppressing[CommandFailure]( + SuppressionRule.LevelAndAbove(Level.ERROR) + )( createAmulet( sv1ValidatorBackend.participantClientWithAdminToken, sv1UserId, diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvOnboardingViaNonFoundingSvIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvOnboardingViaNonFoundingSvIntegrationTest.scala index 52c94bd898..76029cc09f 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvOnboardingViaNonFoundingSvIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvOnboardingViaNonFoundingSvIntegrationTest.scala @@ -26,6 +26,8 @@ class SvOnboardingViaNonFoundingSvIntegrationTest with SvTestUtil with StandaloneCanton { + // sv1 is stopped mid-test, so neither history check is meaningful here. + override protected def runUpdateHistorySanityCheck: Boolean = false override protected def runEventHistorySanityCheck: Boolean = false override def dbsSuffix: String = "non_sv1_svs" @@ -87,6 +89,8 @@ class SvOnboardingViaNonFoundingSvIntegrationTest }(configuration) }) .withManualStart + // Prevent flakes where the topology transaction gets dropped from outbox after disconnect and we're not retrying + .withSvBftSequencerConnectionDisabled() "A new SV can: 1) onboard via a non-sv1 while sv1 is offboarded from the DSO and " + "2) bootstrap using a sequencer that is not sv1's sequencer" in { implicit env => @@ -139,12 +143,13 @@ class SvOnboardingViaNonFoundingSvIntegrationTest } } endpoints.toSet shouldBe Set( - LocalSynchronizerNode.toEndpoint( - sv1Backend.config.localSynchronizerNodes.current.sequencer.internalApi - ), + // SV BFT sequencer connections are disabled +// LocalSynchronizerNode.toEndpoint( +// sv1Backend.config.localSynchronizerNodes.current.sequencer.internalApi +// ), LocalSynchronizerNode.toEndpoint( sv2Backend.config.localSynchronizerNodes.current.sequencer.internalApi - ), + ) ) sv2Backend.participantClient.synchronizers.is_connected( decentralizedSynchronizerAlias diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvStateManagementIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvStateManagementIntegrationTest.scala index dbc5676e16..b48d282e58 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvStateManagementIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvStateManagementIntegrationTest.scala @@ -35,6 +35,7 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.amuletrules_ import org.lfdecentralizedtrust.splice.config.ConfigTransforms import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.SpliceTestConsoleEnvironment +import org.lfdecentralizedtrust.splice.store.VoteResultsFilters import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.CloseVoteRequestTrigger import org.lfdecentralizedtrust.splice.util.{Codec, TriggerTestUtil} @@ -119,10 +120,14 @@ class SvStateManagementIntegrationTest extends SvIntegrationTestBase with Trigge sv1Backend.listVoteRequests() shouldBe empty withClue "VoteRequests" sv1Backend - .listVoteRequestResults(None, Some(false), None, None, None, 1) + .listVoteRequestResults(VoteResultsFilters(accepted = Some(false)), 1) ._1 .loneElement .outcome shouldBe a[VRO_Rejected] + + sv1Backend.countVoteRequestResults( + VoteResultsFilters(accepted = Some(false)) + ) shouldBe 1L withClue "vote result count" }, ) } @@ -160,7 +165,7 @@ class SvStateManagementIntegrationTest extends SvIntegrationTestBase with Trigge _ => { sv1Backend.listVoteRequests() shouldBe empty withClue "VoteRequests" sv1Backend - .listVoteRequestResults(None, Some(false), None, None, None, 1) + .listVoteRequestResults(VoteResultsFilters(accepted = Some(false)), 1) ._1 .loneElement .outcome shouldBe a[VRO_Expired] @@ -201,7 +206,7 @@ class SvStateManagementIntegrationTest extends SvIntegrationTestBase with Trigge _ => { sv1Backend.listVoteRequests() shouldBe empty withClue "VoteRequests" sv1Backend - .listVoteRequestResults(None, Some(false), None, None, None, 1) + .listVoteRequestResults(VoteResultsFilters(accepted = Some(false)), 1) ._1 .loneElement .outcome shouldBe a[VRO_Rejected] @@ -586,7 +591,7 @@ class SvStateManagementIntegrationTest extends SvIntegrationTestBase with Trigge eventually() { val voteResult = sv1Backend - .listVoteRequestResults(None, Some(true), None, None, None, 1) + .listVoteRequestResults(VoteResultsFilters(accepted = Some(true)), 1) ._1 .headOption .value diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvTimeBasedOnboardingIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvTimeBasedOnboardingIntegrationTest.scala index 6376cd4d42..28dc924e85 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvTimeBasedOnboardingIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvTimeBasedOnboardingIntegrationTest.scala @@ -228,7 +228,10 @@ class SvTimeBasedOnboardingIntegrationTest }, ) - actAndCheck("one week has passed", advanceTime(JavaDuration.ofDays(8)))( + actAndCheck(timeUntilSuccess = 30.seconds)( + "one week has passed", + advanceTime(JavaDuration.ofDays(8)), + )( "the vote request is not displayed anymore", _ => { sv1Backend.listVoteRequests() shouldBe empty withClue "VoteRequests" diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvTimeBasedRewardCouponIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvTimeBasedRewardCouponIntegrationTest.scala index 71d16928a4..170b08b3fc 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvTimeBasedRewardCouponIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvTimeBasedRewardCouponIntegrationTest.scala @@ -6,7 +6,6 @@ import org.lfdecentralizedtrust.splice.config.ConfigTransforms.{ ConfigurableApp, updateAutomationConfig, } -import org.lfdecentralizedtrust.splice.http.v0.definitions.TransactionHistoryRequest import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.{ IntegrationTest, @@ -221,38 +220,7 @@ class SvTimeBasedRewardCouponIntegrationTest ) } - clue("The claims appear in the scan history") { - eventually() { - val txs = sv1ScanBackend - .listTransactions( - None, - TransactionHistoryRequest.SortOrder.Desc, - Limit.DefaultMaxPageSize, - ) - .flatMap(_.transfer) - .filter(tf => - tf.sender.inputSvRewardAmount.nonEmpty && - Seq(sv1Party.toProtoPrimitive, aliceValidatorParty.toProtoPrimitive) - .contains(tf.sender.party) - ) - .map(tf => tf.sender.party -> tf.sender.inputSvRewardAmount.value) - .toMap - BigDecimal(txs(sv1Party.toProtoPrimitive)) should beWithin( - // The expected SV reward calculated here does not match exactly the reward calculated in daml, - // presumably because of rounding differences in the reward calculation. - BigDecimal(eachSvGetInRound0) - 0.001, - BigDecimal(eachSvGetInRound0) + 0.001, - ) - BigDecimal(txs(aliceValidatorParty.toProtoPrimitive)) should beWithin( - // The expected SV reward calculated here does not match exactly the reward calculated in daml, - // presumably because of rounding differences in the reward calculation. - BigDecimal(expectedAliceAmount) - 0.001, - BigDecimal(expectedAliceAmount) + 0.001, - ) - } - } - - clue("The claims appear in the wallet history") { + clue("The claims appear in the SV wallet history") { eventually() { val txs = withoutDevNetTopups( sv1WalletClient diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TestTokenV2SettlementIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TestTokenV2SettlementIntegrationTest.scala index d83ae7c2e9..8c99eb8ce3 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TestTokenV2SettlementIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TestTokenV2SettlementIntegrationTest.scala @@ -157,6 +157,34 @@ class TestTokenV2SettlementIntegrationTest def ttAdminValidator(implicit env: SpliceTestConsoleEnvironment) = v("testTokenValidatorLocal") + private def withValidatorsInitializedForTest[T]( + f: () => T + )(implicit env: SpliceTestConsoleEnvironment): T = { + sv1ValidatorBackend.startSync() // hosts DSO + Seq( + aliceValidatorBackend, // hosts Alice + bobValidatorBackend, // hosts Bob + splitwellValidatorBackend, // hosts the venue party + ttAdminValidator, // hosts the ttadmin + ).foreach { validatorBackend => + validatorBackend.startSync() + validatorBackend.participantClient.upload_dar_unless_exists(tokenStandardV2TestDarPath) + validatorBackend.participantClient + .upload_dar_unless_exists(testTokenV2DarPath) + } + try { + f() + } finally { + Seq( + sv1ValidatorBackend, + aliceValidatorBackend, + bobValidatorBackend, + splitwellValidatorBackend, + ttAdminValidator, + ).foreach(_.stop()) + } + } + "TestTokenV2 should be settleable" in { implicit env => initDso() withCanton( @@ -168,625 +196,640 @@ class TestTokenV2SettlementIntegrationTest "EXTRA_PARTICIPANT_ADMIN_USER" -> ttAdminValidator.config.ledgerApiUser, "EXTRA_PARTICIPANT_DB" -> dbName, ) { - sv1ValidatorBackend.startSync() // hosts DSO - Seq( - aliceValidatorBackend, // hosts Alice - bobValidatorBackend, // hosts Bob - splitwellValidatorBackend, // hosts the venue party - ttAdminValidator, // hosts the ttadmin - ).foreach { validatorBackend => - validatorBackend.startSync() - validatorBackend.participantClient.upload_dar_unless_exists(tokenStandardV2TestDarPath) - validatorBackend.participantClient - .upload_dar_unless_exists(testTokenV2DarPath) - } - val aliceParty = onboardWalletUser(aliceWalletClient, aliceValidatorBackend) - val bobParty = onboardWalletUser(bobWalletClient, bobValidatorBackend) - val venueValidator = splitwellValidatorBackend - val venueParty = PartyId.tryFromProtoPrimitive(splitwellWalletClient.userStatus().party) - val ttAdminParty = ttAdminValidator.getValidatorPartyId() - val registry = new TestTokenV2Registry(ttAdminParty, ttAdminValidator) - - // Give alice some CC - aliceWalletClient.tap(1000) - val aliceCCBalanceBefore = eventually() { - val balance = aliceWalletClient.balance().unlockedQty - balance should be > BigDecimal(0) - balance - } + withValidatorsInitializedForTest { () => + val aliceParty = onboardWalletUser(aliceWalletClient, aliceValidatorBackend) + val bobParty = onboardWalletUser(bobWalletClient, bobValidatorBackend) + val venueValidator = splitwellValidatorBackend + val venueParty = PartyId.tryFromProtoPrimitive(splitwellWalletClient.userStatus().party) + val ttAdminParty = ttAdminValidator.getValidatorPartyId() + val registry = new TestTokenV2Registry(ttAdminParty, ttAdminValidator) + + // Give alice some CC + aliceWalletClient.tap(1000) + val aliceCCBalanceBefore = eventually() { + val balance = aliceWalletClient.balance().unlockedQty + balance should be > BigDecimal(0) + balance + } - // make venue and ttadmin featured app parties - splitwellWalletClient.selfGrantFeaturedAppRight() - aliceValidatorWalletLocalClient.selfGrantFeaturedAppRight() - advanceRoundsByOneTickViaAutomation() - advanceRoundsByOneTickViaAutomation() - advanceRoundsByOneTickViaAutomation() - - // Create BatchingUtilityV2 contracts for Alice and Bob - val batchingUtilityIds: Map[PartyId, BatchingUtility.ContractId] = - Map(aliceValidatorBackend -> aliceParty, bobValidatorBackend -> bobParty).map { - case (validatorBackend, party) => - party -> validatorBackend.participantClientWithAdminToken.ledger_api_extensions.commands - .submitWithResult( - userId = validatorBackend.config.ledgerApiUser, - actAs = Seq(party), - readAs = Seq(party), - update = BatchingUtilityV2.create(party.toProtoPrimitive), - ) - .contractId + // make venue and ttadmin featured app parties + splitwellWalletClient.selfGrantFeaturedAppRight() + aliceValidatorWalletLocalClient.selfGrantFeaturedAppRight() + // We need to make sure that after this block, the oldest active OpenMiningRound + // has an `openAt` time that is after the time the first OpenMiningRound was archived, + // otherwise the ingestion start approximation in + // `DbScanRewardsReferenceStore.lookupActiveOpenMiningRounds` might filter out the + // OpenMiningRound contract and AppActivityComputation won't ingest activity records. + clue("Advance rounds") { + advanceRoundsByOneTickViaAutomation() // archives round 0, open rounds are 1-3 + advanceRoundsByOneTickViaAutomation() // archives round 1, open rounds are 2-4 + advanceRoundsByOneTickViaAutomation() // archives round 2, open rounds are 3-5 + advanceRoundsByOneTickViaAutomation() // archives round 3, open rounds are 4-6 } - // Create TokenRules for ttadmin - val tokenRulesId = ttAdminValidator.participantClient.ledger_api_extensions.commands - .submitWithResult( - userId = ttAdminValidator.config.ledgerApiUser, - actAs = Seq(ttAdminParty), - readAs = Seq(ttAdminParty), - update = TokenV2Rules.create(ttAdminParty.toProtoPrimitive), - ) - .contractId - - // Call TokenRules_OfferMint to offer 100 USDC to Bob - val bobConfigAccount = new testtokenv2.accountconfig.AccountConfig( - ttAdminParty.toProtoPrimitive, - basicAccount(bobParty), - new testtokenv2.accountconfig.PartyConfig(true, true), - new testtokenv2.accountconfig.PartyConfig(false, false), - ) - val bobOfferMintAmount = 100 - ttAdminValidator.participantClient.ledger_api_extensions.commands - .submitJava( - userId = ttAdminValidator.config.ledgerApiUser, - actAs = Seq(ttAdminParty), - commands = tokenRulesId - .exerciseTokenRules_OfferMint( - basicAccount(bobParty), - BigDecimal(bobOfferMintAmount).bigDecimal, - new holdingv2.InstrumentId(ttAdminParty.toProtoPrimitive, "USDC"), - Instant.now(), - bobConfigAccount, - ) - .commands() - .asScala - .toSeq, - ) + // Create BatchingUtilityV2 contracts for Alice and Bob + val batchingUtilityIds: Map[PartyId, BatchingUtility.ContractId] = + Map(aliceValidatorBackend -> aliceParty, bobValidatorBackend -> bobParty).map { + case (validatorBackend, party) => + party -> validatorBackend.participantClientWithAdminToken.ledger_api_extensions.commands + .submitWithResult( + userId = validatorBackend.config.ledgerApiUser, + actAs = Seq(party), + readAs = Seq(party), + update = BatchingUtilityV2.create(party.toProtoPrimitive), + ) + .contractId + } - // Bob accepts - val transferInstruction = - Contract - .fromCreatedEvent(transferinstructionv2.TransferInstruction.INTERFACE)( - CreatedEvent.fromProto( - createdEventToJavaProto( - bobValidatorBackend.participantClientWithAdminToken.ledger_api.state.acs - .of_party( - party = bobParty, - filterInterfaces = - Seq(transferinstructionv2.TransferInstruction.TEMPLATE_ID).map(templateId => - TemplateId( - templateId.getPackageId, - templateId.getModuleName, - templateId.getEntityName, - ) - ), - ) - .loneElement - .event - ) - ) + // Create TokenRules for ttadmin + val tokenRulesId = ttAdminValidator.participantClient.ledger_api_extensions.commands + .submitWithResult( + userId = ttAdminValidator.config.ledgerApiUser, + actAs = Seq(ttAdminParty), + readAs = Seq(ttAdminParty), + update = TokenV2Rules.create(ttAdminParty.toProtoPrimitive), ) - .valueOrFail("Failed to read transferinstructionv2.TransferInstruction") - val acceptContext = - registry.getContext( - transferInstruction.payload.transfer.inputHoldingCids.asScala.toSeq + .contractId + + // Call TokenRules_OfferMint to offer 100 USDC to Bob + val bobConfigAccount = new testtokenv2.accountconfig.AccountConfig( + ttAdminParty.toProtoPrimitive, + basicAccount(bobParty), + new testtokenv2.accountconfig.PartyConfig(true, true), + new testtokenv2.accountconfig.PartyConfig(false, false), ) - val transferResult = - bobValidatorBackend.participantClientWithAdminToken.ledger_api_extensions.commands - .submitWithResult( - userId = bobValidatorBackend.config.ledgerApiUser, - actAs = Seq(bobParty), - readAs = Seq(bobParty), - update = transferInstruction.contractId.exerciseTransferInstruction_Accept( - java.util.List.of(bobParty.toProtoPrimitive), - new metadatav1.ExtraArgs(acceptContext.choiceContext, emptyMetadata), - ), - disclosedContracts = acceptContext.disclosedContracts, + val bobOfferMintAmount = 100 + ttAdminValidator.participantClient.ledger_api_extensions.commands + .submitJava( + userId = ttAdminValidator.config.ledgerApiUser, + actAs = Seq(ttAdminParty), + commands = tokenRulesId + .exerciseTokenRules_OfferMint( + basicAccount(bobParty), + BigDecimal(bobOfferMintAmount).bigDecimal, + new holdingv2.InstrumentId(ttAdminParty.toProtoPrimitive, "USDC"), + Instant.now(), + bobConfigAccount, + ) + .commands() + .asScala + .toSeq, ) - transferResult.exerciseResult.output match { - case completed: TransferInstructionResult_Completed => - completed.receiverHoldingCids.asScala.toSeq - case other => fail(s"Offer mint was not completed: $other") - } - // Venue creates the trade - val (createTradeTx, otcTrade) = actAndCheck( - "Venue creates OTC Trade", { - venueValidator.participantClientWithAdminToken.ledger_api_extensions.commands - .submitJava( - actAs = Seq(venueParty), - commands = new tradingappv2.OTCTrade( - venueParty.toProtoPrimitive, - Seq( - // Alice -> Bob: 100 CC - new tradingappv2.TradeLeg( - dsoParty.toProtoPrimitive, - new allocationv2.TransferLeg( - "alicetobob100CC", - basicAccount(aliceParty), - basicAccount(bobParty), - BigDecimal(100).bigDecimal, - amuletInstrumentIdName, - emptyMetadata, - ), - ), - // Bob -> Alice: 15 USDC - new tradingappv2.TradeLeg( - ttAdminParty.toProtoPrimitive, - new allocationv2.TransferLeg( - "bobtoalice15USDC", - basicAccount(bobParty), - basicAccount(aliceParty), - BigDecimal(15).bigDecimal, - usdcInstrumentName, - emptyMetadata, - ), - ), - // Alice -> Venue: 0.2 USDC - new tradingappv2.TradeLeg( - ttAdminParty.toProtoPrimitive, - new allocationv2.TransferLeg( - "alicetovenue0.2USDC", - basicAccount(aliceParty), - basicAccount(venueParty), - BigDecimal(0.2).bigDecimal, - usdcInstrumentName, - emptyMetadata, - ), - ), - ).asJava, - Instant.now(), - Instant.now().plusSeconds(60L), - java.util.Optional.of(Instant.now().plusSeconds(180L)), + // Bob accepts + val transferInstruction = eventually() { + Contract + .fromCreatedEvent(transferinstructionv2.TransferInstruction.INTERFACE)( + CreatedEvent.fromProto( + createdEventToJavaProto( + bobValidatorBackend.participantClientWithAdminToken.ledger_api.state.acs + .of_party( + party = bobParty, + filterInterfaces = + Seq(transferinstructionv2.TransferInstruction.TEMPLATE_ID).map(templateId => + TemplateId( + templateId.getPackageId, + templateId.getModuleName, + templateId.getEntityName, + ) + ), + ) + .loneElement + .event + ) ) - .create() - .commands() - .asScala - .toSeq, ) - }, - )( - "There exists a trade visible to the venue's participant", - _ => - venueValidator.participantClientWithAdminToken.ledger_api_extensions.acs - .awaitJava(tradingappv2.OTCTrade.COMPANION)( - venueParty - ), - ) + .valueOrFail("Failed to read transferinstructionv2.TransferInstruction") + } + val acceptContext = + registry.getContext( + transferInstruction.payload.transfer.inputHoldingCids.asScala.toSeq + ) + val transferResult = + bobValidatorBackend.participantClientWithAdminToken.ledger_api_extensions.commands + .submitWithResult( + userId = bobValidatorBackend.config.ledgerApiUser, + actAs = Seq(bobParty), + readAs = Seq(bobParty), + update = transferInstruction.contractId.exerciseTransferInstruction_Accept( + java.util.List.of(bobParty.toProtoPrimitive), + new metadatav1.ExtraArgs(acceptContext.choiceContext, emptyMetadata), + ), + disclosedContracts = acceptContext.disclosedContracts, + ) + transferResult.exerciseResult.output match { + case completed: TransferInstructionResult_Completed => + completed.receiverHoldingCids.asScala.toSeq + case other => fail(s"Offer mint was not completed: $other") + } - val (createAllocationRequestsTx, (bobAllocationRequest, aliceAllocationRequest)) = - actAndCheck( - "Venue creates allocation requests", { + // Venue creates the trade + val (createTradeTx, otcTrade) = actAndCheck( + "Venue creates OTC Trade", { venueValidator.participantClientWithAdminToken.ledger_api_extensions.commands .submitJava( actAs = Seq(venueParty), - commands = otcTrade.id - .exerciseOTCTrade_RequestAllocations() + commands = new tradingappv2.OTCTrade( + venueParty.toProtoPrimitive, + Seq( + // Alice -> Bob: 100 CC + new tradingappv2.TradeLeg( + dsoParty.toProtoPrimitive, + new allocationv2.TransferLeg( + "alicetobob100CC", + basicAccount(aliceParty), + basicAccount(bobParty), + BigDecimal(100).bigDecimal, + amuletInstrumentIdName, + emptyMetadata, + ), + ), + // Bob -> Alice: 15 USDC + new tradingappv2.TradeLeg( + ttAdminParty.toProtoPrimitive, + new allocationv2.TransferLeg( + "bobtoalice15USDC", + basicAccount(bobParty), + basicAccount(aliceParty), + BigDecimal(15).bigDecimal, + usdcInstrumentName, + emptyMetadata, + ), + ), + // Alice -> Venue: 0.2 USDC + new tradingappv2.TradeLeg( + ttAdminParty.toProtoPrimitive, + new allocationv2.TransferLeg( + "alicetovenue0.2USDC", + basicAccount(aliceParty), + basicAccount(venueParty), + BigDecimal(0.2).bigDecimal, + usdcInstrumentName, + emptyMetadata, + ), + ), + ).asJava, + Instant.now(), + Instant.now().plusSeconds(60L), + java.util.Optional.of(Instant.now().plusSeconds(180L)), + ) + .create() .commands() .asScala .toSeq, ) }, )( - "Sender and receiver see the allocation requests", - _ => { - val bobAllocationRequest = inside( - bobWalletClient.listAllocationRequests() - ) { - case (allocationRequest: HttpWalletAppClient.TokenStandard.V2AllocationRequest) +: Nil => - allocationRequest - } - val aliceAllocationRequest = inside( - aliceWalletClient.listAllocationRequests() - ) { - case (allocationRequest: HttpWalletAppClient.TokenStandard.V2AllocationRequest) +: Nil => - allocationRequest - } - - (bobAllocationRequest, aliceAllocationRequest) - }, + "There exists a trade visible to the venue's participant", + _ => + venueValidator.participantClientWithAdminToken.ledger_api_extensions.acs + .awaitJava(tradingappv2.OTCTrade.COMPANION)( + venueParty + ), ) - val (aliceAllocationCids, aliceAllocateTx) = clue( - "Alice uses the BatchingUtilityV2 to create two allocations and accept the allocation request in a single tx" - ) { - val batchingUtility = batchingUtilityIds(aliceParty) - val aliceAmulets = aliceWalletClient - .list() - .amulets - .map(_.contract.contractId.toInterface(holdingv2.Holding.INTERFACE)) - val amuletSpec = aliceAllocationRequest.contract.payload.allocations.asScala - .filter(_.admin == dsoParty.toProtoPrimitive) - .loneElement - val usdcSpec = aliceAllocationRequest.contract.payload.allocations.asScala - .filter(_.admin == ttAdminParty.toProtoPrimitive) - .loneElement - val amuletAllocationFactory = sv1ScanBackend.getAllocationFactoryV2( - new allocationinstructionv2.AllocationFactory_Allocate( - aliceAllocationRequest.contract.payload.settlement, - amuletSpec, - aliceAllocationRequest.contract.payload.requestedAt, - aliceAmulets.asJava, - emptyExtraArgs, - java.util.List.of(aliceParty.toProtoPrimitive), - ) - ) - val usdcContext = registry.getContext(Seq.empty) - val aliceAllocateUpdate = batchingUtility - .exerciseBatchingUtility_ExecuteBatch( - new HoldingMap( - Map( - new ScopedAccount( - dsoParty.toProtoPrimitive, - basicAccount(aliceParty), - ) -> Map[String, java.util.List[holdingv2.Holding.ContractId]]( - amuletInstrumentIdName -> aliceAmulets.asJava - ).asJava, - new ScopedAccount( - ttAdminParty.toProtoPrimitive, - basicAccount(aliceParty), - ) -> Map - .empty[String, java.util.List[holdingv2.Holding.ContractId]] - .asJava, // alice has no USDC here yet - ).asJava - ), - java.util.List.of( - new TSA_AllocationFactory_AllocateV2( - new ChoiceCall[AllocationFactory_Allocate]( - new metadatav1.AnyContract.ContractId( - amuletAllocationFactory.factoryId.contractId - ), - amuletAllocationFactory.args, + val (createAllocationRequestsTx, (bobAllocationRequest, aliceAllocationRequest)) = + actAndCheck( + "Venue creates allocation requests", { + venueValidator.participantClientWithAdminToken.ledger_api_extensions.commands + .submitJava( + actAs = Seq(venueParty), + commands = otcTrade.id + .exerciseOTCTrade_RequestAllocations() + .commands() + .asScala + .toSeq, ) - ), - new TSA_AllocationFactory_AllocateV2( - new ChoiceCall[AllocationFactory_Allocate]( - new metadatav1.AnyContract.ContractId(tokenRulesId.contractId), - new allocationinstructionv2.AllocationFactory_Allocate( - aliceAllocationRequest.contract.payload.settlement, - usdcSpec, - aliceAllocationRequest.contract.payload.requestedAt, - java.util.List.of(), - new metadatav1.ExtraArgs(usdcContext.choiceContext, emptyMetadata), - java.util.List.of(aliceParty.toProtoPrimitive), + }, + )( + "Sender and receiver see the allocation requests", + _ => { + val bobAllocationRequest = inside( + bobWalletClient.listAllocationRequests() + ) { + case (allocationRequest: HttpWalletAppClient.TokenStandard.V2AllocationRequest) +: Nil => + allocationRequest + } + val aliceAllocationRequest = inside( + aliceWalletClient.listAllocationRequests() + ) { + case (allocationRequest: HttpWalletAppClient.TokenStandard.V2AllocationRequest) +: Nil => + allocationRequest + } + + (bobAllocationRequest, aliceAllocationRequest) + }, + ) + + val (aliceAllocationCids, aliceAllocateTx) = clue( + "Alice uses the BatchingUtilityV2 to create two allocations and accept the allocation request in a single tx" + ) { + // UpdateExternalPartyConfigStateTrigger might run concurrently and cause a LOCAL_VERDICT_INACTIVE_CONTRACTS + // because of the ExternalPartyConfigState being updated. + // In the real world, we expect the venue to also just retry re-fetching all contexts + eventuallySucceeds() { + val batchingUtility = batchingUtilityIds(aliceParty) + val aliceAmulets = aliceWalletClient + .list() + .amulets + .map(_.contract.contractId.toInterface(holdingv2.Holding.INTERFACE)) + val amuletSpec = aliceAllocationRequest.contract.payload.allocations.asScala + .filter(_.admin == dsoParty.toProtoPrimitive) + .loneElement + val usdcSpec = aliceAllocationRequest.contract.payload.allocations.asScala + .filter(_.admin == ttAdminParty.toProtoPrimitive) + .loneElement + val amuletAllocationFactory = sv1ScanBackend.getAllocationFactoryV2( + new allocationinstructionv2.AllocationFactory_Allocate( + aliceAllocationRequest.contract.payload.settlement, + amuletSpec, + aliceAllocationRequest.contract.payload.requestedAt, + aliceAmulets.asJava, + emptyExtraArgs, + java.util.List.of(aliceParty.toProtoPrimitive), + ) + ) + val usdcContext = registry.getContext(Seq.empty) + val aliceAllocateUpdate = batchingUtility + .exerciseBatchingUtility_ExecuteBatch( + new HoldingMap( + Map( + new ScopedAccount( + dsoParty.toProtoPrimitive, + basicAccount(aliceParty), + ) -> Map[String, java.util.List[holdingv2.Holding.ContractId]]( + amuletInstrumentIdName -> aliceAmulets.asJava + ).asJava, + new ScopedAccount( + ttAdminParty.toProtoPrimitive, + basicAccount(aliceParty), + ) -> Map + .empty[String, java.util.List[holdingv2.Holding.ContractId]] + .asJava, // alice has no USDC here yet + ).asJava + ), + java.util.List.of( + new TSA_AllocationFactory_AllocateV2( + new ChoiceCall[AllocationFactory_Allocate]( + new metadatav1.AnyContract.ContractId( + amuletAllocationFactory.factoryId.contractId + ), + amuletAllocationFactory.args, + ) ), - ) - ), - new TSA_AllocationRequest_AcceptV2( - new ChoiceCall[AllocationRequest_Accept]( - new metadatav1.AnyContract.ContractId( - aliceAllocationRequest.contract.contractId.contractId + new TSA_AllocationFactory_AllocateV2( + new ChoiceCall[AllocationFactory_Allocate]( + new metadatav1.AnyContract.ContractId(tokenRulesId.contractId), + new allocationinstructionv2.AllocationFactory_Allocate( + aliceAllocationRequest.contract.payload.settlement, + usdcSpec, + aliceAllocationRequest.contract.payload.requestedAt, + java.util.List.of(), + new metadatav1.ExtraArgs(usdcContext.choiceContext, emptyMetadata), + java.util.List.of(aliceParty.toProtoPrimitive), + ), + ) ), - new AllocationRequest_Accept( - java.util.List.of(aliceParty.toProtoPrimitive), - amuletAllocationFactory.args.extraArgs, + new TSA_AllocationRequest_AcceptV2( + new ChoiceCall[AllocationRequest_Accept]( + new metadatav1.AnyContract.ContractId( + aliceAllocationRequest.contract.contractId.contractId + ), + new AllocationRequest_Accept( + java.util.List.of(aliceParty.toProtoPrimitive), + amuletAllocationFactory.args.extraArgs, + ), + ) ), + ), + true, + ) + val aliceAllocateTx = + aliceValidatorBackend.participantClientWithAdminToken.ledger_api_extensions.commands + .submitJava( + userId = aliceValidatorBackend.config.ledgerApiUser, + actAs = Seq(aliceParty), + readAs = Seq(aliceParty), + commands = aliceAllocateUpdate.commands().asScala.toSeq, + disclosedContracts = + amuletAllocationFactory.disclosedContracts ++ usdcContext.disclosedContracts, ) - ), - ), - true, - ) - val aliceAllocateTx = - aliceValidatorBackend.participantClientWithAdminToken.ledger_api_extensions.commands - .submitJava( - userId = aliceValidatorBackend.config.ledgerApiUser, - actAs = Seq(aliceParty), - readAs = Seq(aliceParty), - commands = aliceAllocateUpdate.commands().asScala.toSeq, - disclosedContracts = - amuletAllocationFactory.disclosedContracts ++ usdcContext.disclosedContracts, - ) - val aliceAllocationCids = SpliceLedgerConnection - .decodeExerciseResult( - aliceAllocateUpdate, - aliceAllocateTx, - ) - .exerciseResult - .actionResults - .asScala - .map { - case _: TSAR_AllocationRequest_AcceptV2Result => None - case v: TSAR_AllocationInstructionResultV2 => - v.allocationInstructionResultValue.output match { - case completed: AllocationInstructionResult_Completed => - Some(completed.allocationCid) - case other => fail(s"Expected AllocationInstructionResult_Completed but got $other") + val aliceAllocationCids = SpliceLedgerConnection + .decodeExerciseResult( + aliceAllocateUpdate, + aliceAllocateTx, + ) + .exerciseResult + .actionResults + .asScala + .map { + case _: TSAR_AllocationRequest_AcceptV2Result => None + case v: TSAR_AllocationInstructionResultV2 => + v.allocationInstructionResultValue.output match { + case completed: AllocationInstructionResult_Completed => + Some(completed.allocationCid) + case other => + fail(s"Expected AllocationInstructionResult_Completed but got $other") + } + case other => + fail(s"Expected TSAR_AllocationResultV2 but got $other") } - case other => - fail(s"Expected TSAR_AllocationResultV2 but got $other") - } - .collect { case Some(cid) => cid } + .collect { case Some(cid) => cid } - (aliceAllocationCids, aliceAllocateTx) - } + (aliceAllocationCids, aliceAllocateTx) + } + } - val (bobAllocationCids, bobAllocateTx) = clue( - "Bob uses the BatchingUtilityV2 to accept the request and create two allocations in a single tx" - ) { - val batchingUtility = batchingUtilityIds(bobParty) - val amuletSpec = bobAllocationRequest.contract.payload.allocations.asScala - .filter(_.admin == dsoParty.toProtoPrimitive) - .loneElement - val usdcSpec = bobAllocationRequest.contract.payload.allocations.asScala - .filter(_.admin == ttAdminParty.toProtoPrimitive) - .loneElement - val amuletAllocationFactory = sv1ScanBackend.getAllocationFactoryV2( - new allocationinstructionv2.AllocationFactory_Allocate( - bobAllocationRequest.contract.payload.settlement, - amuletSpec, - bobAllocationRequest.contract.payload.requestedAt, - java.util.List.of(), // bob has no amulets - emptyExtraArgs, - java.util.List.of(bobParty.toProtoPrimitive), - ) - ) - val bobUsdcHoldings = getHoldings(bobParty, bobValidatorBackend) - .map(_.contractId) - .map(id => new holdingv2.Holding.ContractId(id)) - val usdcContext = registry.getContext( - bobUsdcHoldings - ) - val bobAllocateUpdate = batchingUtility - .exerciseBatchingUtility_ExecuteBatch( - new HoldingMap( - Map( - new ScopedAccount( - dsoParty.toProtoPrimitive, - basicAccount(bobParty), - ) -> Map[String, java.util.List[holdingv2.Holding.ContractId]]().asJava, - new ScopedAccount( - ttAdminParty.toProtoPrimitive, - basicAccount(bobParty), - ) -> Map[String, java.util.List[holdingv2.Holding.ContractId]]( - usdcInstrumentName -> bobUsdcHoldings.asJava - ).asJava, // alice has no USDC here yet - ).asJava - ), - java.util.List.of( - new TSA_AllocationFactory_AllocateV2( - new ChoiceCall[AllocationFactory_Allocate]( - new metadatav1.AnyContract.ContractId( - amuletAllocationFactory.factoryId.contractId - ), - amuletAllocationFactory.args, - ) - ), - new TSA_AllocationFactory_AllocateV2( - new ChoiceCall[AllocationFactory_Allocate]( - new metadatav1.AnyContract.ContractId(tokenRulesId.contractId), - new allocationinstructionv2.AllocationFactory_Allocate( - bobAllocationRequest.contract.payload.settlement, - usdcSpec, - bobAllocationRequest.contract.payload.requestedAt, - java.util.List.of(), - new metadatav1.ExtraArgs(usdcContext.choiceContext, emptyMetadata), - java.util.List.of(bobParty.toProtoPrimitive), + val (bobAllocationCids, bobAllocateTx) = clue( + "Bob uses the BatchingUtilityV2 to accept the request and create two allocations in a single tx" + ) { + // Same UpdateExternalPartyConfigStateTrigger/LOCAL_VERDICT_INACTIVE_CONTRACTS logic + // as with alice's usage of BatchingUtilityV2 above. + eventuallySucceeds() { + val batchingUtility = batchingUtilityIds(bobParty) + val amuletSpec = bobAllocationRequest.contract.payload.allocations.asScala + .filter(_.admin == dsoParty.toProtoPrimitive) + .loneElement + val usdcSpec = bobAllocationRequest.contract.payload.allocations.asScala + .filter(_.admin == ttAdminParty.toProtoPrimitive) + .loneElement + val amuletAllocationFactory = sv1ScanBackend.getAllocationFactoryV2( + new allocationinstructionv2.AllocationFactory_Allocate( + bobAllocationRequest.contract.payload.settlement, + amuletSpec, + bobAllocationRequest.contract.payload.requestedAt, + java.util.List.of(), // bob has no amulets + emptyExtraArgs, + java.util.List.of(bobParty.toProtoPrimitive), + ) + ) + val bobUsdcHoldings = getHoldings(bobParty, bobValidatorBackend) + .map(_.contractId) + .map(id => new holdingv2.Holding.ContractId(id)) + val usdcContext = registry.getContext( + bobUsdcHoldings + ) + val bobAllocateUpdate = batchingUtility + .exerciseBatchingUtility_ExecuteBatch( + new HoldingMap( + Map( + new ScopedAccount( + dsoParty.toProtoPrimitive, + basicAccount(bobParty), + ) -> Map[String, java.util.List[holdingv2.Holding.ContractId]]().asJava, + new ScopedAccount( + ttAdminParty.toProtoPrimitive, + basicAccount(bobParty), + ) -> Map[String, java.util.List[holdingv2.Holding.ContractId]]( + usdcInstrumentName -> bobUsdcHoldings.asJava + ).asJava, // alice has no USDC here yet + ).asJava + ), + java.util.List.of( + new TSA_AllocationFactory_AllocateV2( + new ChoiceCall[AllocationFactory_Allocate]( + new metadatav1.AnyContract.ContractId( + amuletAllocationFactory.factoryId.contractId + ), + amuletAllocationFactory.args, + ) ), - ) - ), - new TSA_AllocationRequest_AcceptV2( - new ChoiceCall[AllocationRequest_Accept]( - new metadatav1.AnyContract.ContractId( - bobAllocationRequest.contract.contractId.contractId + new TSA_AllocationFactory_AllocateV2( + new ChoiceCall[AllocationFactory_Allocate]( + new metadatav1.AnyContract.ContractId(tokenRulesId.contractId), + new allocationinstructionv2.AllocationFactory_Allocate( + bobAllocationRequest.contract.payload.settlement, + usdcSpec, + bobAllocationRequest.contract.payload.requestedAt, + java.util.List.of(), + new metadatav1.ExtraArgs(usdcContext.choiceContext, emptyMetadata), + java.util.List.of(bobParty.toProtoPrimitive), + ), + ) ), - new AllocationRequest_Accept( - java.util.List.of(bobParty.toProtoPrimitive), - new metadatav1.ExtraArgs(usdcContext.choiceContext, emptyMetadata), + new TSA_AllocationRequest_AcceptV2( + new ChoiceCall[AllocationRequest_Accept]( + new metadatav1.AnyContract.ContractId( + bobAllocationRequest.contract.contractId.contractId + ), + new AllocationRequest_Accept( + java.util.List.of(bobParty.toProtoPrimitive), + new metadatav1.ExtraArgs(usdcContext.choiceContext, emptyMetadata), + ), + ) ), + ), + true, + ) + val bobAllocateTx = + bobValidatorBackend.participantClientWithAdminToken.ledger_api_extensions.commands + .submitJava( + userId = bobValidatorBackend.config.ledgerApiUser, + actAs = Seq(bobParty), + readAs = Seq(bobParty), + commands = bobAllocateUpdate.commands().asScala.toSeq, + disclosedContracts = + amuletAllocationFactory.disclosedContracts ++ usdcContext.disclosedContracts, ) - ), - ), - true, - ) - val bobAllocateTx = - bobValidatorBackend.participantClientWithAdminToken.ledger_api_extensions.commands - .submitJava( - userId = bobValidatorBackend.config.ledgerApiUser, - actAs = Seq(bobParty), - readAs = Seq(bobParty), - commands = bobAllocateUpdate.commands().asScala.toSeq, - disclosedContracts = - amuletAllocationFactory.disclosedContracts ++ usdcContext.disclosedContracts, - ) - val bobAllocationCids = SpliceLedgerConnection - .decodeExerciseResult( - bobAllocateUpdate, - bobAllocateTx, - ) - .exerciseResult - .actionResults - .asScala - .map { - case _: TSAR_AllocationRequest_AcceptV2Result => None - case v: TSAR_AllocationInstructionResultV2 => - v.allocationInstructionResultValue.output match { - case completed: AllocationInstructionResult_Completed => - Some(completed.allocationCid) + val bobAllocationCids = SpliceLedgerConnection + .decodeExerciseResult( + bobAllocateUpdate, + bobAllocateTx, + ) + .exerciseResult + .actionResults + .asScala + .map { + case _: TSAR_AllocationRequest_AcceptV2Result => None + case v: TSAR_AllocationInstructionResultV2 => + v.allocationInstructionResultValue.output match { + case completed: AllocationInstructionResult_Completed => + Some(completed.allocationCid) + case other => + fail(s"Expected AllocationInstructionResult_Completed but got $other") + } case other => - fail(s"Expected AllocationInstructionResult_Completed but got $other") + fail(s"Expected TSAR_AllocationResultV2 but got $other") } - case other => - fail(s"Expected TSAR_AllocationResultV2 but got $other") - } - .collect { case Some(cid) => cid } - - (bobAllocationCids, bobAllocateTx) - } + .collect { case Some(cid) => cid } - val (settleTradeTx, _) = actAndCheck( - "Venue settles the trade", { - val allAllocations = { - venueValidator.participantClientWithAdminToken.ledger_api.state.acs.of_party( - party = venueParty, - filterInterfaces = Seq(allocationv2.Allocation.TEMPLATE_ID).map(templateId => - TemplateId( - templateId.getPackageId, - templateId.getModuleName, - templateId.getEntityName, - ) - ), - includeCreatedEventBlob = true, - ) + (bobAllocationCids, bobAllocateTx) } - // sanity check - (bobAllocationCids ++ aliceAllocationCids).foreach { cid => - allAllocations - .find(_.contractId == cid.contractId) - .valueOrFail(s"No allocation found for cid $cid") - } - val amuletAllocations = - allAllocations.filter(_.event.signatories.contains(dsoParty.toProtoPrimitive)) - val usdAllocations = - allAllocations.filter(_.event.signatories.contains(ttAdminParty.toProtoPrimitive)) - val settleBatch = new allocationv2.SettlementFactory_SettleBatch( - new allocationv2.SettlementInfo( - java.util.List.of(venueParty.toProtoPrimitive), - "OTCTrade", - java.util.Optional.of(new metadatav1.AnyContract.ContractId(otcTrade.id.contractId)), - emptyMetadata, - ), - transferLegsFromTrade(otcTrade).asJava, - allAllocations - .map(alloc => - new allocationv2.FinalizedAllocation( - new allocationv2.Allocation.ContractId(alloc.contractId), - java.util.List.of(), - java.util.Optional.empty[java.util.Map[String, java.math.BigDecimal]](), + } + + val (settleTradeTx, _) = actAndCheck( + "Venue settles the trade", { + // Same UpdateExternalPartyConfigStateTrigger/LOCAL_VERDICT_INACTIVE_CONTRACTS logic + // as with alice's usage of BatchingUtilityV2 above. + eventuallySucceeds() { + val allAllocations = { + venueValidator.participantClientWithAdminToken.ledger_api.state.acs.of_party( + party = venueParty, + filterInterfaces = Seq(allocationv2.Allocation.TEMPLATE_ID).map(templateId => + TemplateId( + templateId.getPackageId, + templateId.getModuleName, + templateId.getEntityName, + ) + ), + includeCreatedEventBlob = true, ) - ) - .asJava, - /*actors = */ java.util.List.of(venueParty.toProtoPrimitive), - emptyExtraArgs, - ) - val amuletContext = sv1ScanBackend.getSettlementFactoryV2(settleBatch) - val bobUsdcHoldings = getHoldings(bobParty, bobValidatorBackend) - .map(_.contractId) - .map(id => new holdingv2.Holding.ContractId(id)) - val usdcContext = registry.getContext( - bobUsdcHoldings - ) - venueValidator.participantClientWithAdminToken.ledger_api_extensions.commands - .submitJava( - actAs = Seq(venueParty), - commands = otcTrade.id - .exerciseOTCTrade_Settle( - Map[String, tradingappv2.SettlementBatch]( - dsoParty.toProtoPrimitive -> new SettlementBatchV2( - amuletAllocations - .map(alloc => new allocationv2.Allocation.ContractId(alloc.contractId)) - .asJava, + } + // sanity check + (bobAllocationCids ++ aliceAllocationCids).foreach { cid => + allAllocations + .find(_.contractId == cid.contractId) + .valueOrFail(s"No allocation found for cid $cid") + } + val amuletAllocations = + allAllocations.filter(_.event.signatories.contains(dsoParty.toProtoPrimitive)) + val usdAllocations = + allAllocations.filter(_.event.signatories.contains(ttAdminParty.toProtoPrimitive)) + val settleBatch = new allocationv2.SettlementFactory_SettleBatch( + new allocationv2.SettlementInfo( + java.util.List.of(venueParty.toProtoPrimitive), + "OTCTrade", + java.util.Optional + .of(new metadatav1.AnyContract.ContractId(otcTrade.id.contractId)), + emptyMetadata, + ), + transferLegsFromTrade(otcTrade).asJava, + allAllocations + .map(alloc => + new allocationv2.FinalizedAllocation( + new allocationv2.Allocation.ContractId(alloc.contractId), java.util.List.of(), - amuletContext.factoryId, - amuletContext.args.extraArgs, - ), - ttAdminParty.toProtoPrimitive -> new SettlementBatchV2( - usdAllocations - .map(alloc => new allocationv2.Allocation.ContractId(alloc.contractId)) - .asJava, - java.util.List.of( - new tradingappv2.MissingAllocation( - java.util.Optional.empty(), - tokenRulesId.toInterface( - allocationinstructionv2.AllocationFactory.INTERFACE - ), - new allocationinstructionv2.AllocationFactory_Allocate( - new allocationv2.SettlementInfo( - java.util.List.of(venueParty.toProtoPrimitive), - "OTCTradeProposal", - java.util.Optional.of( - new metadatav1.AnyContract.ContractId(otcTrade.id.contractId) + java.util.Optional.empty[java.util.Map[String, java.math.BigDecimal]](), + ) + ) + .asJava, + /*actors = */ java.util.List.of(venueParty.toProtoPrimitive), + emptyExtraArgs, + ) + val amuletContext = sv1ScanBackend.getSettlementFactoryV2(settleBatch) + val bobUsdcHoldings = getHoldings(bobParty, bobValidatorBackend) + .map(_.contractId) + .map(id => new holdingv2.Holding.ContractId(id)) + val usdcContext = registry.getContext( + bobUsdcHoldings + ) + venueValidator.participantClientWithAdminToken.ledger_api_extensions.commands + .submitJava( + actAs = Seq(venueParty), + commands = otcTrade.id + .exerciseOTCTrade_Settle( + Map[String, tradingappv2.SettlementBatch]( + dsoParty.toProtoPrimitive -> new SettlementBatchV2( + amuletAllocations + .map(alloc => new allocationv2.Allocation.ContractId(alloc.contractId)) + .asJava, + java.util.List.of(), + amuletContext.factoryId, + amuletContext.args.extraArgs, + ), + ttAdminParty.toProtoPrimitive -> new SettlementBatchV2( + usdAllocations + .map(alloc => new allocationv2.Allocation.ContractId(alloc.contractId)) + .asJava, + java.util.List.of( + new tradingappv2.MissingAllocation( + java.util.Optional.empty(), + tokenRulesId.toInterface( + allocationinstructionv2.AllocationFactory.INTERFACE ), - emptyMetadata, - ), - new allocationv2.AllocationSpecification( - ttAdminParty.toProtoPrimitive, - basicAccount(venueParty), - java.util.List.of( - new allocationv2.TransferLegSide( - "alicetovenue0.2USDC", - allocationv2.TransferSide.RECEIVERSIDE, - basicAccount(aliceParty), - BigDecimal(0.2).bigDecimal, - usdcInstrumentName, + new allocationinstructionv2.AllocationFactory_Allocate( + new allocationv2.SettlementInfo( + java.util.List.of(venueParty.toProtoPrimitive), + "OTCTradeProposal", + java.util.Optional.of( + new metadatav1.AnyContract.ContractId(otcTrade.id.contractId) + ), + emptyMetadata, + ), + new allocationv2.AllocationSpecification( + ttAdminParty.toProtoPrimitive, + basicAccount(venueParty), + java.util.List.of( + new allocationv2.TransferLegSide( + "alicetovenue0.2USDC", + allocationv2.TransferSide.RECEIVERSIDE, + basicAccount(aliceParty), + BigDecimal(0.2).bigDecimal, + usdcInstrumentName, + emptyMetadata, + ) + ), + java.util.Optional.empty(), + java.util.Optional.empty(), + false, emptyMetadata, - ) + ), + Instant.now(), + java.util.List.of(), + new metadatav1.ExtraArgs(usdcContext.choiceContext, emptyMetadata), + java.util.List.of(venueParty.toProtoPrimitive), ), - java.util.Optional.empty(), - java.util.Optional.empty(), - false, - emptyMetadata, - ), - Instant.now(), - java.util.List.of(), - new metadatav1.ExtraArgs(usdcContext.choiceContext, emptyMetadata), - java.util.List.of(venueParty.toProtoPrimitive), + ) ), - ) - ), - new allocationv2.SettlementFactory.ContractId(tokenRulesId.contractId), - new metadatav1.ExtraArgs(usdcContext.choiceContext, emptyMetadata), - ), - ).asJava, - java.util.List.of(), + new allocationv2.SettlementFactory.ContractId(tokenRulesId.contractId), + new metadatav1.ExtraArgs(usdcContext.choiceContext, emptyMetadata), + ), + ).asJava, + java.util.List.of(), + ) + .commands() + .asScala + .toSeq, + disclosedContracts = + usdcContext.disclosedContracts ++ amuletContext.disclosedContracts, ) - .commands() - .asScala - .toSeq, - disclosedContracts = - usdcContext.disclosedContracts ++ amuletContext.disclosedContracts, - ) - }, - )( - "The balances are updated", - _ => { - aliceWalletClient.balance().unlockedQty should be(aliceCCBalanceBefore - 100) - bobWalletClient.balance().unlockedQty should be(100) - - getUsdcBalance(bobParty, bobValidatorBackend) should be(bobOfferMintAmount - 15) - getUsdcBalance(aliceParty, aliceValidatorBackend) should be(15 - 0.2) - getUsdcBalance(venueParty, venueValidator) should be(0.2) - }, - ) + } + }, + )( + "The balances are updated", + _ => { + aliceWalletClient.balance().unlockedQty should be(aliceCCBalanceBefore - 100) + bobWalletClient.balance().unlockedQty should be(100) - val events = Seq( - createTradeTx -> "Create Trade", - createAllocationRequestsTx -> "Create Allocation Requests", - aliceAllocateTx -> "Alice Allocations", - bobAllocateTx -> "Bob Allocations", - settleTradeTx -> "Settle Trade", - ).map { case (tx, name) => - val updateId = tx.getUpdateId - name -> clue(s"Checking traffic & activity records for '$name'") { - eventually() { - inside(sv1ScanBackend.getEventById(updateId, None)) { - case Some( - item @ EventHistoryItem( - _, - Some(_), - Some(_), - Some(_), - ) - ) => - EventHistoryItem.encodeEventHistoryItem(item) + getUsdcBalance(bobParty, bobValidatorBackend) should be(bobOfferMintAmount - 15) + getUsdcBalance(aliceParty, aliceValidatorBackend) should be(15 - 0.2) + getUsdcBalance(venueParty, venueValidator) should be(0.2) + }, + ) + + val events = Seq( + createTradeTx -> "Create Trade", + createAllocationRequestsTx -> "Create Allocation Requests", + aliceAllocateTx -> "Alice Allocations", + bobAllocateTx -> "Bob Allocations", + settleTradeTx -> "Settle Trade", + ).map { case (tx, name) => + val updateId = tx.getUpdateId + name -> clue(s"Checking traffic & activity records for '$name'") { + eventually() { + inside(sv1ScanBackend.getEventById(updateId, None)) { + case Some( + item @ EventHistoryItem( + _, + Some(_), + Some(_), + Some(_), + ) + ) => + EventHistoryItem.encodeEventHistoryItem(item) + } } } } - } - val json = io.circe.JsonObject(events*) - val savePath = java.io.File.createTempFile("test_token_v2_settlement_results", ".json").toPath - Files.writeString(savePath, json.toJson.spaces2) + val json = io.circe.JsonObject(events*) + val savePath = + java.io.File.createTempFile("test_token_v2_settlement_results", ".json").toPath + Files.writeString(savePath, json.toJson.spaces2) - logger.info(s"Traffic & Activity Records results written to $savePath") + logger.info(s"Traffic & Activity Records results written to $savePath") + } } } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TokenStandardCliTestDataTimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TokenStandardCliTestDataTimeBasedIntegrationTest.scala index a086e43182..bd8adda202 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TokenStandardCliTestDataTimeBasedIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TokenStandardCliTestDataTimeBasedIntegrationTest.scala @@ -144,7 +144,11 @@ class TokenStandardCliTestDataTimeBasedIntegrationTest updateAllScanAppConfigs_(config => config.copy(parameters = config.parameters.copy(rateLimiting = - RateLimitersConfig(SpliceRateLimitConfig(enabled = false, 1), Map.empty) + RateLimitersConfig( + default = SpliceRateLimitConfig.WithPerClientIp(enabled = false, 1), + rateLimiters = Map.empty, + global = SpliceRateLimitConfig.WithPerClientIp(enabled = false, 1), + ) ) ) )(config) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TokenStandardTransferIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TokenStandardTransferIntegrationTest.scala index 94e7fe219c..fb8da38f96 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TokenStandardTransferIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TokenStandardTransferIntegrationTest.scala @@ -1,7 +1,6 @@ package org.lfdecentralizedtrust.splice.integration.tests import com.digitalasset.canton.concurrent.Threading -import com.digitalasset.canton.console.CommandFailure import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.{HasActorSystem, HasExecutionContext} import org.lfdecentralizedtrust.splice.codegen.java.splice.api.token.metadatav1 @@ -428,25 +427,28 @@ class TokenStandardTransferIntegrationTest trackingId, ) - assertThrows[CommandFailure]( - loggerFactory.assertLogs( - aliceWalletClient.createTokenStandardTransfer( - bobUserParty, - 10, - "not ok, resubmitted same trackingId so should be rejected", - expiration, - trackingId, - ), - _.errorMessage should include("Command submission already exists"), - ) + val createdCid = created.output match { + case members.TransferInstructionPending(value) => value.transferInstructionCid + case x => fail(s"Expected pending transfer, got $x") + } + + // Resubmitting the same trackingId is deduplicated idempotently: the accepted duplicate is + // recovered centrally and returns the original result instead of failing. + val resubmitted = aliceWalletClient.createTokenStandardTransfer( + bobUserParty, + 10, + "resubmitted with the same trackingId", + expiration, + trackingId, ) + inside(resubmitted.output) { case members.TransferInstructionPending(value) => + value.transferInstructionCid shouldBe createdCid + } + // Still exactly one transfer instruction, i.e. no duplicate was created. eventually() { inside(aliceWalletClient.listTokenStandardTransfers()) { case Seq(t) => - t.contractId.contractId should be(created.output match { - case members.TransferInstructionPending(value) => value.transferInstructionCid - case x => fail(s"Expected pending transfer, got $x") - }) + t.contractId.contractId should be(createdCid) } } } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TokenStandardV2TransferIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TokenStandardV2TransferIntegrationTest.scala index 385e16d5c7..574af2078b 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TokenStandardV2TransferIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TokenStandardV2TransferIntegrationTest.scala @@ -1,7 +1,6 @@ package org.lfdecentralizedtrust.splice.integration.tests import com.digitalasset.canton.concurrent.Threading -import com.digitalasset.canton.console.CommandFailure import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.{HasActorSystem, HasExecutionContext} import org.lfdecentralizedtrust.splice.codegen.java.splice.api.token.transferinstructionv2.transferinstructionresult_output.TransferInstructionResult_Completed @@ -381,25 +380,28 @@ class TokenStandardV2TransferIntegrationTest trackingId, ) - assertThrows[CommandFailure]( - loggerFactory.assertLogs( - aliceWalletClient.createTokenStandardTransferV2( - bobUserParty, - 10, - "not ok, resubmitted same trackingId so should be rejected", - expiration, - trackingId, - ), - _.errorMessage should include("Command submission already exists"), - ) + val createdCid = created.output match { + case members.TransferInstructionPending(value) => value.transferInstructionCid + case x => fail(s"Expected pending transfer, got $x") + } + + // Resubmitting the same trackingId is deduplicated idempotently: the accepted duplicate is + // recovered centrally and returns the original result instead of failing. + val resubmitted = aliceWalletClient.createTokenStandardTransferV2( + bobUserParty, + 10, + "resubmitted with the same trackingId", + expiration, + trackingId, ) + inside(resubmitted.output) { case members.TransferInstructionPending(value) => + value.transferInstructionCid shouldBe createdCid + } + // Still exactly one transfer instruction, i.e. no duplicate was created. eventually() { inside(aliceWalletClient.listTokenStandardTransfers()) { case Seq(t) => - t.contractId.contractId should be(created.output match { - case members.TransferInstructionPending(value) => value.transferInstructionCid - case x => fail(s"Expected pending transfer, got $x") - }) + t.contractId.contractId should be(createdCid) } } } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsSvAppTimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsSvAppTimeBasedIntegrationTest.scala index b88a5c992d..13a38c0fd0 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsSvAppTimeBasedIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsSvAppTimeBasedIntegrationTest.scala @@ -36,11 +36,13 @@ import org.lfdecentralizedtrust.splice.sv.automation.RewardMetricsTrigger import org.lfdecentralizedtrust.splice.sv.automation.confirmation.{ CalculateRewardsDryRunTrigger, CalculateRewardsTrigger, + SummarizingMiningRoundTrigger, } import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.{ ProcessRewardsDryRunTrigger, ProcessRewardsTrigger, } +import org.lfdecentralizedtrust.splice.scan.admin.api.client.BftScanConnection import org.lfdecentralizedtrust.splice.scan.automation.RewardComputationTrigger import org.lfdecentralizedtrust.splice.sv.config.InitialRewardConfig import org.lfdecentralizedtrust.splice.util.{ @@ -227,7 +229,7 @@ class TrafficBasedRewardsSvAppTimeBasedIntegrationTest clue("CalculateRewardsV2 contracts are also visible in scan rewards reference store") { eventually() { - val v2s = sv1ScanBackend.appState.rewardsReferenceStoreO.value + val v2s = sv1ScanBackend.appState.rewardsReferenceStore .listActiveCalculateRewardsV2() .futureValue v2s.map(c => @@ -352,6 +354,45 @@ class TrafficBasedRewardsSvAppTimeBasedIntegrationTest confirmMismatchingRootHashIsFlagged(bobParty) } + // sv2's CalculateRewardsTrigger and SummarizingMiningRoundTrigger report the + // scan URIs that formed the BFT consensus at INFO. This method captures the + // logs emitted while running the 'body' argument and asserts that sv2 + // obtained both the root-hash and the reward accounting totals for 'round' + // via BFT read from sv1 and sv4. + private def withExpectedRewardTriggersLogging[A](round: Long)( + body: => A + ): A = { + val bftReadLogs = + (SuppressionRule.forLogger[CalculateRewardsTrigger] || + SuppressionRule.forLogger[SummarizingMiningRoundTrigger]) && + SuppressionRule.LevelAndAbove(Level.INFO) + + loggerFactory.assertEventuallyLogsSeq(bftReadLogs)( + body, + logs => { + // sv3 is stopped and sv2's own scan is not part of its peer BFT connection, + // so only sv1's and sv4's scans can form the consensus. + val expectedScanUris = Set("http://localhost:5012", "http://localhost:5312") + def bftReadLogged(subject: String) = + forAtLeast(1, logs) { log => + val prefix = + s"Obtained the $subject for round $round via BFT read from scans: " + log.loggerName should include("SV=sv2") + log.message should include(prefix) + val scanUris = log.message + .substring(log.message.indexOf(prefix) + prefix.length) + .stripSuffix(".") + .split(", ") + .toSeq + scanUris.size should be(1) + forAll(scanUris)(uri => expectedScanUris should contain(uri)) + } + bftReadLogged("root-hash") + bftReadLogged("reward accounting totals") + }, + ) + } + private def metricValue( node: LocalInstanceReference, name: String, @@ -385,99 +426,101 @@ class TrafficBasedRewardsSvAppTimeBasedIntegrationTest // Pausing this ensures that the root-hash is not calculated while we advance round val sv2RewardComputation = sv2ScanBackend.automation.trigger[RewardComputationTrigger] - // Here we ensure that SV2 has done ingestion of app-activity for the round just closed - // But then its AppActivityRecordMetaT is bumped so that it cannot compute the - // root-hash for the round. - val (calculateRewardsCid, round) = setTriggersWithin( - triggersToPauseAtStart = Seq(sv2CalculateRewards, sv2RewardComputation) - ) { - val round = oldestOpenRound - doTransfer(bobParty) - // Note: we can't use advanceRoundsToNextRoundOpening here, as it blocks - // on summarizing and issuing round to complete, and here the - // summarizing round will block until the sv2 provides the round totals - // via bft read. - advanceTimeAndWaitForRoundOpening - - val (calculateRewardsCid, rootHash) = - clue( - s"Round $round just closed: its CalculateRewardsV2 exists and sv1 serves root-hash" - ) { - eventually() { - val calc = sv1Backend.appState.dsoStore - .listCalculateRewardsV2() - .futureValue - .filterNot(_.payload.dryRun) - .find(_.payload.round.number == round) - .value - val rootHash = inside(sv1ScanBackend.getRewardAccountingRootHash(round)) { - case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashOk(h) => - h.rootHash + val round = oldestOpenRound + withExpectedRewardTriggersLogging(round) { + // Here we ensure that SV2 has done ingestion of app-activity for the round just closed + // But then its AppActivityRecordMetaT is bumped so that it cannot compute the + // root-hash for the round. + val calculateRewardsCid = setTriggersWithin( + triggersToPauseAtStart = Seq(sv2CalculateRewards, sv2RewardComputation) + ) { + doTransfer(bobParty) + // Note: we can't use advanceRoundsToNextRoundOpening here, as it blocks + // on summarizing and issuing round to complete, and here the + // summarizing round will block until the sv2 provides the round totals + // via bft read. + advanceTimeAndWaitForRoundOpening + + val (calculateRewardsCid, rootHash) = + clue( + s"Round $round just closed: its CalculateRewardsV2 exists and sv1 serves root-hash" + ) { + eventually() { + val calc = sv1Backend.appState.dsoStore + .listCalculateRewardsV2() + .futureValue + .filterNot(_.payload.dryRun) + .find(_.payload.round.number == round) + .value + val rootHash = inside(sv1ScanBackend.getRewardAccountingRootHash(round)) { + case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashOk(h) => + h.rootHash + } + (calc.contractId, rootHash) } - (calc.contractId, rootHash) } - } - clue(s"Only sv1 and sv4 confirm round $round, so it is not yet processed") { - eventually() { - val startProcessingAction = new ARC_AmuletRules( - new CRARC_StartProcessingRewardsV2( - new AmuletRules_StartProcessingRewardsV2(calculateRewardsCid, new Hash(rootHash)) + clue(s"Only sv1 and sv4 confirm round $round, so it is not yet processed") { + eventually() { + val startProcessingAction = new ARC_AmuletRules( + new CRARC_StartProcessingRewardsV2( + new AmuletRules_StartProcessingRewardsV2(calculateRewardsCid, new Hash(rootHash)) + ) ) - ) + sv1Backend.appState.dsoStore + .listConfirmations(startProcessingAction) + .futureValue should have size 2 + } sv1Backend.appState.dsoStore - .listConfirmations(startProcessingAction) - .futureValue should have size 2 + .listOldestSummarizingMiningRounds() + .futureValue + .map(_.payload.round.number) should contain(round) } - sv1Backend.appState.dsoStore - .listOldestSummarizingMiningRounds() - .futureValue - .map(_.payload.round.number) should contain(round) - } - // This is trying to simulate AppActivityRecordMetaT's userVersion bump - // albeit in a direct way, to avoid restart of scan app, etc. - actAndCheck( - s"Reset sv2's earliest-ingested round to $round", { - val sv2Db = sv2ScanBackend.appState.storage match { - case db: DbStorage => db - case other => fail(s"Expected DbStorage") - } - implicit val closeContext: CloseContext = CloseContext(sv2Db) - sv2Db - .update_( - sqlu"""update app_activity_record_meta - set earliest_ingested_round = $round, - last_archived_round = null""", - "test.increaseAppActivityMeta_EarliestIngestedRound", - ) - .futureValueUS - }, - )( - s"sv2's own scan now answers CannotProvide for round $round", - _ => - sv2ScanBackend.getRewardAccountingRootHash(round) shouldBe - a[GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashCannotProvide], - ) + // This is trying to simulate AppActivityRecordMetaT's userVersion bump + // albeit in a direct way, to avoid restart of scan app, etc. + actAndCheck( + s"Reset sv2's earliest-ingested round to $round", { + val sv2Db = sv2ScanBackend.appState.storage match { + case db: DbStorage => db + case other => fail(s"Expected DbStorage") + } + implicit val closeContext: CloseContext = CloseContext(sv2Db) + sv2Db + .update_( + sqlu"""update app_activity_record_meta + set earliest_ingested_round = $round, + last_archived_round = null""", + "test.increaseAppActivityMeta_EarliestIngestedRound", + ) + .futureValueUS + }, + )( + s"sv2's own scan now answers CannotProvide for round $round", + _ => + sv2ScanBackend.getRewardAccountingRootHash(round) shouldBe + a[GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashCannotProvide], + ) - (calculateRewardsCid, round) - } + calculateRewardsCid + } - // setTriggersWithin has resumed sv2's CalculateRewardsTrigger. sv3 is stopped and sv2's own - // scan CannotProvide, so the deciding 3rd confirmation can only come from sv2 via bft read. - clue(s"sv2's own scan still answers CannotProvide for round $round") { - sv2ScanBackend.getRewardAccountingRootHash(round) shouldBe - a[GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashCannotProvide] - } + // setTriggersWithin has resumed sv2's CalculateRewardsTrigger. sv3 is stopped and sv2's own + // scan CannotProvide, so the deciding 3rd confirmation can only come from sv2 via bft read. + clue(s"sv2's own scan still answers CannotProvide for round $round") { + sv2ScanBackend.getRewardAccountingRootHash(round) shouldBe + a[GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashCannotProvide] + } - clue( - s"sv2 reads round $round from the sv1 and sv4, and supplies the 3rd confirmation vote" - ) { - eventually() { - sv1Backend.appState.dsoStore - .listCalculateRewardsV2() - .futureValue - .map(_.contractId) should not contain calculateRewardsCid + clue( + s"sv2 reads round $round from the sv1 and sv4, and supplies the 3rd confirmation vote" + ) { + eventually() { + sv1Backend.appState.dsoStore + .listCalculateRewardsV2() + .futureValue + .map(_.contractId) should not contain calculateRewardsCid + } } } @@ -515,15 +558,38 @@ class TrafficBasedRewardsSvAppTimeBasedIntegrationTest } } finally { otherProcessRewardsTriggers.foreach(_.resume()) - clue("Restart sv3") { - sv3ScanBackend.start() - sv3Backend.start() - sv3Backend.waitForInitialization( - timeout = NonNegativeDuration.tryFromDuration(120.seconds) - ) - sv3ScanBackend.waitForInitialization( - timeout = NonNegativeDuration.tryFromDuration(120.seconds) - ) + // On restart, sv3 catches up on the round that was processed while sv3 + // was down. The reward triggers may fire before that round + // advances. sv2's own scan still answers 'CannotProvide' for that round + // (its earliest-ingested round was bumped above), so it contributes an + // 'IgnoreResponse' to sv3's BFT reads, which 'BftScanConnection' logs at + // WARN as "The following Scan URLs disagreed with consensus". These WARNs + // are an expected consequence of the 'CannotProvide' scenario under test, + // so we suppress them (targeted to 'BftScanConnection' WARNs) to keep the + // `sbt checkErrors` log-scan gate green. + // + // The same supression happens in 'withExpectedRewardTriggersLogging' but + // + // 1. 'withExpectedRewardTriggersLogging' targets a narrow part of the try + // block and doesn't expand into this finally block, and + // 2. 'withExpectedRewardTriggersLogging' has strict expectation about the + // logs when rewards trigger fire. Here triggers may or may not fire + // -- it's a race between sv3 catching up and triggers firing. We + // can't guarantee that triggers fire => can't expect that WARNs will + // appear. So we just supress them instead of expecting them. + loggerFactory.suppress( + SuppressionRule.forLogger[BftScanConnection] && SuppressionRule.Level(Level.WARN) + ) { + clue("Restart sv3") { + sv3ScanBackend.start() + sv3Backend.start() + sv3Backend.waitForInitialization( + timeout = NonNegativeDuration.tryFromDuration(120.seconds) + ) + sv3ScanBackend.waitForInitialization( + timeout = NonNegativeDuration.tryFromDuration(120.seconds) + ) + } } } } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsTimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsTimeBasedIntegrationTest.scala index 48c7e1cda3..a283f2599a 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsTimeBasedIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsTimeBasedIntegrationTest.scala @@ -39,6 +39,7 @@ import org.lfdecentralizedtrust.splice.sv.automation.confirmation.{ CalculateRewardsDryRunTrigger, } import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.ExpiredAmuletTransferInstructionTrigger +import org.lfdecentralizedtrust.splice.sv.automation.singlesv.ReceiveSvRewardCouponTrigger import org.lfdecentralizedtrust.splice.util.{ AmuletConfigUtil, ChoiceContextWithDisclosures, @@ -83,7 +84,7 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase override def environmentDefinition: SpliceEnvironmentDefinition = EnvironmentDefinition - .simpleTopology4SvsWithSimTime(this.getClass.getSimpleName) + .simpleTopology1SvWithSimTime(this.getClass.getSimpleName) .withAdditionalSetup(implicit env => { Seq( sv1ValidatorBackend, @@ -117,6 +118,14 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase .withPausedTrigger[CollectRewardsAndMergeAmuletsTrigger] )(config) ) + // Pause SV reward collection so that it does not race against + // advanceTimeAndWaitForRoundOpening in the activity block, + // which would cause "Skipped N SV rewards" warnings + .addConfigTransform((_, config) => + updateAutomationConfig(ConfigurableApp.Sv)( + _.withPausedTrigger[ReceiveSvRewardCouponTrigger] + )(config) + ) "CIP-104 reward accounting pipeline works" in { implicit env => val aliceParty = onboardWalletUser(aliceWalletClient, aliceValidatorBackend) @@ -159,40 +168,9 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase val calculateRewardsDryRunTriggers = activeSvs.map(_.dsoAutomation.trigger[CalculateRewardsDryRunTrigger]) - // 3 initial advances with CalculateRewardsTrigger paused but - // verdict ingestion active, so that the meta row is created and - // bootstrap rounds have activity data available. - setTriggersWithin(triggersToPauseAtStart = - calculateRewardsTriggers ++ calculateRewardsDryRunTriggers - ) { - for (round <- 1 to 3) { - advanceTimeAndWaitForRoundOpening - assertOldestOpenRound(round.toLong) - } - - clue("Bootstrap rounds have zero activity on firstSV (no featured apps yet)") { - assertZeroTotals(sv1ScanBackend, 0L to 2L) - } - - clue("All SVs report zero totals for rounds after bootstrap") { - Seq(sv1ScanBackend, sv2ScanBackend, sv3ScanBackend, sv4ScanBackend).foreach { scan => - assertZeroTotals(scan, 1L to 2L, timeout = 40.seconds) - } - } - } - - // Sequence of actions - // Open rounds | Action - // ------------+-------------------------------------- - // 3, 4 | settle id0, grant venue FAP - // 4, 5 | settle id1, grant alice FAP - // 5, 6 | settle id2, cancel venue FAP - // 6, 7 | settle id3, (total 2 DvP trades) - // 7, 8 | settle id4, (total 3 DvP trades) - // 8, 9 | no-activity - // 9, 10 | settle id5, 1 DvP + 3 direct trades - // 10, 11 | settle id6, (total 5 DvP trades) - // 11, 12 | settle id7, (round not closed) + // CalculateRewardsTrigger is paused for the entire test body so + // that we can confirm CalculateRewardsV2 contracts were created + // for each round before the triggers consume them. val ( updateId0, updateId1, @@ -204,10 +182,34 @@ abstract class TrafficBasedRewardsTimeBasedIntegrationTestBase aliceCreateId, svExpireId, ) = - pauseScanVerdictIngestionWithin(sv1ScanBackend) { - setTriggersWithin(triggersToPauseAtStart = - calculateRewardsTriggers ++ calculateRewardsDryRunTriggers - ) { + setTriggersWithin(triggersToPauseAtStart = + calculateRewardsTriggers ++ calculateRewardsDryRunTriggers + ) { + // 3 initial advances with verdict ingestion active, so that the + // meta row is created and bootstrap rounds have activity data + // available. + for (round <- 1 to 3) { + advanceRoundsToNextRoundOpening + assertOldestOpenRound(round.toLong) + } + + clue("Bootstrap rounds have zero activity on firstSV (no featured apps yet)") { + assertZeroTotals(sv1ScanBackend, 0L to 2L) + } + + // Sequence of actions + // Open rounds | Action + // ------------+-------------------------------------- + // 3, 4 | settle id0, grant venue FAP + // 4, 5 | settle id1, grant alice FAP + // 5, 6 | settle id2, cancel venue FAP + // 6, 7 | settle id3, (total 2 DvP trades) + // 7, 8 | settle id4, (total 3 DvP trades) + // 8, 9 | no-activity + // 9, 10 | settle id5, 1 DvP + 3 direct trades + // 10, 11 | settle id6, (total 5 DvP trades) + // 11, 12 | settle id7, (round not closed) + pauseScanVerdictIngestionWithin(sv1ScanBackend) { val id0 = settleTrade(aliceParty, bobParty, venueParty) grantFeaturedAppRight(splitwellWalletClient) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/UnhideAndExpireRewardCouponV2TimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/UnhideAndExpireRewardCouponV2TimeBasedIntegrationTest.scala index 1c313bf995..613362b3e4 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/UnhideAndExpireRewardCouponV2TimeBasedIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/UnhideAndExpireRewardCouponV2TimeBasedIntegrationTest.scala @@ -28,12 +28,14 @@ import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.{ IntegrationTestWithIsolatedEnvironment, SpliceTestConsoleEnvironment, } -import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.ExpireRewardCouponV2Trigger +import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.{ + ExpireRewardCouponV2Trigger, + UnhideRewardCouponV2Trigger, +} import org.lfdecentralizedtrust.splice.sv.config.InitialRewardConfig import org.lfdecentralizedtrust.splice.util.{ ChoiceContextWithDisclosures, TimeTestUtil, - TriggerTestUtil, UploadablePackage, WalletTestUtil, } @@ -54,26 +56,36 @@ class UnhideAndExpireRewardCouponV2TimeBasedIntegrationTest extends IntegrationTestWithIsolatedEnvironment with HasExecutionContext with WalletTestUtil - with TriggerTestUtil with TimeTestUtil { - private val v2AmuletVersion = DarResources.amulet_0_1_19.metadata.version + // Version where V2 was introduced, or the current minimum initialization version if higher + private val minV2AmuletVersion = + Ordering[PackageVersion].max( + DarResources.amulet.minimumInitialization.metadata.version, + DarResources.amulet_0_1_19.metadata.version, + ) + + private val minV2AmuletPackageId = + DarResources.amulet.getPackageIdWithVersion(minV2AmuletVersion.toString).value - private val previousAmuletPackageId = - DarResources.amulet.others - .filter(_.metadata.version < v2AmuletVersion) - .maxBy(_.metadata.version) - .packageId + private val latestAmuletDar: DarResource = DarResources.amulet.latest private val v2CapableAmuletPackageIds: Seq[String] = DarResources.amulet.all - .filter(_.metadata.version >= v2AmuletVersion) + .filter(_.metadata.version >= minV2AmuletVersion) + .map(_.packageId) + .distinct + + private val amuletVersionsAboveOldestV2: Seq[String] = + DarResources.amulet.all + .filter(_.metadata.version > minV2AmuletVersion) .map(_.packageId) .distinct - // Set of packages alice must not vet to have wrong vetting state for v2 coupons - private val v2CapableDarsUnvettedOnAlice: Seq[DarResource] = { - val v2CapableAmuletIds = v2CapableAmuletPackageIds.toSet + // Only the latest is unvetted, as this would still cause + // ProcessRewardsTrigger to create hidden coupons + private val darsUnvettedOnAliceAtStart: Seq[DarResource] = { + val latestAmuletIds = Set(latestAmuletDar.packageId) Seq( DarResources.amulet, DarResources.amuletNameService, @@ -82,8 +94,8 @@ class UnhideAndExpireRewardCouponV2TimeBasedIntegrationTest DarResources.walletPayments, ).flatMap(_.all) .filter(d => - v2CapableAmuletIds.contains(d.packageId) || - d.dependencyPackageIds.exists(v2CapableAmuletIds.contains) + latestAmuletIds.contains(d.packageId) || + d.dependencyPackageIds.exists(latestAmuletIds.contains) ) .distinctBy(d => (d.metadata.name, d.metadata.version)) } @@ -99,7 +111,7 @@ class UnhideAndExpireRewardCouponV2TimeBasedIntegrationTest (aliceValidator -> config .validatorApps(aliceValidator) .copy( - additionalPackagesToUnvet = v2CapableDarsUnvettedOnAlice + additionalPackagesToUnvet = darsUnvettedOnAliceAtStart .groupBy(_.metadata.name) .map { case (name, resources) => name -> resources.map(_.metadata.version).toSet @@ -121,6 +133,11 @@ class UnhideAndExpireRewardCouponV2TimeBasedIntegrationTest _.withPausedTrigger[AcceptedTransferOfferTrigger] )(config) ) + .addConfigTransform((_, config) => + updateAutomationConfig(ConfigurableApp.Sv)( + _.withPausedTrigger[UnhideRewardCouponV2Trigger] + )(config) + ) .addConfigTransform((_, config) => ConfigTransforms.updateAllSvAppConfigs_(svConfig => svConfig.copy( @@ -135,7 +152,7 @@ class UnhideAndExpireRewardCouponV2TimeBasedIntegrationTest "Unhide and expire of RewardCouponV2" in { implicit env => val aliceParticipantId = aliceValidatorBackend.appState.participantAdminConnection.getParticipantId().futureValue - assertAliceVettedBelowV2(aliceParticipantId) + assertAliceVettedBelowLatest(aliceParticipantId) val (aliceParty, bobParty) = onboardAliceAndBobWithFeaturedRights() @@ -216,6 +233,10 @@ class UnhideAndExpireRewardCouponV2TimeBasedIntegrationTest clue("UnhideRewardCouponV2Trigger unhides Alice's coupons once she is re-vetted") { eventually() { + sv1Backend.dsoDelegateBasedAutomation + .trigger[UnhideRewardCouponV2Trigger] + .runOnce() + .futureValue val coupons = aliceCoupons coupons should not be empty coupons.filterNot(_.payload.providerIsObserver) shouldBe empty @@ -223,6 +244,112 @@ class UnhideAndExpireRewardCouponV2TimeBasedIntegrationTest } } + // Scenario for #6372, both providers have V2 capable versions vetted, but they lack a common vetted version. + clue( + "ProcessRewardsTrigger handles a batch where providers have jointly-incompatible vetting states" + ) { + val bobParticipantId = + bobValidatorBackend.appState.participantAdminConnection.getParticipantId().futureValue + + // Alice unvets minV2AmuletVersion; Bob keeps only minV2AmuletVersion, nothing after it. + actAndCheck( + s"Unvet $minV2AmuletPackageId on Alice and $amuletVersionsAboveOldestV2 on Bob", { + aliceValidatorBackend.participantClient.topology.vetted_packages.propose_delta( + aliceParticipantId, + removes = Seq(PackageId.assertFromString(minV2AmuletPackageId)), + force = ForceFlags(ForceFlag.AllowUnvettedDependencies), + store = TopologyStoreId.Synchronizer(decentralizedSynchronizerId), + ) + bobValidatorBackend.participantClient.topology.vetted_packages.propose_delta( + bobParticipantId, + removes = amuletVersionsAboveOldestV2.map(PackageId.assertFromString), + force = ForceFlags(ForceFlag.AllowUnvettedDependencies), + store = TopologyStoreId.Synchronizer(decentralizedSynchronizerId), + ) + }, + )( + "sv1's participant observes Alice no longer has minV2AmuletVersion vetted, and Bob's vetting is capped at minV2AmuletVersion", + _ => { + vettedPackagesOnSv1View(aliceParticipantId) should not contain + minV2AmuletPackageId + vettedPackagesOnSv1View(bobParticipantId) + .intersect(amuletVersionsAboveOldestV2) shouldBe empty + + val aliceVettedAboveMin = + vettedPackagesOnSv1View(aliceParticipantId).intersect(amuletVersionsAboveOldestV2) + val bobVettedAboveMin = + vettedPackagesOnSv1View(bobParticipantId).intersect(amuletVersionsAboveOldestV2) + aliceVettedAboveMin.intersect(bobVettedAboveMin) shouldBe empty + }, + ) + + val (round, _) = actAndCheck( + "Generate activity", { + doTransfer() + val round = oldestOpenRound + advanceRoundsToNextRoundOpening + round + }, + )( + "ProcessRewardsTrigger issues coupons for the round", + round => { + val newAliceCoupons = aliceCoupons.filter(_.payload.round.number == round) + val newBobCoupons = bobUnassignedCoupons.filter(_.payload.round.number == round) + newAliceCoupons should not be empty + newAliceCoupons.foreach(_.payload.providerIsObserver shouldBe true) + newBobCoupons should not be empty + newBobCoupons.foreach(_.payload.providerIsObserver shouldBe false) + }, + ) + + clue("UnhideRewardCouponV2Trigger can unhide Bob's coupon before he is fully re-vetted") { + eventually() { + sv1Backend.dsoDelegateBasedAutomation + .trigger[UnhideRewardCouponV2Trigger] + .runOnce() + .futureValue + val coupons = bobUnassignedCoupons.filter(_.payload.round.number == round) + coupons should not be empty + coupons.filterNot(_.payload.providerIsObserver) shouldBe empty + } + } + + clue("Restore Alice's and Bob's full vetting") { + actAndCheck( + s"Re-vet the minV2AmuletVersion package on Alice and $amuletVersionsAboveOldestV2 on Bob", { + aliceValidatorBackend.participantClient.topology.vetted_packages.propose_delta( + aliceParticipantId, + adds = Seq( + VettedPackage( + PackageId.assertFromString(minV2AmuletPackageId), + None, + None, + ) + ), + store = TopologyStoreId.Synchronizer(decentralizedSynchronizerId), + ) + bobValidatorBackend.participantClient.topology.vetted_packages.propose_delta( + bobParticipantId, + adds = amuletVersionsAboveOldestV2.map(id => + VettedPackage(PackageId.assertFromString(id), None, None) + ), + store = TopologyStoreId.Synchronizer(decentralizedSynchronizerId), + ) + }, + )( + "sv1's participant observes Alice and Bob are fully vetted again", + _ => { + v2CapableAmuletPackageIds.toSet.subsetOf( + vettedPackagesOnSv1View(aliceParticipantId).toSet + ) shouldBe true + v2CapableAmuletPackageIds.toSet.subsetOf( + vettedPackagesOnSv1View(bobParticipantId).toSet + ) shouldBe true + }, + ) + } + } + clue( "RewardCouponV2 can be assigned after vetting, even when beneficiary is offline" ) { @@ -344,22 +471,27 @@ class UnhideAndExpireRewardCouponV2TimeBasedIntegrationTest } } - private def aliceVettedPackagesOnSv1View( - aliceParticipantId: ParticipantId + private def vettedPackagesOnSv1View( + participantId: ParticipantId )(implicit env: SpliceTestConsoleEnvironment): Seq[String] = sv1ValidatorBackend.appState.participantAdminConnection - .listVettedPackages(aliceParticipantId, decentralizedSynchronizerId, AuthorizedState) + .listVettedPackages(participantId, decentralizedSynchronizerId, AuthorizedState) .futureValue .flatMap(_.mapping.packages.map(_.packageId)) - private def assertAliceVettedBelowV2( + private def assertAliceVettedBelowLatest( aliceParticipantId: ParticipantId )(implicit env: SpliceTestConsoleEnvironment): Unit = - clue("Alice's validator vets the highest amulet below the V2 but none at/above it") { + clue("Alice's validator vets the second-latest amulet version but not the latest") { eventually() { - val vetted = aliceVettedPackagesOnSv1View(aliceParticipantId) - vetted should contain(previousAmuletPackageId) - vetted.intersect(v2CapableAmuletPackageIds) shouldBe empty + val vetted = vettedPackagesOnSv1View(aliceParticipantId) + vetted should contain( + DarResources.amulet.others + .filter(_.metadata.version < latestAmuletDar.metadata.version) + .maxBy(_.metadata.version) + .packageId + ) + vetted should not contain latestAmuletDar.packageId } } @@ -402,18 +534,18 @@ class UnhideAndExpireRewardCouponV2TimeBasedIntegrationTest val aliceAdminConnection = aliceValidatorBackend.appState.participantAdminConnection aliceAdminConnection .uploadDarFiles( - v2CapableDarsUnvettedOnAlice.map(UploadablePackage.fromResource), + darsUnvettedOnAliceAtStart.map(UploadablePackage.fromResource), RetryFor.Automation, ) .futureValue aliceAdminConnection - .vetDars(decentralizedSynchronizerId, v2CapableDarsUnvettedOnAlice, None, None) + .vetDars(decentralizedSynchronizerId, darsUnvettedOnAliceAtStart, None, None) .futureValue }, )( "sv1's participant observes Alice has the correct vetting state for RewardAccountingV2", _ => - aliceVettedPackagesOnSv1View(aliceParticipantId) should contain( + vettedPackagesOnSv1View(aliceParticipantId) should contain( DarResources.amulet.latest.packageId ), ) @@ -432,7 +564,7 @@ class UnhideAndExpireRewardCouponV2TimeBasedIntegrationTest )( "sv1's participant observes Alice is in the wrong vetting state", _ => - aliceVettedPackagesOnSv1View(aliceParticipantId) + vettedPackagesOnSv1View(aliceParticipantId) .intersect(v2CapableAmuletPackageIds) shouldBe empty, ) @@ -454,9 +586,9 @@ class UnhideAndExpireRewardCouponV2TimeBasedIntegrationTest "sv1's participant observes Alice has the correct vetting state again", _ => { v2CapableAmuletPackageIds.toSet - .subsetOf(aliceVettedPackagesOnSv1View(aliceParticipantId).toSet) shouldBe true + .subsetOf(vettedPackagesOnSv1View(aliceParticipantId).toSet) shouldBe true aliceLedgerApiAmuletVersionOnSv1View(aliceParty).exists( - _ >= v2AmuletVersion + _ >= minV2AmuletVersion ) shouldBe true }, ) @@ -488,6 +620,11 @@ class UnhideAndExpireRewardCouponV2TimeBasedIntegrationTest } } + private def oldestOpenRound(implicit env: SpliceTestConsoleEnvironment): Long = { + val (openRounds, _) = sv1ScanBackend.getOpenAndIssuingMiningRounds() + openRounds.map(_.contract.payload.round.number.toLong).min + } + private def assertOldestOpenRound( expected: Long )(implicit env: SpliceTestConsoleEnvironment): Unit = { diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/UnsupportedPackageVettingIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/UnsupportedPackageVettingIntegrationTest.scala index e981fbe357..d7f9b156ff 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/UnsupportedPackageVettingIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/UnsupportedPackageVettingIntegrationTest.scala @@ -29,29 +29,52 @@ import org.lfdecentralizedtrust.splice.util.{ UploadablePackage, WalletTestUtil, } +import org.lfdecentralizedtrust.splice.config.ConfigTransforms.updateAllValidatorConfigs import org.lfdecentralizedtrust.splice.validator.automation.ValidatorPackageVettingTrigger import org.scalatest.concurrent.PatienceConfiguration -import scala.concurrent.duration.DurationInt +import scala.concurrent.duration.DurationInt import scala.concurrent.duration.FiniteDuration +import com.digitalasset.canton.logging.SuppressionRule +import org.lfdecentralizedtrust.splice.config.ConfigTransforms +import org.slf4j.event.Level +@org.lfdecentralizedtrust.splice.util.scalatesttags.NoDamlCompatibilityCheck class UnsupportedPackageVettingIntegrationTest extends IntegrationTest with PackageUnvettingUtil with AmuletConfigUtil with WalletTestUtil { + // Prevent failures due to: + // NO_VETTED_INTERFACE_IMPLEMENTATION_PACKAGE(9,f5ce331d): + // No vetted package for rendering the interface view for package-name 'splice-amulet' + override protected def runTokenStandardCliSanityCheck: Boolean = false + override def environmentDefinition: SpliceEnvironmentDefinition = EnvironmentDefinition .simpleTopology1Sv(this.getClass.getSimpleName) .withoutAliceValidatorConnectingToSplitwell // if other tests run before, packages that break this test might already be vetted .withNoVettedPackages(implicit env => env.validators.local.map(_.participantClient)) + .withReducedAmuletRulesCacheTTL() .addConfigTransforms((_, config) => updateAutomationConfig(ConfigurableApp.Sv)( _.withPausedTrigger[SvPackageVettingTrigger] )(config) ) + .addConfigTransforms((_, config) => + updateAllValidatorConfigs { case (name, c) => + if (name == "aliceValidator" || name == "bobValidator") { + c.copy( + automation = c.automation.withPausedTrigger[ValidatorPackageVettingTrigger] + ) + } else c + }(config) + ) + .addConfigTransforms((_, config) => + ConfigTransforms.useDecentralizedSynchronizerSplitwell()(config) + ) "Unsupported vetted packages are automatically removed by the package vetting trigger for SV and validator" in { implicit env => @@ -78,12 +101,11 @@ class UnsupportedPackageVettingIntegrationTest unsupportedDarsToVetSv, sv1Backend.dsoAutomation.trigger[SvPackageVettingTrigger], ) - // See https://github.com/DACH-NY/canton/issues/29834: set darsUnvettedByAutomation when unvetting works on non-sv validators test( aliceValidatorBackend.appState.participantAdminConnection, synchronizerId, unsupportedDarsToVetValidator, - Seq.empty, + unsupportedDarsToVetValidator, aliceValidatorBackend.validatorAutomation.trigger[ValidatorPackageVettingTrigger], ) } @@ -128,7 +150,7 @@ class UnsupportedPackageVettingIntegrationTest } } - "SVs unvet package versions above the configured PackageConfig, validators do not" in { + "SVs and validators unvet package versions above the configured PackageConfig" in { implicit env => val synchronizerId = sv1Backend.participantClient.synchronizers.list_connected().head.synchronizerId @@ -190,16 +212,82 @@ class UnsupportedPackageVettingIntegrationTest } } - clue("alice validator keeps package versions above the downgraded PackageConfig vetted") { + clue("alice validator unvets package versions above the downgraded PackageConfig") { eventually() { getVettedPackageIds( aliceValidatorBackend.appState.participantAdminConnection, synchronizerId, - ) should contain allElementsOf validatorDarsAbovePackageConfigVersion.map(_.packageId) + ) should contain noElementsOf validatorDarsAbovePackageConfigVersion.map(_.packageId) } eventually(40.seconds) { alicesTapsWithPackageId(DarResources.amulet_0_1_16.packageId) } } } + + "Unvetting amulet does not affect a validator that has splitwell depending on it" in { + implicit env => + val bobValidatorVettingTrigger = + bobValidatorBackend.validatorAutomation.trigger[ValidatorPackageVettingTrigger] + + val synchronizerId = + sv1Backend.participantClient.synchronizers.list_connected().head.synchronizerId + + val bobParticipant = bobValidatorBackend.appState.participantAdminConnection + val splitwellParticipant = splitwellValidatorBackend.appState.participantAdminConnection + + val splitwellDar = DarResources.splitwell_0_1_0 + val amuletDependency = DarResources.amulet_0_1_0 + + actAndCheck( + "bob and splitwell upload and vet splitwell-0.1.0 (which vets amulet-0.1.0 as a dependency)", { + val participants = Seq(bobParticipant, splitwellParticipant) + participants.foreach( + _.uploadDarFiles( + Seq(splitwellDar).map(UploadablePackage.fromResource), + RetryFor.Automation, + ).futureValue + ) + participants.foreach( + _.vetDars(synchronizerId, Seq(splitwellDar), None, None) + .futureValue(timeout = PatienceConfiguration.Timeout(FiniteDuration(40, "seconds"))) + ) + }, + )( + "both splitwell-0.1.0 and amulet-0.1.0 are vetted on bob's participant", + _ => { + val vettedIds = getVettedPackageIds(bobParticipant, synchronizerId) + vettedIds should contain(splitwellDar.packageId) + vettedIds should contain(amuletDependency.packageId) + }, + ) + + clue("amulet-0.1.0 is unvetted on bob") { + loggerFactory.assertEventuallyLogsSeq(SuppressionRule.LevelAndAbove(Level.INFO))( + bobValidatorVettingTrigger.resume(), + entries => { + forAtLeast(1, entries)( + _.message should include regex "Success: dars .*48cac5ba4b6bf78df6c3a952ce05409a1d2ef39c05351074679adc0cf9cd1351.* are removed .*" + ) + }, + timeUntilSuccess = 40.seconds, + ) + } + + clue("splitwell-0.1.0 remains vetted after trigger ran") { + eventually() { + val vettedIds = getVettedPackageIds(bobParticipant, synchronizerId) + vettedIds should contain(splitwellDar.packageId) + vettedIds should not contain amuletDependency.packageId + } + } + + clue("splitwell is still usable on bob") { + onboardWalletUser(bobWalletClient, bobValidatorBackend) + eventually() { + bobSplitwellClient.createInstallRequests() should not be empty + } + } + + } } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ValidatorReonboardingIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ValidatorReonboardingIntegrationTest.scala index 7d5251a6d6..821e3af09c 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ValidatorReonboardingIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ValidatorReonboardingIntegrationTest.scala @@ -274,7 +274,7 @@ class ValidatorReonboardingIntegrationTest extends ValidatorReonboardingIntegrat val lockedAmount = walletUsdToAmulet(BigDecimal(50)) actAndCheck( - "alice locks a amulet that both aliceParty and aliceValidatorWalletParty are stake holders", + "alice locks an amulet that has both aliceParty and aliceValidatorWalletParty as stakeholders", lockAmulets( aliceValidatorBackend, aliceParty, diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletBuyTrafficRequestIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletBuyTrafficRequestIntegrationTest.scala index b05a07e46e..412a5d8cb9 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletBuyTrafficRequestIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletBuyTrafficRequestIntegrationTest.scala @@ -307,6 +307,12 @@ class WalletBuyTrafficRequestIntegrationTest )) ), ) + // All 10 calls share the same tracking id, but the dedup recovery does not apply here: + // reading back an accepted duplicate needs a completed submission, which concurrent + // submissions do not have. So one call wins and the other 9 are rejected, mostly with + // SUBMISSION_ALREADY_IN_FLIGHT from the participant and some with 429 from the rate + // limiter. Duplicates of an already-completed submission are covered by the sequential + // dedup tests. successes shouldBe 1 } } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletIntegrationTest.scala index f37c9c21d5..1c67b378f1 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletIntegrationTest.scala @@ -2,7 +2,6 @@ package org.lfdecentralizedtrust.splice.integration.tests import org.lfdecentralizedtrust.splice.auth.AuthUtil import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet as amuletCodegen -import org.lfdecentralizedtrust.splice.codegen.java.splice.types.Round import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.payment as walletCodegen import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.transferpreapproval.TransferPreapprovalProposal import org.lfdecentralizedtrust.splice.http.v0.definitions.TapRequest @@ -12,11 +11,7 @@ import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.BracketSynchronous.bracket import org.lfdecentralizedtrust.splice.integration.tests.WalletTxLogTestUtil import org.lfdecentralizedtrust.splice.store.MultiDomainAcsStore.ContractState -import org.lfdecentralizedtrust.splice.util.{ - SpliceUtil, - WalletTestUtil, - JavaDecodeUtil as DecodeUtil, -} +import org.lfdecentralizedtrust.splice.util.{WalletTestUtil, JavaDecodeUtil as DecodeUtil} import org.lfdecentralizedtrust.splice.validator.automation.AcceptTransferPreapprovalProposalTrigger import org.lfdecentralizedtrust.splice.wallet.admin.api.client.commands.HttpWalletAppClient.CreateTransferPreapprovalResponse import org.lfdecentralizedtrust.splice.wallet.store.{ @@ -34,6 +29,8 @@ import com.digitalasset.canton.discard.Implicits.DiscardOps import org.apache.pekko.http.scaladsl.Http import org.apache.pekko.http.scaladsl.model.{HttpRequest, HttpResponse, StatusCodes} import org.apache.pekko.http.scaladsl.model.headers.{Authorization, OAuth2BearerToken} +import org.scalatest.concurrent.PatienceConfiguration +import org.scalatest.time.{Seconds, Span} import org.slf4j.event.Level import java.time.Duration @@ -58,41 +55,37 @@ class WalletIntegrationTest "A wallet" should { - // TODO (#2336): unignore this test - "tap stupid amount" ignore { implicit env => + val tapLimit = 100000000 + + s"tap $tapLimit amount" in { implicit env => import com.digitalasset.daml.lf.data.Numeric val aliceParty = onboardWalletUser(aliceWalletClient, aliceValidatorBackend) val round = sv1ScanBackend.getLatestOpenMiningRound(env.environment.clock.now) val price = round.contract.payload.amuletPrice val decimalScale = Numeric.Scale.assertFromInt(10) - // We subtract one to allow some slack in back/forth conversions from CC to USD. Otherwise, - // the command gets rejected by the participant and we test nothing. - val maxDecimal = Numeric - .subtract(Numeric.maxValue(decimalScale), Numeric.assertFromBigDecimal(decimalScale, 1)) - .value val maxUsd = Numeric - .multiply(decimalScale, maxDecimal, Numeric.assertFromBigDecimal(decimalScale, price)) + .multiply( + decimalScale, + Numeric.assertFromBigDecimal(decimalScale, tapLimit), + Numeric.assertFromBigDecimal(decimalScale, price), + ) .value // Integration test that the tap goes through aliceWalletClient.tap(maxUsd) val amulet = aliceValidatorBackend.participantClientWithAdminToken.ledger_api_extensions.acs .filterJava(amuletCodegen.Amulet.COMPANION)(aliceParty, _ => true) .loneElement - // Unit test that expiry does the right thing - SpliceUtil.amuletExpiresAt(amulet.data) shouldBe new Round(Long.MaxValue) - // Test that the USD/CC conversions get us to the max Decimal value ignoring decimal points + // Test that the USD/CC conversions get us to the limit ignoring decimal points amulet.data.amount.initialAmount.setScale(0, java.math.RoundingMode.DOWN) shouldBe Numeric - .maxValue(decimalScale) + .assertFromBigDecimal(decimalScale, tapLimit) .setScale(0, java.math.RoundingMode.DOWN) } "tap deduplicates" in { implicit env => onboardWalletUser(aliceWalletClient, aliceValidatorBackend) aliceWalletClient.tap(50.0, Some("dedup-test")) - assertThrowsAndLogsCommandFailures( - aliceWalletClient.tap(50.0, Some("dedup-test")), - _.errorMessage should include("409 Conflict"), - ) + // Duplicate tap with the same command id returns the original result idempotently (200, not 409). + aliceWalletClient.tap(50.0, Some("dedup-test")) } "allow two wallet app users to connect to one wallet backend and tap" in { implicit env => @@ -225,9 +218,12 @@ class WalletIntegrationTest val tapsAfter = Range(0, 3).map(_ => Future(Try(aliceWalletClient.tap(10)))) - // Wait for all futures to complete - val successfulTaps = (tapsBefore ++ tapsAfter).map(_.futureValue).count(_.isSuccess) - if (failedAcceptF.futureValue.isSuccess) + // Wait for all futures to complete. The stale accept forces the treasury to filter + // and retry batches, so under load this can exceed the default patience. + val patience = PatienceConfiguration.Timeout(Span(60, Seconds)) + val successfulTaps = + (tapsBefore ++ tapsAfter).map(_.futureValue(patience)).count(_.isSuccess) + if (failedAcceptF.futureValue(patience).isSuccess) fail("The AcceptTransferOffer action unexpectedly succeeded") successfulTaps should be( @@ -543,10 +539,11 @@ class WalletIntegrationTest aliceWalletClient.balance().unlockedQty should be(40.0) }, ) - assertThrowsAndLogsCommandFailures( - bobWalletClient.transferPreapprovalSend(aliceUserParty, 40.0, deduplicationId), - _.errorMessage should include("409 Conflict"), - ) + // Duplicate send with same deduplication id returns the original result idempotently (200, not 409). + bobWalletClient.transferPreapprovalSend(aliceUserParty, 40.0, deduplicationId) + // Balance is unchanged — idempotent + bobWalletClient.balance().unlockedQty should be(60.0) + aliceWalletClient.balance().unlockedQty should be(40.0) clue("Preapproval sends work if provider has a featured app right") { // Feature alice validator to test a transfer with a featured preapproval provider diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletMintingDelegationTimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletMintingDelegationTimeBasedIntegrationTest.scala index 1e25e56b30..8ea958b5c6 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletMintingDelegationTimeBasedIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletMintingDelegationTimeBasedIntegrationTest.scala @@ -51,6 +51,7 @@ class WalletMintingDelegationTimeBasedIntegrationTest // Pre-generate key pairs so external party IDs are known at config time private val sharingAppProvider = preGenerateExternalParty("sharing_app_provider") private val sharingRecipient = preGenerateExternalParty("sharing_recipient") + private val externalSharingProvider = preGenerateExternalParty("external_sharing_provider") // We create many coupons directly, so avoid running sanity checks override protected def runUpdateHistorySanityCheck: Boolean = false @@ -66,13 +67,14 @@ class WalletMintingDelegationTimeBasedIntegrationTest updateAllValidatorConfigs { case (name, c) => if (name == "aliceValidator") { c.copy( - rewardSharingConfigByParty = Map( - sharingAppProvider.partyId.toProtoPrimitive -> RewardSharingConfig( + rewardSharingConfigByParty = Map[String, RewardSharingConfig]( + sharingAppProvider.partyId.toProtoPrimitive -> RewardSharingConfig.BuiltIn( minTtlAfterSharing = NonNegativeFiniteDuration.ofHours(25), beneficiaries = Seq( AppRewardBeneficiaryConfig(sharingRecipient.partyId, BigDecimal(0.4)) ), - ) + ), + externalSharingProvider.partyId.toProtoPrimitive -> RewardSharingConfig.External(), ) ) } else c @@ -756,6 +758,106 @@ class WalletMintingDelegationTimeBasedIntegrationTest } } } + "mint already-assigned V2 coupons but hold back unassigned ones in external sharing mode" in { + implicit env => + val aliceParty = onboardWalletUser(aliceWalletClient, aliceValidatorBackend) + aliceWalletClient.tap(100.0) + aliceValidatorWalletClient.tap(100.0) + + val externalSharingParty = + onboardExternalParty(aliceValidatorBackend, externalSharingProvider) + createAndAcceptExternalPartySetupProposal(aliceValidatorBackend, externalSharingParty) + + val expiresAt = env.environment.clock.now.plus(Duration.ofDays(30)).toInstant + val (_, proposalContractId) = actAndCheck( + "Create minting delegation proposal", + createMintingDelegationProposal(externalSharingParty, aliceParty, expiresAt), + )( + "Proposal is visible", + _ => { + val proposals = aliceWalletClient.listMintingDelegationProposals() + proposals.proposals should have size 1 withClue "proposals" + proposals.proposals.head.contract.contractId + }, + ) + + actAndCheck( + "Alice accepts the proposal", + aliceWalletClient.acceptMintingDelegationProposal(proposalContractId), + )( + "Delegation is created", + _ => { + val delegations = aliceWalletClient.listMintingDelegations() + delegations.delegations should have size 1 withClue "delegations" + }, + ) + + val unassignedAmount1 = BigDecimal(1000.0) + val unassignedAmount2 = BigDecimal(500.0) + val assignedAmount = BigDecimal(250.0) + + val externalPartyMintingDelegationTrigger = mintingDelegationCollectRewardsTrigger( + aliceValidatorBackend, + externalSharingParty.party, + ) + + val externalPartyWallet = aliceValidatorBackend.appState.walletManager + .valueOrFail("WalletManager is expected to be defined") + .externalPartyWalletManager + .lookupExternalPartyWallet(externalSharingParty.party) + .valueOrFail( + s"Expected ${externalSharingParty.party} to have an external party wallet" + ) + + // Pause the trigger, create two unassigned and one already-assigned V2 + // coupon, then resume. In external sharing mode the off-node automation + // owns beneficiary assignment, so the trigger must leave the unassigned + // coupons untouched while still minting the already-assigned coupon. + setTriggersWithin(triggersToPauseAtStart = Seq(externalPartyMintingDelegationTrigger)) { + actAndCheck( + "Create V2 coupons", + createRewardCouponsV2( + Seq( + (externalSharingParty.party, unassignedAmount1, None), + (externalSharingParty.party, unassignedAmount2, None), + (externalSharingParty.party, assignedAmount, Some(externalSharingParty.party)), + ) + ), + )( + "Coupons are visible in store", + _ => + externalPartyWallet.store.multiDomainAcsStore + .listContracts(RewardCouponV2.COMPANION) + .futureValue should have size 3, + ) + } + + clue("Assigned coupon is minted while unassigned coupons are held back untouched") { + eventually() { + val v2Coupons = externalPartyWallet.store.multiDomainAcsStore + .listContracts(RewardCouponV2.COMPANION) + .futureValue + v2Coupons.filter(_.payload.beneficiary.isEmpty) should have size 2 withClue + "external sharing mode must leave unassigned coupons untouched" + v2Coupons.filter(_.payload.beneficiary.isPresent) shouldBe + empty withClue "the already-assigned coupon must be minted and consumed" + } + } + + // Only the already-assigned coupon is minted; the two unassigned coupons + // are neither shared nor collected, so they do not contribute to the balance. + clue("Balance reflects only the directly minted assigned coupon") { + eventually() { + val balance = BigDecimal( + aliceValidatorBackend + .getExternalPartyBalance(externalSharingParty.party) + .totalUnlockedCoin + ) + balance shouldBe assignedAmount withClue + "external sharing mode mints only the already-assigned coupon, not the held-back unassigned ones" + } + } + } } private def collectRewardsAndMergeAmuletsTrigger( diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletPaymentIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletPaymentIntegrationTest.scala index 40040b601d..8687bbf9e9 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletPaymentIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletPaymentIntegrationTest.scala @@ -252,6 +252,15 @@ class WalletPaymentIntegrationTest extends IntegrationTest with WalletTestUtil { trackingId, ) + // Wait for the offer to be ingested, so that the resubmission hits the trackingId check + // instead of racing it. A duplicate that loses the race is recovered from the ledger and + // returns the original offer rather than failing. + eventually() { + inside(aliceWalletClient.listTransferOffers()) { case Seq(t) => + t.contractId should be(offerId) + } + } + assertThrows[CommandFailure]( loggerFactory.assertLogs( aliceWalletClient.createTransferOffer( @@ -261,8 +270,8 @@ class WalletPaymentIntegrationTest extends IntegrationTest with WalletTestUtil { expiration, trackingId, ), - _.errorMessage should include("Command submission already exists").or( - include(s"Transfer offer with trackingId ${trackingId} already exists.") + _.errorMessage should include( + s"Transfer offer with trackingId ${trackingId} already exists." ), ) ) @@ -271,9 +280,6 @@ class WalletPaymentIntegrationTest extends IntegrationTest with WalletTestUtil { inside(aliceWalletClient.listTransferOffers()) { case Seq(t) => t.contractId should be(offerId) } - inside(aliceWalletClient.listTransferOffers()) { case Seq(t) => - t.contractId should be(offerId) - } } } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletRewardsTimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletRewardsTimeBasedIntegrationTest.scala index 39852b9dde..5cabd4b0bf 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletRewardsTimeBasedIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletRewardsTimeBasedIntegrationTest.scala @@ -19,7 +19,10 @@ import org.lfdecentralizedtrust.splice.util.{ WalletTestUtil, } import org.lfdecentralizedtrust.splice.validator.automation.ReceiveFaucetCouponTrigger -import org.lfdecentralizedtrust.splice.wallet.automation.CollectRewardsAndMergeAmuletsTrigger +import org.lfdecentralizedtrust.splice.wallet.automation.{ + CollectRewardsAndMergeAmuletsTrigger, + RewardSharingTrigger, +} import org.lfdecentralizedtrust.splice.wallet.config.{ AppRewardBeneficiaryConfig, RewardSharingConfig, @@ -31,6 +34,11 @@ import scala.concurrent.duration.DurationInt * that the sharing trigger correctly assigns beneficiaries with the right * amounts (batching multiple coupons), that the minting trigger does not * re-assign unshared coupons, and that balances reflect the minted rewards. + * The test also verifies external sharing mode: a party configured with the + * External reward-sharing config gets no built-in sharing trigger, and its + * unassigned reward coupons are left untouched (neither shared nor collected). + * This confirms that built-in and external sharing modes co-exist in one + * environment without interfering. */ @org.lfdecentralizedtrust.splice.util.scalatesttags.SpliceAmulet_0_1_19 class WalletRewardsTimeBasedIntegrationTest @@ -54,12 +62,14 @@ class WalletRewardsTimeBasedIntegrationTest } val aliceValidatorPartyId = validatorPartyId("alice_validator_user", "aliceValidator") val bobValidatorPartyId = validatorPartyId("bob_validator_user", "bobValidator") + val splitwellValidatorPartyId = + validatorPartyId("splitwell_validator_user", "splitwellValidator") updateAllValidatorConfigs { case (name, c) => if (name == "aliceValidator") { // Alice shares 40% with bob; the implicit remainder (60%) goes to alice. c.copy( rewardSharingConfigByParty = Map( - aliceValidatorPartyId.toProtoPrimitive -> RewardSharingConfig( + aliceValidatorPartyId.toProtoPrimitive -> RewardSharingConfig.BuiltIn( minTtlAfterSharing = NonNegativeFiniteDuration.ofHours(30), beneficiaries = Seq( AppRewardBeneficiaryConfig(bobValidatorPartyId, BigDecimal(0.4)) @@ -67,6 +77,12 @@ class WalletRewardsTimeBasedIntegrationTest ) ) ) + } else if (name == "splitwellValidator") { + c.copy( + rewardSharingConfigByParty = Map( + splitwellValidatorPartyId.toProtoPrimitive -> RewardSharingConfig.External() + ) + ) } else c }(config) }) @@ -88,6 +104,24 @@ class WalletRewardsTimeBasedIntegrationTest waitForWalletUser(bobValidatorWalletClient) val aliceValidatorParty = aliceValidatorBackend.getValidatorPartyId() val bobValidatorParty = bobValidatorBackend.getValidatorPartyId() + val splitwellValidatorParty = splitwellValidatorBackend.getValidatorPartyId() + + clue( + "alice (built in) has sharing trigger; splitwell (external sharing automation) does not" + ) { + val aliceAutomation = aliceValidatorBackend + .userWalletAutomation(aliceValidatorWalletClient.config.ledgerApiUser) + .futureValue + aliceAutomation.triggers[RewardSharingTrigger] should not be empty + + eventually() { + val splitwellWallet = splitwellValidatorBackend.appState.walletManager + .valueOrFail("WalletManager is expected to be defined") + .lookupEndUserPartyWallet(splitwellValidatorParty) + .valueOrFail("Expected splitwell validator to have a wallet") + splitwellWallet.automation.triggers[RewardSharingTrigger] shouldBe empty + } + } // Tap amulet and do a transfer from alice to bob aliceWalletClient.tap(walletAmuletToUsd(50)) @@ -111,6 +145,7 @@ class WalletRewardsTimeBasedIntegrationTest val bobV2Amount = BigDecimal(1000.0) val aliceV2Amounts = Seq(BigDecimal(10.0), BigDecimal(5.0)) + val splitwellV2Amount = BigDecimal(7.0) val openRounds = eventually() { import math.Ordering.Implicits.* @@ -159,10 +194,11 @@ class WalletRewardsTimeBasedIntegrationTest // Bob (no sharing config) → his coupon stays unminted (trigger paused). // Alice (has sharing config, 2 coupons) → shared then minted, // exercising batching via additionalCoupons in AssignBeneficiaries. - clue("Create unassigned RewardCouponV2 for both validators") { + clue("Create unassigned RewardCouponV2 for all validators") { createRewardCouponsV2( Seq( - (bobValidatorParty, bobV2Amount, None) + (bobValidatorParty, bobV2Amount, None), + (splitwellValidatorParty, splitwellV2Amount, None), ) ++ aliceV2Amounts.map((aliceValidatorParty, _, None)) ) } @@ -225,6 +261,27 @@ class WalletRewardsTimeBasedIntegrationTest } } + clue("splitwell's external-sharing-mode coupon is neither shared nor collected") { + val splitwellWallet = splitwellValidatorBackend.appState.walletManager + .valueOrFail("WalletManager is expected to be defined") + .lookupEndUserPartyWallet(splitwellValidatorParty) + .valueOrFail("Expected splitwell validator to have a wallet") + eventually() { + val coupons = splitwellWallet.store.multiDomainAcsStore + .listContracts(RewardCouponV2.COMPANION) + .futureValue + .filter(_.payload.provider == splitwellValidatorParty.toProtoPrimitive) + + coupons should have size 1 withClue + "the single unassigned coupon must still be present" + + coupons.filter(_.payload.beneficiary.isPresent) shouldBe + empty withClue "external sharing mode must not assign beneficiaries" + + BigDecimal(coupons.head.payload.amount) shouldBe splitwellV2Amount + } + } + balance } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletTimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletTimeBasedIntegrationTest.scala index ec75840912..d9bee318f0 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletTimeBasedIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletTimeBasedIntegrationTest.scala @@ -1,5 +1,7 @@ package org.lfdecentralizedtrust.splice.integration.tests +import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.transferpreapproval.TransferPreapprovalProposal +import org.lfdecentralizedtrust.splice.config.ConfigTransforms import org.lfdecentralizedtrust.splice.config.ConfigTransforms.{ ConfigurableApp, updateAutomationConfig, @@ -10,6 +12,7 @@ import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.Integration import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.{ AnsSubscriptionRenewalPaymentTrigger, ExpiredLockedAmuletTrigger, + ExpireTransferPreapprovalsTrigger, } import org.lfdecentralizedtrust.splice.sv.automation.singlesv.ReceiveSvRewardCouponTrigger import org.lfdecentralizedtrust.splice.util.{ @@ -18,8 +21,13 @@ import org.lfdecentralizedtrust.splice.util.{ TriggerTestUtil, WalletTestUtil, } -import org.lfdecentralizedtrust.splice.validator.automation.ReceiveFaucetCouponTrigger +import org.lfdecentralizedtrust.splice.validator.automation.{ + ReceiveFaucetCouponTrigger, + RenewTransferPreapprovalTrigger, +} import org.lfdecentralizedtrust.splice.wallet.admin.api.client.commands.HttpWalletAppClient +import com.digitalasset.canton.config.NonNegativeFiniteDuration +import monocle.macros.syntax.lens.* import java.time.Duration @@ -30,6 +38,9 @@ class WalletTimeBasedIntegrationTest with SplitwellTestUtil with TriggerTestUtil { + // reduce for expiry test + private val preapprovalLifetime = NonNegativeFiniteDuration.ofMinutes(1) + override def environmentDefinition: SpliceEnvironmentDefinition = EnvironmentDefinition .simpleTopology1SvWithSimTime(this.getClass.getSimpleName) @@ -50,6 +61,11 @@ class WalletTimeBasedIntegrationTest _.withPausedTrigger[ReceiveSvRewardCouponTrigger] )(config) ) + .addConfigTransforms((_, config) => + ConfigTransforms.updateAllValidatorConfigs_( + _.focus(_.transferPreapproval.preapprovalLifetime).replace(preapprovalLifetime) + )(config) + ) "A wallet" should { @@ -220,6 +236,58 @@ class WalletTimeBasedIntegrationTest } } } + + "create a new TransferPreapproval if the existing one has expired" in { implicit env => + val aliceUserParty = onboardWalletUser(aliceWalletClient, aliceValidatorBackend) + val aliceValidatorParty = aliceValidatorBackend.getValidatorPartyId() + + def alicePreapprovals = + aliceValidatorBackend + .listTransferPreapprovals() + .filter(_.payload.receiver == aliceUserParty.toProtoPrimitive) + + // disable renew and expiry automation so the expired one does not get archived. + setTriggersWithin( + triggersToPauseAtStart = Seq( + aliceValidatorBackend.validatorAutomation.trigger[RenewTransferPreapprovalTrigger] + ) ++ activeSvs.map(_.dsoDelegateBasedAutomation.trigger[ExpireTransferPreapprovalsTrigger]) + ) { + val initial = clue("Alice creates a TransferPreapproval") { + createTransferPreapprovalEnsuringItExists(aliceWalletClient, aliceValidatorBackend) + alicePreapprovals.loneElement + } + + clue("The TransferPreapproval expires without being renewed or archived") { + advanceTime(preapprovalLifetime.asJava.plusSeconds(1)) + val expired = alicePreapprovals.loneElement + expired.contract.contractId shouldBe initial.contract.contractId + expired.payload.expiresAt should be < getLedgerTime.toInstant + } + + actAndCheck( + "Alice creates another TransferPreapprovalProposal", + aliceValidatorBackend.participantClientWithAdminToken.ledger_api_extensions.commands + .submitWithResult( + userId = aliceValidatorBackend.config.ledgerApiUser, + actAs = Seq(aliceUserParty), + readAs = Seq(aliceUserParty), + update = TransferPreapprovalProposal.create( + aliceUserParty.toProtoPrimitive, + aliceValidatorParty.toProtoPrimitive, + java.util.Optional.of(dsoParty.toProtoPrimitive), + ), + ), + )( + "Validator automation creates a new TransferPreapproval", + _ => { + val fresh = alicePreapprovals + .filterNot(_.contract.contractId == initial.contract.contractId) + .loneElement + fresh.payload.expiresAt should be > getLedgerTime.toInstant + }, + ) + } + } } } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletTxLogIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletTxLogIntegrationTest.scala index f88e98f41e..f7b02ba426 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletTxLogIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletTxLogIntegrationTest.scala @@ -1388,10 +1388,10 @@ class WalletTxLogIntegrationTest logs => inside(logs) { case logLines if logLines.nonEmpty => - logLines - .filter(_.errorMessage contains ("RuntimeException")) - .foreach(_.errorMessage should include("Unexpected amulet create event")) - logLines should have size (env.scans.local.size.toLong + 1) // + 1 for UserWalletTxLog + forExactly(1, logLines) { line => + line.errorMessage should include("Unexpected amulet create event") + line.loggerName should include("DbMultiDomainAcsStore") + } }, ) @@ -1407,10 +1407,10 @@ class WalletTxLogIntegrationTest logs => inside(logs) { case logLines if logLines.nonEmpty => - logLines - .filter(_.errorMessage contains ("RuntimeException")) - .foreach(_.errorMessage should include("Unexpected amulet archive event")) - logLines should have size (env.scans.local.size.toLong + 1) // + 1 for UserWalletTxLog + forExactly(1, logLines) { line => + line.errorMessage should include("Unexpected amulet archive event") + line.loggerName should include("DbMultiDomainAcsStore") + } }, ) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/runbook/RunbookSvPreflightIntegrationTestBase.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/runbook/RunbookSvPreflightIntegrationTestBase.scala index 9425e906d0..3ff56d669b 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/runbook/RunbookSvPreflightIntegrationTestBase.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/runbook/RunbookSvPreflightIntegrationTestBase.scala @@ -52,40 +52,6 @@ abstract class RunbookSvPreflightIntegrationTestBase } } - "CometBFT is working" in { _ => - val svUiUrl = s"https://sv.sv.${sys.env("NETWORK_APPS_ADDRESS")}/"; - - withFrontEnd("sv") { implicit webDriver => - actAndCheck( - s"Logging in to SV UI at: ${svUiUrl}", { - completeAuth0LoginWithAuthorization( - svUiUrl, - svUsername, - svPassword, - () => find(id("logout-button")) should not be empty withClue "'Logout' button", - ) - - eventuallyClickOn(id("information-tab-cometBft-debug")) - }, - )( - s"We see all other SVs as peers", - _ => { - inside(find(id("comet-bft-debug-network"))) { case Some(e) => - if (isDevNet) { - forAll(Range(1, 5)) { _ => - e.text should include(s"\"moniker\": \"${getSvName(1)}\"") - } - } else { - forAll(Range(1, 2)) { _ => - e.text should include(s"\"moniker\": \"Digital-Asset-2\"") - } - } - } - }, - ) - } - } - "The SV can log in to their wallet" in { implicit env => withFrontEnd("sv") { implicit webDriver => actAndCheck( diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/runbook/SvNonDevNetPreflightintegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/runbook/SvNonDevNetPreflightintegrationTest.scala index 8938f7637a..3dfd7ac233 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/runbook/SvNonDevNetPreflightintegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/runbook/SvNonDevNetPreflightintegrationTest.scala @@ -109,10 +109,6 @@ abstract class SvNonDevNetPreflightIntegrationTestBase } } - "Check health status of sv cometBft node" in { implicit env => - svClient.cometBftNodeStatus().catchingUp shouldBe false - } - "Check that there is a recent participant identities backup on GCP" in { _ => testRecentParticipantIdentitiesDump(svNamespace, IdentityDump) } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/performance/tests/BaseStorePerformanceTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/performance/tests/BaseStorePerformanceTest.scala index 95a5fec347..1814c36e31 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/performance/tests/BaseStorePerformanceTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/performance/tests/BaseStorePerformanceTest.scala @@ -117,14 +117,11 @@ abstract class BaseStorePerformanceTest( case Left(err) => throw new RuntimeException(s"Failed to create storage: $err") } - /** Suppress Flyway ClassPathScanner warnings about unloadable test jars (apps-app_2.13-0.1.0-SNAPSHOT-tests.jar) - * TODO(#4790): This is a temporary workaround, w/o adding ignored logs. - */ - org.slf4j.LoggerFactory - .getLogger("org.flywaydb.core.internal.scanner.classpath.ClassPathScanner") - .asInstanceOf[ch.qos.logback.classic.Logger] - .setLevel(ch.qos.logback.classic.Level.ERROR) - + // Running unforked puts sbt's test jar on the classpath Flyway scans. + // So, unlike in forked approach, Flyway finds the jar but can't read it. + // Flyway skips it with a WARN. "Skipping unloadable jar file: ...apps-app_*-tests.jar" WARN. + // That log line is handled via an ignore pattern in project/ignore-patterns/canton_network_test_log.ignore.txt + // Migrations aren't affected — the actual migration files are read from the main resources. new DbMigrations(storage.dbConfig, false, timeouts, loggerFactory) .migrateDatabase() .map(_ => storage) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/unit/http/HttpClientProxyTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/unit/http/HttpClientProxyTest.scala index dbdae41051..a1a485d736 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/unit/http/HttpClientProxyTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/unit/http/HttpClientProxyTest.scala @@ -223,7 +223,7 @@ class HttpClientProxyTest .set("http.proxyPassword", "fail") withProperties(props) { executeRequest(serverBinding).failed.futureValue.getMessage should include( - "401 Unauthorized" + "407 Proxy Authentication Required" ) } } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/util/FrontendLoginUtil.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/util/FrontendLoginUtil.scala index 2ba07f75c9..e0fef3e7ea 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/util/FrontendLoginUtil.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/util/FrontendLoginUtil.scala @@ -124,7 +124,12 @@ trait FrontendLoginUtil extends WithAuth0Support { self: FrontendTestCommon => ) } val userPartyId = if (onboardThroughWalletUI) { - actAndCheck("onboard user", eventuallyClickOn(id("onboard-button")))( + // Onboarding creates the WalletAppInstall contract + // under CI load sequencing can take longer than the default 20s + actAndCheck(timeUntilSuccess = 40.seconds)( + "onboard user", + eventuallyClickOn(id("onboard-button")), + )( "user is onboarded", _ => { val userId = seleniumText(find(id("logged-in-user"))) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/util/UpdateHistoryTestUtil.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/util/UpdateHistoryTestUtil.scala index db3215507a..501f34cb73 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/util/UpdateHistoryTestUtil.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/util/UpdateHistoryTestUtil.scala @@ -33,7 +33,12 @@ import org.lfdecentralizedtrust.splice.store.UpdateHistoryTestBase.{ LostInScanApi, LostInStoreIngestion, } -import org.lfdecentralizedtrust.splice.store.{PageLimit, UpdateHistory, UpdateHistoryTestBase} +import org.lfdecentralizedtrust.splice.store.{ + PageLimit, + TimestampWithMigrationId, + UpdateHistory, + UpdateHistoryTestBase, +} import org.lfdecentralizedtrust.splice.store.UpdateHistory.UpdateHistoryResponse import com.daml.ledger.api.v2.transaction_filter import com.digitalasset.canton.admin.api.client.commands.LedgerApiCommands.UpdateService.{ @@ -45,7 +50,6 @@ import com.digitalasset.canton.config.RequireTypes.PositiveInt import com.digitalasset.canton.console.LocalInstanceReference import com.digitalasset.canton.metrics.MetricValue import com.digitalasset.canton.topology.{PartyId, SynchronizerId} -import org.lfdecentralizedtrust.splice.http.v0.definitions.TransactionHistoryResponseItem import org.scalatest.Assertion import scala.jdk.CollectionConverters.* @@ -116,11 +120,11 @@ trait UpdateHistoryTestUtil extends TestCommon { val recordedUpdates = updateHistory .getAllUpdates( Some( - ( - 0L, + TimestampWithMigrationId( // The after0 argument to getUpdates() is exclusive, so we need to subtract a small value // to include the first element actualUpdates.head.update.recordTime.addMicros(-1L), + 0L, ) ), PageLimit.tryCreate(actualUpdates.size), @@ -141,11 +145,11 @@ trait UpdateHistoryTestUtil extends TestCommon { val recordedUpdates = updateHistory .getAllUpdates( Some( - ( - 0L, + TimestampWithMigrationId( // The after0 argument to getUpdates() is exclusive, so we need to subtract a small value // to include the first element actualUpdates.head.update.recordTime.addMicros(-1L), + 0L, ) ), PageLimit.tryCreate(actualUpdates.size), @@ -443,22 +447,6 @@ trait UpdateHistoryTestUtil extends TestCommon { def shortDebugDescription(u: Seq[definitions.UpdateHistoryItem]): String = { u.map(shortDebugDescription).mkString("[\n", ",\n", "\n]") } - def shortDebugDescription(u: TransactionHistoryResponseItem): String = { - // Minimal, human-readable description. - // Only contains data that is consistent across SVs (in particular, no offset). - u.transactionType match { - case TransactionHistoryResponseItem.TransactionType.members.Transfer => - s"Transfer(${u.date}, ${u.transfer.value.sender}, ${u.transfer.value.receivers - .map(r => s"${r.party} -> ${r.amount}") - .mkString(", ")})" - case TransactionHistoryResponseItem.TransactionType.members.Mint => - s"Mint(${u.date}, ${u.mint.value.amuletOwner}, ${u.mint.value.amuletAmount})" - case TransactionHistoryResponseItem.TransactionType.members.DevnetTap => - s"DevnetTap(${u.date}, ${u.tap.value.amuletOwner}, ${u.tap.value.amuletAmount})" - case TransactionHistoryResponseItem.TransactionType.members.AbortTransferInstruction => - s"AbortTransferInstruction(${u.date}, ${u.abortTransferInstruction.value.transferInstructionCid})" - } - } def dropTrailingNones(u: UpdateHistoryResponse): UpdateHistoryResponse = u.copy(update = dropTrailingNones(u.update)) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/util/WalletTestUtil.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/util/WalletTestUtil.scala index 9bed4ecebe..1422e68567 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/util/WalletTestUtil.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/util/WalletTestUtil.scala @@ -66,11 +66,12 @@ trait WalletTestUtil extends TestCommon with AnsTestUtil { wallet: WalletAppClientReference, expectedAmountRanges: Seq[(BigDecimal, BigDecimal)], holdingFee: BigDecimal = defaultHoldingFeeAmulet.bigDecimal, + timeUntilSuccess: FiniteDuration = 10.seconds, ): Unit = clue(s"checking wallet with $expectedAmountRanges") { val expectedRatePerRound = new feesCodegen.RatePerRound( holdingFee.bigDecimal setScale 10 ) - eventually(10.seconds, 500.millis) { + eventually(timeUntilSuccess, 500.millis) { val amulets = wallet.list().amulets.sortBy(amulet => amulet.contract.payload.amount.initialAmount) amulets should have size (expectedAmountRanges.size.toLong) diff --git a/apps/common/frontend/src/__tests__/dso.test.tsx b/apps/common/frontend/src/__tests__/dso.test.tsx new file mode 100644 index 0000000000..80d0fd30d2 --- /dev/null +++ b/apps/common/frontend/src/__tests__/dso.test.tsx @@ -0,0 +1,75 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +import { DsoInfo, theme } from '@canton-network/splice-common-frontend'; +import { Contract } from '@canton-network/splice-common-frontend-utils'; +import { dsoInfo } from '@canton-network/splice-common-test-handlers'; +import { QueryClient, QueryClientProvider, onlineManager, useQuery } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { CometBftNodeDumpOrErrorResponse } from '@canton-network/sv-openapi'; +import { afterEach, describe, expect, test } from 'vitest'; + +import { ThemeProvider } from '@mui/material'; + +import { AmuletRules } from '@daml.js/splice-amulet/lib/Splice/AmuletRules'; +import { DsoRules } from '@daml.js/splice-dso-governance/lib/Splice/DsoRules'; + +import DsoViewPrettyJSON from '../components/Dso'; + +const makeDsoInfo = (): DsoInfo => ({ + svUser: dsoInfo.sv_user, + svPartyId: dsoInfo.sv_party_id, + dsoPartyId: dsoInfo.dso_party_id, + votingThreshold: BigInt(dsoInfo.voting_threshold), + amuletRules: Contract.decodeOpenAPI(dsoInfo.amulet_rules.contract, AmuletRules), + dsoRules: Contract.decodeOpenAPI(dsoInfo.dso_rules.contract, DsoRules), + nodeStates: [], +}); + +const TestDso: React.FC = () => { + const dsoInfoQuery = useQuery({ + queryKey: ['dsoInfo'], + queryFn: async () => makeDsoInfo(), + initialData: makeDsoInfo(), + }); + const cometBftNodeDebugQuery = useQuery({ + queryKey: ['cometBftDebug'], + queryFn: async () => { + throw new Error('unreachable: query is paused while offline'); + }, + }); + return ( + + ); +}; + +describe('DsoViewPrettyJSON', () => { + afterEach(() => { + onlineManager.setOnline(true); + }); + + // With CantonBFT the cometbft debug endpoint 404s forever, so the query never + // reaches success; when it pauses (browser offline / tab backgrounded during + // retry backoff), status is 'pending' but isLoading is false and data is + // undefined. Rendering must not crash in that state. + test('does not crash when the cometBFT debug query is paused without data', () => { + window.splice_config = { + spliceInstanceNames: { amuletName: 'Amulet' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + onlineManager.setOnline(false); + + render( + + + + + + ); + + expect(screen.getByText('Super Validator Information')).toBeDefined(); + }); +}); diff --git a/apps/common/frontend/src/components/Dso.tsx b/apps/common/frontend/src/components/Dso.tsx index 5b36a6d645..772584900e 100644 --- a/apps/common/frontend/src/components/Dso.tsx +++ b/apps/common/frontend/src/components/Dso.tsx @@ -118,7 +118,7 @@ const TabPanel = (props: TabPanelProps) => { function getCometBftDebugData( cometBftNodeDebugQuery: UseQueryResult ) { - if (cometBftNodeDebugQuery.isLoading) { + if (cometBftNodeDebugQuery.isPending) { return ; } diff --git a/apps/common/frontend/src/theme/index.ts b/apps/common/frontend/src/theme/index.ts index f3dbc9f1db..686eb1c6d3 100644 --- a/apps/common/frontend/src/theme/index.ts +++ b/apps/common/frontend/src/theme/index.ts @@ -27,6 +27,7 @@ declare module '@mui/material/styles' { } interface Palette { + neutral: Palette['primary']; colors: { neutral: Record; primary: Record; @@ -36,10 +37,12 @@ declare module '@mui/material/styles' { testnet: string; devnet: string; scratchnet: string; + localnet: string; }; } // allow configuration using `createTheme` interface PaletteOptions { + neutral?: PaletteOptions['primary']; colors?: { neutral?: Record; primary?: Record; @@ -49,10 +52,17 @@ declare module '@mui/material/styles' { testnet: string; devnet: string; scratchnet: string; + localnet: string; }; } } +declare module '@mui/material/Badge' { + interface BadgePropsColorOverrides { + neutral: true; + } +} + declare module '@mui/material/Button' { interface ButtonPropsVariantOverrides { pill: true; @@ -89,6 +99,7 @@ let theme = createTheme({ testnet: '#C8F1FE', devnet: '#C6B2FF', scratchnet: '#FFFFFF', + localnet: '#BDC9DB', }, }, }); @@ -111,6 +122,13 @@ theme = createTheme(theme, { tertiary: { main: '#875CFF', }, + neutral: theme.palette.augmentColor({ + color: { + main: theme.palette.colors.neutral[25], + contrastText: '#E2E2E2', + }, + name: 'neutral', + }), warning: { main: '#FD8575', }, @@ -142,7 +160,7 @@ theme = createTheme(theme, { }, }); -// Based on the Major Third type scale: https://typescale.com/?size=16&scale=1.250&text=A%20Visual%20Type%20Scale&font=Lato&fontweight=400&bodyfont=body_font_default&bodyfontweight=400&lineheight=1.75&backgroundcolor=%23ffffff&fontcolor=%23000000&preview=false +// Based on the Major Third type scale: https://typescale.com/?size=16&scale=1.250&text=A%20Visual%20Type%20Scale&font=Inter&fontweight=400&bodyfont=body_font_default&bodyfontweight=400&lineheight=1.75&backgroundcolor=%23ffffff&fontcolor=%23000000&preview=false const TYPE_SCALE = 1.25; theme = createTheme(theme, { diff --git a/apps/common/src/main/java/com/google/common/util/concurrent/BurstyRateLimiterFactory.java b/apps/common/src/main/java/com/google/common/util/concurrent/BurstyRateLimiterFactory.java new file mode 100644 index 0000000000..593e053bc6 --- /dev/null +++ b/apps/common/src/main/java/com/google/common/util/concurrent/BurstyRateLimiterFactory.java @@ -0,0 +1,51 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.google.common.util.concurrent; + +/** + * Shim that constructs a Guava {@link RateLimiter} backed by {@code SmoothBursty} with a custom + * maximum burst duration. It lives in this package because the relevant {@code + * SmoothRateLimiter.SmoothBursty} constructor and its permit bookkeeping fields are package-private. + * + *

In contrast to {@link RateLimiter#create(double)}, the limiters created here already hold + * {@code permitsPerSecond} permits (capped by the maximum burst budget) at creation time, instead of + * starting with an empty bucket that only fills up over time. That matters for limiters that are + * created lazily (e.g. one per client IP), which would otherwise reject an initial burst that an + * already running limiter would have accepted. + */ +public final class BurstyRateLimiterFactory { + + /** + * Guava's default burst window for {@code RateLimiter.create(double)}. + */ + private static final double DEFAULT_MAX_BURST_SECONDS = 1.0; + + private BurstyRateLimiterFactory() { + } + + /** + * Creates a {@link RateLimiter} allowing {@code permitsPerSecond} permits per second, starting + * with {@code permitsPerSecond} permits already available. + */ + public static RateLimiter create(double permitsPerSecond) { + return create(permitsPerSecond, DEFAULT_MAX_BURST_SECONDS); + } + + /** + * Creates a bursty {@link RateLimiter} that sustains {@code permitsPerSecond} on average while + * allowing bursts of up to {@code permitsPerSecond * maxBurstSeconds} permits after idle periods. + * The limiter starts with one second worth of permits, i.e. {@code permitsPerSecond} permits + * (capped by the maximum burst budget), already available. + */ + public static RateLimiter create(double permitsPerSecond, double maxBurstSeconds) { + SmoothRateLimiter.SmoothBursty rateLimiter = + new SmoothRateLimiter.SmoothBursty( + RateLimiter.SleepingStopwatch.createFromSystemTimer(), maxBurstSeconds); + rateLimiter.setRate(permitsPerSecond); + synchronized (rateLimiter) { + rateLimiter.storedPermits = Math.min(permitsPerSecond, rateLimiter.maxPermits); + } + return rateLimiter; + } +} diff --git a/apps/common/src/main/openapi/common-internal.yaml b/apps/common/src/main/openapi/common-internal.yaml index c39e503c4e..f388a23fd9 100644 --- a/apps/common/src/main/openapi/common-internal.yaml +++ b/apps/common/src/main/openapi/common-internal.yaml @@ -360,6 +360,33 @@ components: Cursor for the next page of results. Pass this as `pageToken` in the request. If absent or `null`, there are no more pages. + CountVoteResultsRequest: + description: | + Filters for counting vote results. Same semantics as the corresponding + fields on `ListVoteResultsRequest`. + type: object + properties: + actionName: + type: string + accepted: + type: boolean + requester: + type: string + effectiveFrom: + type: string + effectiveTo: + type: string + + CountVoteResultsResponse: + type: object + required: + - count + properties: + count: + type: integer + format: int64 + description: Total number of vote results matching the request filters. + PreviousSvRewardWeightRequest: type: object required: diff --git a/apps/common/src/main/resources/db/migration/canton-network/postgres/stable/V073__dso_unavailable_parties.sql b/apps/common/src/main/resources/db/migration/canton-network/postgres/stable/V073__dso_unavailable_parties.sql new file mode 100644 index 0000000000..9d3d90a345 --- /dev/null +++ b/apps/common/src/main/resources/db/migration/canton-network/postgres/stable/V073__dso_unavailable_parties.sql @@ -0,0 +1,26 @@ +-- Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- Table storing parties that the automation temporarily ignore, because of unresponsiveness or vetting errors. +-- Entries are ignored for updated_at + ignore_duration. +create table dso_unavailable_parties +( + -- the ID of the party that is unavailable + party text not null, + -- the time when the party was marked as unavailable, used for capped exponential backoff + updated_at bigint not null, + -- the duration (microseconds) to ignore the entry, used for capped exponential backoff + ignore_duration bigint not null, + -- the store ID when the party is added, used for resets + store_id bigint not null, + -- the metadata fields reserved for diagnostic/extra information + metadata jsonb, + primary key (party) +); + +-- Index for the expiry check per party +create index dso_unavailable_parties_pid_exp + on dso_unavailable_parties (party, (updated_at + ignore_duration)); + +-- Index for the efficient store cleanup +create index dso_unavailable_parties_sid on dso_unavailable_parties (store_id); diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/SpliceMetrics.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/SpliceMetrics.scala index 8ea09718a1..ca3d7c2371 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/SpliceMetrics.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/SpliceMetrics.scala @@ -5,7 +5,7 @@ package org.lfdecentralizedtrust.splice import com.daml.metrics.HealthMetrics import com.daml.metrics.api.MetricHandle.LabeledMetricsFactory -import com.daml.metrics.api.{MetricName, MetricsContext} +import com.daml.metrics.api.{HistogramInventory, MetricName, MetricsContext} import com.digitalasset.canton.environment.BaseMetrics import com.digitalasset.canton.logging.NamedLoggerFactory import com.digitalasset.canton.metrics.ActiveRequestsMetrics.GrpcServerMetricsX @@ -59,4 +59,28 @@ abstract class BaseSpliceMetrics( override def httpClientMetrics: HttpClientMetrics = new HttpClientMetrics( openTelemetryMetricsFactory ) + + override def cryptoMetrics = crypto + + private[this] val crypto = { + import com.digitalasset.canton.metrics.{ + CryptoMetrics, + DecryptionHistograms, + DecryptionMetrics, + KmsMetrics, + SigningHistograms, + SigningMetrics, + } + new CryptoMetrics( + new SigningMetrics( + new SigningHistograms(prefix)(new HistogramInventory()), + openTelemetryMetricsFactory, + ), + new DecryptionMetrics( + new DecryptionHistograms(prefix)(new HistogramInventory()), + openTelemetryMetricsFactory, + ), + Some(new KmsMetrics(prefix, openTelemetryMetricsFactory)), + ) + } } diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/admin/api/HttpRequestLogger.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/admin/api/HttpRequestLogger.scala index b5514fdeb6..364d1fe413 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/admin/api/HttpRequestLogger.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/admin/api/HttpRequestLogger.scala @@ -4,12 +4,13 @@ package org.lfdecentralizedtrust.splice.admin.api import org.apache.pekko.http.scaladsl.model.{ContentTypes, HttpEntity, RemoteAddress} -import org.apache.pekko.http.scaladsl.server.{Directive0, RequestContext} +import org.apache.pekko.http.scaladsl.server.{Directive0, Directive1, RequestContext} import org.apache.pekko.http.scaladsl.server.Directives.* import com.digitalasset.canton.config.ApiLoggingConfig import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.util.ShowUtil.* +import org.lfdecentralizedtrust.splice.http.ClientIpDirectives object HttpRequestLogger { def apply( @@ -17,6 +18,7 @@ object HttpRequestLogger { maxPathLength: Int, maxStringLength: Int, maxMetadataSize: Int, + clientIpHeaders: Seq[String], loggerFactory: NamedLoggerFactory, )(implicit traceContext: TraceContext): Directive0 = { new HttpRequestLogger( @@ -24,6 +26,7 @@ object HttpRequestLogger { maxPathLength, maxStringLength, maxMetadataSize, + clientIpHeaders, loggerFactory, ).directive } @@ -31,12 +34,14 @@ object HttpRequestLogger { // ignores maxMethodLength and maxMessageLines def apply( loggingConfig: ApiLoggingConfig, + clientIpHeaders: Seq[String], loggerFactory: NamedLoggerFactory, )(implicit traceContext: TraceContext): Directive0 = apply( messagePayloads = loggingConfig.messagePayloads, maxPathLength = loggingConfig.maxMethodLength, maxStringLength = loggingConfig.maxStringLength, maxMetadataSize = loggingConfig.maxMetadataSize, + clientIpHeaders = clientIpHeaders, loggerFactory = loggerFactory, ) } @@ -46,6 +51,7 @@ final class HttpRequestLogger( maxPathLength: Int, maxStringLength: Int, maxMetadataSize: Int, + clientIpHeaders: Seq[String], override protected val loggerFactory: NamedLoggerFactory, ) extends NamedLogging { def createLogMessage(ctx: RequestContext, remoteAddress: RemoteAddress)( @@ -56,8 +62,14 @@ final class HttpRequestLogger( s"HTTP ${ctx.request.method.name} ${pathLimited} from (${remoteAddress}): ${message}" } + private def extractConfiguredClientIp: Directive1[RemoteAddress] = + ClientIpDirectives.extractClientIp(clientIpHeaders).flatMap { + case Some(remoteAddress) => provide(remoteAddress) + case None => extractClientIP + } + private def directive(implicit traceContext: TraceContext): Directive0 = { - extractClientIP.flatMap { remoteAddress => + extractConfiguredClientIp.flatMap { remoteAddress => extractRequestContext.flatMap { ctx => val msg = createLogMessage(ctx, remoteAddress) logger.debug(msg("received request.")) diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/admin/http/HttpAdminService.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/admin/http/HttpAdminService.scala index 3494946147..04bdee6c17 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/admin/http/HttpAdminService.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/admin/http/HttpAdminService.scala @@ -36,6 +36,7 @@ object HttpAdminService { adminApi: AdminServerConfig, parameterConfig: CantonNodeParameters, apiLoggingConfig: ApiLoggingConfig, + clientIpHeaders: Seq[String], loggerFactory: NamedLoggerFactory, node: => Option[CantonNode], )(implicit @@ -49,6 +50,7 @@ object HttpAdminService { adminApi.port, parameterConfig, apiLoggingConfig, + clientIpHeaders, loggerFactory, node, ) @@ -59,6 +61,7 @@ object HttpAdminService { port: Port, parameterConfig: CantonNodeParameters, apiLoggingConfig: ApiLoggingConfig, + clientIpHeaders: Seq[String], loggerFactory: NamedLoggerFactory, node: => Option[CantonNode], )(implicit ac: ActorSystem, ec: ExecutionContext, tracer: Tracer, elc: ErrorLoggingContext) @@ -98,7 +101,7 @@ object HttpAdminService { // handleRejections (inside the logger) seals the route: rejections are // converted to HTTP responses so mapResponse sees all outcomes and logs // exactly one "Responding with status code" per request. - HttpRequestLogger(apiLoggingConfig, loggerFactory)(traceContext) { + HttpRequestLogger(apiLoggingConfig, clientIpHeaders, loggerFactory)(traceContext) { handleRejections(RejectionHandler.default) { encodeResponse( handleRejections( diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/automation/BatchedMultiDomainExpiredContractTrigger.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/automation/BatchedMultiDomainExpiredContractTrigger.scala index ca8402976a..e2ec3530cf 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/automation/BatchedMultiDomainExpiredContractTrigger.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/automation/BatchedMultiDomainExpiredContractTrigger.scala @@ -8,14 +8,14 @@ import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} import com.digitalasset.canton.topology.PartyId import com.digitalasset.canton.tracing.TraceContext -import com.digitalasset.canton.util.ShowUtil.* import com.digitalasset.daml.lf.data.Ref.PackageVersion import io.opentelemetry.api.trace.Tracer import org.apache.pekko.stream.Materializer import org.lfdecentralizedtrust.splice.environment.{PackageIdResolver, PackageVettingLookupService} import org.lfdecentralizedtrust.splice.store.MultiDomainAcsStore.ContractState -import org.lfdecentralizedtrust.splice.store.{MultiDomainAcsStore, PageLimit} +import org.lfdecentralizedtrust.splice.store.{IgnoredPartiesStore, MultiDomainAcsStore, PageLimit} import org.lfdecentralizedtrust.splice.util.{AssignedContract, Contract} +import com.digitalasset.canton.discard.Implicits.DiscardOps import scala.concurrent.{ExecutionContext, Future} @@ -34,7 +34,7 @@ abstract class BatchedMultiDomainExpiredContractTrigger[ companion: C, vettingLookupService: PackageVettingLookupService, pkg: PackageIdResolver.Package, - stakeholders: T => Seq[PartyId], + getStakeholders: T => Seq[PartyId], )(implicit ec: ExecutionContext, mat: Materializer, @@ -44,6 +44,14 @@ abstract class BatchedMultiDomainExpiredContractTrigger[ import BatchedMultiDomainExpiredContractTrigger.Batch + protected val ignoredPartiesStore: IgnoredPartiesStore + + protected def ignorePartiesWithoutVettedAmulet( + informees: Set[PartyId], + contractIds: Seq[String], + logAsWarning: Boolean, + )(implicit tc: TraceContext): String + override final protected def listReadyTasks(now: CantonTimestamp, limit: Int)(implicit tc: TraceContext ): Future[Seq[Batch[TCid, T]]] = @@ -58,14 +66,21 @@ abstract class BatchedMultiDomainExpiredContractTrigger[ PackageIdResolver.Package.SpliceAmulet, expiredContracts, batchSize, - )(c => stakeholders(c.payload)) + )(c => getStakeholders(c.payload)) .map { _.toSeq.flatMap { - case (Some(version), contractBatches) => contractBatches.map(Batch(pkg, version, _)) + case (Some(version), contractBatches) => + contractBatches.map { contracts => + val stakeholders = contracts.flatMap(c => getStakeholders(c.payload)).toSet + Batch(pkg, version, contracts, stakeholders) + } case (None, contracts) => - logger.warn( - show"No vetted $pkg version for ${contracts.flatten.map { _.contractId.contractId }}" - ) + val stakeholders = contracts.flatten.flatMap(c => getStakeholders(c.payload)).toSet + ignorePartiesWithoutVettedAmulet( + stakeholders, + contracts.flatten.map(_.contractId.contractId), + logAsWarning = true, + ).discard Seq.empty } } @@ -92,6 +107,7 @@ object BatchedMultiDomainExpiredContractTrigger { expiredContracts: Seq[ AssignedContract[TCid, T] ], + stakeholders: Set[PartyId], ) extends PrettyPrinting { override def pretty: Pretty[this.type] = prettyOfClass( @@ -99,6 +115,7 @@ object BatchedMultiDomainExpiredContractTrigger { param("vettedVersion", _.vettedVersion), param("numExpiredContracts", _.expiredContracts.size), param("expiredContractCids", _.expiredContracts.map(_.contractId.contractId.unquoted)), + param("stakeholders", _.stakeholders), ) } diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/automation/PollingTrigger.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/automation/PollingTrigger.scala index 12274243f7..ea48f78312 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/automation/PollingTrigger.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/automation/PollingTrigger.scala @@ -112,6 +112,8 @@ trait PollingTrigger extends Trigger with FlagCloseableAsync { context.metricsFactory, mc.labels, context.retryProvider, + // Built outside the RetryFor typeclass, so the automation default has to be restated. + duplicateCommandIsFatal = false, ) override def isHealthy: Boolean = pollingLoopRef diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/automation/SqlIndexInitializationTrigger.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/automation/SqlIndexInitializationTrigger.scala index 1f11182896..9e72fdf48c 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/automation/SqlIndexInitializationTrigger.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/automation/SqlIndexInitializationTrigger.scala @@ -13,12 +13,14 @@ import com.digitalasset.canton.tracing.TraceContext import io.opentelemetry.api.trace.Tracer import org.apache.pekko.stream.Materializer import org.lfdecentralizedtrust.splice.automation.SqlIndexInitializationTrigger.IndexAction +import org.lfdecentralizedtrust.splice.store.db.AdvisoryLocks import org.lfdecentralizedtrust.splice.util.PrettyInstances.* import slick.dbio.{DBIOAction, Effect, NoStream} import slick.jdbc.canton.ActionBasedSQLInterpolation.Implicits.actionBasedSQLInterpolationCanton import java.util.concurrent.atomic.AtomicReference import scala.concurrent.{ExecutionContext, ExecutionContextExecutor, Future, Promise} +import scala.util.{Failure, Success} /** A trigger that asynchronously creates or drops SQL indexes at application startup. * @@ -113,12 +115,12 @@ class SqlIndexInitializationTrigger( override protected def completeTask(task: SqlIndexInitializationTrigger.Task)(implicit tc: TraceContext - ): Future[TaskOutcome] = task match { + ): Future[TaskOutcome] = (task match { case Task.ExecuteAction(IndexAction.Drop(indexName)) => logger.info(s"Dropping index $indexName") storage - .update( - sqlu"drop index concurrently if exists #$indexName", + .queryAndUpdate( + AdvisoryLocks.withDdlLock(sqlu"drop index concurrently if exists #$indexName"), "drop_" + indexName, ) .unwrap @@ -130,7 +132,7 @@ class SqlIndexInitializationTrigger( case Task.ExecuteAction(IndexAction.Create(indexName, createAction)) => logger.info(s"Creating index $indexName") storage - .update(createAction, "create_" + indexName) + .queryAndUpdate(AdvisoryLocks.withDdlLock(createAction), "create_" + indexName) .unwrap .map { _ => logger.info(s"Finished creating index $indexName") @@ -144,6 +146,13 @@ class SqlIndexInitializationTrigger( } logger.info(s"Confirmed action completed for index ${action.indexName}") Future.successful(TaskSuccess(s"Confirmed action completed for index ${action.indexName}")) + }).transform { + case Failure(e: AdvisoryLocks.FailedToAcquireLockException) => + // There was a concurrent DDL statement running. + // The action stays in `remainingActions`, so we retry it on the next poll. + logger.info(s"Skipping $task, another DDL statement was running currently", e) + Success(TaskNoop) + case other => other } } diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/config/RateLimitersConfig.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/config/RateLimitersConfig.scala index 971de2ae5f..39626cd40f 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/config/RateLimitersConfig.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/config/RateLimitersConfig.scala @@ -3,11 +3,45 @@ package org.lfdecentralizedtrust.splice.config -import org.lfdecentralizedtrust.splice.util.SpliceRateLimitConfig +import org.lfdecentralizedtrust.splice.util.{PerAttributeRateLimitConfig, SpliceRateLimitConfig} case class RateLimitersConfig( - default: SpliceRateLimitConfig, - rateLimiters: Map[String, SpliceRateLimitConfig], + /** Overall rate limiter applied per operation. Used when there is no operation-specific override + * in `rateLimiters`. The embedded `perClientIp` limiter is disabled by default; enable it to + * additionally limit per client IP. + */ + default: SpliceRateLimitConfig.WithPerClientIp = + SpliceRateLimitConfig.WithPerClientIp(ratePerSecond = 200), + /** Per-operation overrides of the overall `default` rate limiter. */ + rateLimiters: Map[String, SpliceRateLimitConfig.WithPerClientIp] = Map.empty, + global: SpliceRateLimitConfig.WithPerClientIp = RateLimitersConfig.DefaultGlobal, + /** Names of the HTTP headers from which the client IP used for per-client-IP rate limiting is + * extracted, in order of precedence: the first header that is present and whose value (or, for + * comma separated lists such as `X-Forwarded-For`, whose first entry) parses as an IP literal + * is used. Set to an empty list to disable per-client-IP rate limiting. + * + * Note that the default headers are client-controlled and can hence be spoofed unless they are + * overwritten by infrastructure the client cannot bypass. In deployments with a trusted reverse + * proxy, configure the (non-spoofable) header set by that proxy instead, e.g. + * `["x-envoy-external-address"]` behind an Envoy proxy. + */ + clientIpHeaders: Seq[String] = RateLimitersConfig.DefaultClientIpHeaders, ) { - def forRateLimiter(name: String): SpliceRateLimitConfig = rateLimiters.getOrElse(name, default) + def forRateLimiter(name: String): SpliceRateLimitConfig.WithPerClientIp = + rateLimiters.getOrElse(name, default) +} + +object RateLimitersConfig { + + /** The commonly used client IP headers, in order of precedence. Both are set by clients or + * reverse proxies and are hence only trustworthy if a proxy the client cannot bypass overwrites + * them. + */ + val DefaultClientIpHeaders: Seq[String] = Seq("x-forwarded-for", "x-real-ip") + + private val DefaultGlobal: SpliceRateLimitConfig.WithPerClientIp = + SpliceRateLimitConfig.WithPerClientIp( + ratePerSecond = 200, + perClientIp = PerAttributeRateLimitConfig(), + ) } diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/config/SpliceConfig.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/config/SpliceConfig.scala index f0830271d3..3ee52a5994 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/config/SpliceConfig.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/config/SpliceConfig.scala @@ -25,6 +25,7 @@ abstract class SpliceBackendConfig extends LocalNodeConfig { def participantClient: BaseParticipantClientConfig def automation: AutomationConfig + def parameters: SpliceParametersConfig } @@ -91,6 +92,7 @@ final case class EnabledFeaturesConfig( // On 3.5 we should be able to set it to false. reconnectOnSynchronizerConfigurationChange: Boolean = true, enableUnsupportedDarsUnvetting: Boolean = true, + enableValidatorDarsUnvetting: Boolean = true, ignorePartyIdWithIgnoredAmulet: Boolean = true, naiveUnresponsivePartiesAutoIgnore: Boolean = true, ) diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/config/SpliceParametersConfig.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/config/SpliceParametersConfig.scala index 42c842df85..733aed37ec 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/config/SpliceParametersConfig.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/config/SpliceParametersConfig.scala @@ -6,7 +6,6 @@ package org.lfdecentralizedtrust.splice.config import com.digitalasset.canton.config.RequireTypes.NonNegativeInt import com.digitalasset.canton.config.* import org.lfdecentralizedtrust.splice.store.{ChoiceContextContractFetcher, HardLimit, Limit} -import org.lfdecentralizedtrust.splice.util.SpliceRateLimitConfig final case class SpliceParametersConfig( batching: BatchingConfig = BatchingConfig(), @@ -17,8 +16,7 @@ final case class SpliceParametersConfig( // Do not define any defaults on the class containing the `SpliceParametersConfig` as they'll be overwritten. // Do it instead on the app.conf file in `cluster/images/${the_app}/app.conf` customTimeouts: Map[String, NonNegativeFiniteDuration] = Map.empty, - rateLimiting: RateLimitersConfig = - RateLimitersConfig(SpliceRateLimitConfig(enabled = true, ratePerSecond = 200), Map.empty), + rateLimiting: RateLimitersConfig = RateLimitersConfig(), // Configuration for the circuit breaker for ledger API command submissions. circuitBreakers: CircuitBreakersConfig = CircuitBreakersConfig(), enabledFeatures: EnabledFeaturesConfig = EnabledFeaturesConfig(), diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/DarResources.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/DarResources.scala index 9184a816b0..ee0a4e30dd 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/DarResources.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/DarResources.scala @@ -10067,13 +10067,25 @@ object DarResources { DarResources.splitwell, ) + lazy val corePackageResources: Seq[PackageResource] = + TokenStandard.allPackageResources ++ Seq( + DarResources.amulet, + DarResources.amuletNameService, + DarResources.apiRewardAssignmentV1, + DarResources.dsoGovernance, + DarResources.utilBatchedMarkers, + DarResources.validatorLifecycle, + DarResources.wallet, + DarResources.walletPayments, + ) + lazy val pkgIdToDarResource: Map[String, DarResource] = - packageResources.view.flatMap(_.all).map(resource => resource.packageId -> resource).toMap + corePackageResources.view.flatMap(_.all).map(resource => resource.packageId -> resource).toMap // We don't index the map by PackageMetadata because that type contains some additional // fields that don't matter. lazy val pkgMetadataToDarResource: Map[(PackageName, PackageVersion), DarResource] = - packageResources.view + corePackageResources.view .flatMap(_.all) .map(resource => (resource.metadata.name, resource.metadata.version) -> resource) .toMap diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/LsuTopologyAdminConnection.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/LsuTopologyAdminConnection.scala index fbdd7bdb45..21d13a817a 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/LsuTopologyAdminConnection.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/LsuTopologyAdminConnection.scala @@ -6,7 +6,7 @@ package org.lfdecentralizedtrust.splice.environment import cats.data.EitherT import cats.implicits.catsSyntaxOptionId import com.digitalasset.canton.admin.api.client.commands.TopologyAdminCommands -import com.digitalasset.canton.config.RequireTypes.{NonNegativeInt, PositiveInt} +import com.digitalasset.canton.config.RequireTypes.NonNegativeInt import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.topology.{PhysicalSynchronizerId, SequencerId, SynchronizerId} import com.digitalasset.canton.topology.admin.grpc.{BaseQuery, TopologyStoreId} @@ -32,74 +32,58 @@ import scala.concurrent.{ExecutionContext, Future} trait LsuTopologyAdminConnection { this: TopologyAdminConnection => - def lookupSequencerSuccessors(synchronizerId: SynchronizerId, sequencerId: SequencerId)(implicit + def lookupSequencerSuccessors( + synchronizerId: SynchronizerId, + sequencerId: SequencerId, + successor: Option[PhysicalSynchronizerId], + ops: Option[TopologyChangeOp], + )(implicit tc: TraceContext, ec: ExecutionContext, ): Future[Option[TopologyResult[LsuSequencerConnectionSuccessor]]] = runCmd( TopologyAdminCommands.Read.ListLsuSequencerConnectionSuccessor( BaseQuery( - TopologyStoreId.Synchronizer(synchronizerId), + TopologyStoreId.Synchronizer(synchronizerId.logical), proposals = false, timeQuery = TimeQuery.HeadState, - ops = Some(TopologyChangeOp.Replace), + ops = ops, filterSigningKey = "", protocolVersion = None, ), sequencerId.filterString, - filterSuccessorPhysicalSynchronizerId = "", + filterSuccessorPhysicalSynchronizerId = successor.map(_.toProtoPrimitive).getOrElse(""), ) ).map(_.headOption.map(r => TopologyResult(r.context, r.item))) def ensureSequencerSuccessor( - synchronizerId: PhysicalSynchronizerId, + successorSynchronizerId: PhysicalSynchronizerId, sequencerId: SequencerId, connection: GrpcConnection, )(implicit tc: TraceContext, ec: ExecutionContext, ): Future[TopologyResult[LsuSequencerConnectionSuccessor]] = { - retryProvider.ensureThat( - RetryFor.Automation, - s"sequencer_successor_$sequencerId", - s"sequencer successor for $sequencerId is published with connection $connection", - lookupSequencerSuccessors(synchronizerId.logical, sequencerId).map { result => - result.filter(_.mapping.connection == connection).toRight(result) - }, - (previous: Option[TopologyResult[LsuSequencerConnectionSuccessor]]) => { - logger.info(s"Adding sequencer $sequencerId successor with connection $connection") - (previous match { - case Some(successor) => - proposeMapping( - synchronizerId.logical, - successor.mapping.copy(connection = connection), - successor.base.serial + PositiveInt.one, - isProposal = false, - ) - case None => - proposeMapping( - synchronizerId.logical, - LsuSequencerConnectionSuccessor(sequencerId, synchronizerId, connection), - PositiveInt.one, - isProposal = false, - ) - }).map(_ => ()) + ensureTopologyMappingO( + successorSynchronizerId.logical, + s"sequencer successor for $sequencerId with connection $connection", + _ => + EitherT + .liftF( + lookupSequencerSuccessors(successorSynchronizerId.logical, sequencerId, None, None) + ) + .subflatMap { + case Some(successor) + if successor.mapping.connection == connection && successor.mapping.successorPsid == successorSynchronizerId => + Right(successor) + case Some(existing) => Left(existing.some) + case None => Left(None) + }, + { (_: Option[TopologyMapping]) => + Right( + LsuSequencerConnectionSuccessor(sequencerId, successorSynchronizerId, connection) + ) }, - logger, - ) - } - - def removeSequencerSuccessor( - synchronizerId: SynchronizerId, - sequencerId: SequencerId, - )(implicit tc: TraceContext, ec: ExecutionContext): Future[Unit] = { - ensureTopologyMappingRemoved( - s"Remove SequencerSuccessor for $synchronizerId and sequencer $sequencerId", - synchronizerId, - lookupSequencerSuccessors( - synchronizerId, - sequencerId, - ), - proposal = true, + retryFor = RetryFor.Automation, ) } diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/NodeBootstrapBase.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/NodeBootstrapBase.scala index 729411f223..bb6fa45add 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/NodeBootstrapBase.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/NodeBootstrapBase.scala @@ -7,7 +7,7 @@ import cats.data.EitherT import com.daml.nameof.NameOf.functionFullName import org.lfdecentralizedtrust.splice.SpliceMetrics import com.digitalasset.canton.concurrent.ExecutionContextIdlenessExecutorService -import com.digitalasset.canton.config.{LocalNodeConfig, ProcessingTimeout} +import com.digitalasset.canton.config.ProcessingTimeout import com.digitalasset.canton.config.CantonRequireTypes.InstanceName import com.digitalasset.canton.crypto.Crypto import com.digitalasset.canton.environment.{CantonNode, CantonNodeBootstrap, CantonNodeParameters} @@ -26,6 +26,7 @@ import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference} import scala.concurrent.{blocking, Future} import scala.util.{Failure, Success} import org.lfdecentralizedtrust.splice.admin.http.{AdminRoutes, HttpAdminService} +import org.lfdecentralizedtrust.splice.config.SpliceBackendConfig /** Modelled after CantonNodeBootstrap */ @@ -66,7 +67,7 @@ trait NodeBootstrap[+N <: CantonNode] */ abstract class NodeBootstrapBase[ T <: CantonNode, - NodeConfig <: LocalNodeConfig, + NodeConfig <: SpliceBackendConfig, ParameterConfig <: CantonNodeParameters, ]( nodeConfig: NodeConfig, @@ -109,6 +110,7 @@ abstract class NodeBootstrapBase[ nodeConfig.adminApi, parameterConfig, parameterConfig.loggingConfig.api, + nodeConfig.parameters.rateLimiting.clientIpHeaders, loggerFactory, getNode, ) diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/ParticipantAdminSynchronizerConnection.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/ParticipantAdminSynchronizerConnection.scala index 9ad03ec13b..81e3fd45d4 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/ParticipantAdminSynchronizerConnection.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/ParticipantAdminSynchronizerConnection.scala @@ -12,6 +12,7 @@ import com.digitalasset.canton.admin.api.client.data.{ import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.SynchronizerAlias import com.digitalasset.canton.admin.api.client.data +import com.digitalasset.canton.logging.pretty.Pretty import com.digitalasset.canton.participant.synchronizer.SynchronizerConnectionConfig import com.digitalasset.canton.sequencing.SequencerConnectionValidation import com.digitalasset.canton.topology.{PhysicalSynchronizerId, SynchronizerId} @@ -160,6 +161,9 @@ trait ParticipantAdminSynchronizerConnection { ): Future[Unit] = runCmd(ParticipantAdminCommands.SynchronizerConnectivity.DisconnectSynchronizer(alias)) + private implicit val prettyRegisteredSynchronizer: Pretty[RegisteredSynchronizer] = + Pretty.adHocPrettyInstance + def ensureSynchronizerRegisteredWithManualConnect( config: SynchronizerConnectionConfig, retryFor: RetryFor, @@ -169,15 +173,14 @@ trait ParticipantAdminSynchronizerConnection { "manualConnect must be true when trying to register only", ) retryProvider - .ensureThat( + .ensureThatO( retryFor, "synchronizer_registered_no_handshake", s"participant registered ${config.synchronizerAlias}", - isSynchronizerRegistered(config.synchronizerAlias).map(Either.cond(_, (), ())), - (_: Unit) => registerSynchronizer(config), + listSynchronizerConnectionConfig(config.synchronizerAlias).map(_.headOption), + registerSynchronizer(config), logger, ) - .flatMap(_ => getRegisteredSynchronizer(config.synchronizerAlias)) } def ensureSynchronizerRegisteredAndConnected( @@ -191,7 +194,7 @@ trait ParticipantAdminSynchronizerConnection { retryFor, "synchronizer_registered", s"participant registered ${config.synchronizerAlias} with config $config", - lookupRegisteredSynchronizer(config.synchronizerAlias, config.synchronizerId).map { + lookupRegisteredSynchronizer(config.synchronizerAlias, config.psid).map { case Some(_) if !overwriteExistingConnection => Right(()) // We don't set the sequencer id when connecting but Canton returns it so we ignore it in the comparison here. case Some(existingConfig) @@ -210,7 +213,7 @@ trait ParticipantAdminSynchronizerConnection { case Some(_) => modifySynchronizerConnectionConfigAndReconnect( config.synchronizerAlias, - config.synchronizerId, + config.psid, reconnectOnSynchronizerConfigurationChange, _ => Some(config), ) @@ -317,12 +320,12 @@ trait ParticipantAdminSynchronizerConnection { case Some(config) => if ( registeredSynchronizer.psid.toOption - .exists(oldPsid => config.synchronizerId.exists(psid => psid != oldPsid)) + .exists(oldPsid => config.psid.exists(psid => psid != oldPsid)) ) { Future.failed( Status.INVALID_ARGUMENT .withDescription( - s"New config physical synchronizer id ${config.synchronizerId} cannot be different from the old one ${registeredSynchronizer.psid} for synchronizer $synchronizer" + s"New config physical synchronizer id ${config.psid} cannot be different from the old one ${registeredSynchronizer.psid} for synchronizer $synchronizer" ) .asRuntimeException() ) @@ -333,7 +336,7 @@ trait ParticipantAdminSynchronizerConnection { for { _ <- setSynchronizerConnectionConfig( config, - registeredSynchronizer.psid.toOption.orElse(config.synchronizerId), + registeredSynchronizer.psid.toOption.orElse(config.psid), ) } yield true } @@ -361,7 +364,7 @@ trait ParticipantAdminSynchronizerConnection { if (isSynchronizerRegistered) { modifySynchronizerConnectionConfig( config.synchronizerAlias, - config.synchronizerId, + config.psid, f, ) } else { @@ -398,7 +401,7 @@ trait ParticipantAdminSynchronizerConnection { f: SynchronizerConnectionConfig => Option[SynchronizerConnectionConfig], retryFor: RetryFor, )(implicit traceContext: TraceContext): Future[Unit] = { - require(config.synchronizerId.isDefined, "psid must be set") + require(config.psid.isDefined, "psid must be set") for { configModified <- modifyOrRegisterSynchronizerConnectionConfig( config, diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/RetryFor.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/RetryFor.scala index d0d3e8de11..6cc84f82c8 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/RetryFor.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/RetryFor.scala @@ -8,12 +8,17 @@ import scala.concurrent.duration.* /** The intended use of a retry, expressed in terms like number of retries and * backoff. Use a definition in the companion rather than constructing ''ad hoc''. + * + * `duplicateCommandIsFatal` gives up on DUPLICATE_COMMAND instead of retrying. It is set + * for client calls, where a duplicate cannot resolve within the deduplication window, and + * left off for automation, which retries and then finds its task stale. */ final case class RetryFor private ( maxRetries: Int, initialDelay: FiniteDuration, maxDelay: Duration, resetRetriesAfter: Option[FiniteDuration], + duplicateCommandIsFatal: Boolean = false, ) object RetryFor { @@ -67,6 +72,7 @@ object RetryFor { initialDelay = 100.millis, maxDelay = 1.seconds, resetRetriesAfter = None, + duplicateCommandIsFatal = true, ) /** A retry intended for client calls during the init phase, timing out slower compared to the regular client calls to allow for more contention that happens during initialization. */ @@ -75,5 +81,6 @@ object RetryFor { initialDelay = 100.millis, maxDelay = 3.seconds, resetRetriesAfter = None, + duplicateCommandIsFatal = true, ) } diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/RetryProvider.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/RetryProvider.scala index cfbbd329c1..998bf52637 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/RetryProvider.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/RetryProvider.scala @@ -472,6 +472,7 @@ final class RetryProvider( "operation" -> operationId ), this, + retryConfig.duplicateCommandIsFatal, ), ) } @@ -516,6 +517,7 @@ object RetryProvider { metricsFactory: LabeledMetricsFactory, additionalMetricsLabels: Map[String, String], flagCloseable: FlagCloseable, + duplicateCommandIsFatal: Boolean, ) extends ExceptionRetryPolicy { // Additional categories that are not marked as retryable but we // can safely retry since we know there are other apps or @@ -600,9 +602,18 @@ object RetryProvider { case _ => false } + val isDuplicateCommand = errorDetails.exists { + case detail: ErrorDetails.ErrorInfoDetail => + (detail.errorCodeId: String) == "DUPLICATE_COMMAND" + case _ => false + } + errorCategory match { // Pruning errors fall under FAILED_PRECONDITION which we usually retry but there is no chance to recover from it so we instead treat it as a fatal error. case _ if isPruningError => fatalError + // Accepted duplicates are recovered centrally in SpliceLedgerConnection; a rejected + // one can never succeed within the dedup window, so client calls give up here. + case _ if isDuplicateCommand && duplicateCommandIsFatal => fatalError case Some(cat) if cat.retryable.nonEmpty || extraRetryableCategories.contains(cat) => // don't log the stack traces of transient gRPC exceptions to make the logs less noisy. val msg = @@ -803,6 +814,7 @@ object RetryProvider { metricsFactory: LabeledMetricsFactory, additionalMetricsLabels: Map[String, String], flagCloseable: FlagCloseable, + duplicateCommandIsFatal: Boolean, ): ExceptionRetryPolicy } @@ -815,6 +827,7 @@ object RetryProvider { metricsFactory: LabeledMetricsFactory, additionalMetricsLabels: Map[String, String], flagCloseable: FlagCloseable, + duplicateCommandIsFatal: Boolean, ) = a(operationName) } @@ -825,6 +838,7 @@ object RetryProvider { metricsFactory: LabeledMetricsFactory, additionalMetricsLabels: Map[String, String], flagCloseable: FlagCloseable, + duplicateCommandIsFatal: Boolean, ): RetryableError = RetryProvider.RetryableError( operationName, additionalCodes, @@ -835,6 +849,7 @@ object RetryProvider { metricsFactory, additionalMetricsLabels, flagCloseable, + duplicateCommandIsFatal, ) } @@ -846,6 +861,7 @@ object RetryProvider { metricsFactory: LabeledMetricsFactory, additionalMetricsLabels: Map[String, String], flagCloseable: FlagCloseable, + duplicateCommandIsFatal: Boolean, ): RetryableError = RetryProvider.RetryableError( operationName, Seq.empty, @@ -856,6 +872,7 @@ object RetryProvider { metricsFactory, additionalMetricsLabels, flagCloseable, + duplicateCommandIsFatal, ) } } diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/SequencerAdminConnection.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/SequencerAdminConnection.scala index f53094feb8..70d7bd6715 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/SequencerAdminConnection.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/SequencerAdminConnection.scala @@ -33,15 +33,13 @@ import com.digitalasset.canton.synchronizer.sequencer.SequencerPruningStatus import com.digitalasset.canton.synchronizer.sequencer.admin.grpc.InitializeSequencerResponse import com.digitalasset.canton.time.Clock import com.digitalasset.canton.topology.{Member, NodeIdentity, PhysicalSynchronizerId, SequencerId} -import com.digitalasset.canton.topology.admin.grpc.{BaseQuery, TopologyStoreId} import com.digitalasset.canton.topology.admin.v30.{ GenesisStateV2Response, SequencerLsuStateResponse, } import com.digitalasset.canton.topology.MediatorGroup.MediatorGroupIndex import com.digitalasset.canton.topology.store.StoredTopologyTransactions.GenericStoredTopologyTransactions -import com.digitalasset.canton.topology.store.TimeQuery.Snapshot -import com.digitalasset.canton.topology.transaction.{SequencerSynchronizerState, TopologyMapping} +import com.digitalasset.canton.topology.transaction.SequencerSynchronizerState import com.digitalasset.canton.tracing.TraceContext import com.google.protobuf.ByteString import com.typesafe.config.ConfigFactory @@ -68,6 +66,7 @@ import java.nio.file.{Files, Path} import java.util.{Base64, Collections} import scala.concurrent.{ExecutionContextExecutor, Future, blocking} import scala.jdk.CollectionConverters.* +import scala.util.control.NonFatal import org.lfdecentralizedtrust.splice.store.bulk.ZstdGroupedWeight /** Connection to the subset of the Canton sequencer admin API that we rely @@ -186,25 +185,6 @@ class SequencerAdminConnection( ) } - def getTopologyTransactionsSummary(store: TopologyStoreId, now: CantonTimestamp)(implicit - traceContext: TraceContext - ): Future[Map[TopologyMapping.Code, Int]] = { - runCmd( - TopologyAdminCommands.Read.ListAllV2( - query = BaseQuery( - store = store, - proposals = false, - timeQuery = Snapshot(now), - ops = None, - filterSigningKey = "", - protocolVersion = None, - ), - filterNamespace = "", - includeMappings = Seq.empty, - ) - ).map(_.result.groupMapReduce(_.mapping.code)(_ => 1)(_ + _)) - } - def getOnboardingState(sequencerIdOrTimestamp: Either[SequencerId, CantonTimestamp])(implicit traceContext: TraceContext ): Future[ByteString] = { @@ -302,29 +282,36 @@ class SequencerAdminConnection( logger, s"$serviceName connection", ) - // stub acts the client-side proxy to get access to raw grpc commands - val stub = request.createService(channel.channel) - // bridges the gRPC response stream to a Pekko Source and converts the Protobuf ByteString to a Pekko ByteString - val source = ClientAdapter - .serverStreaming( - request - .createRequestInternal() - .getOrElse(throw new IllegalStateException("Unable to create internal request.")), - (req: OnboardingStateV2Request, obs: StreamObserver[OnboardingStateV2Response]) => - stub.onboardingStateV2(req, obs), - ) - .map { response => - val proto: ByteString = response.onboardingStateForSequencer - PekkoByteString(proto.asReadOnlyByteBuffer()) + try { + // stub acts the client-side proxy to get access to raw grpc commands + val stub = request.createService(channel.channel) + // bridges the gRPC response stream to a Pekko Source and converts the Protobuf ByteString to a Pekko ByteString + val source = ClientAdapter + .serverStreaming( + request + .createRequestInternal() + .getOrElse(throw new IllegalStateException("Unable to create internal request.")), + (req: OnboardingStateV2Request, obs: StreamObserver[OnboardingStateV2Response]) => + stub.onboardingStateV2(req, obs), + ) + .map { response => + val proto: ByteString = response.onboardingStateForSequencer + PekkoByteString(proto.asReadOnlyByteBuffer()) + } + .via( + ZstdGroupedWeight(compressionLevel = 3, minSize = chunkSize.toLong) + ) // 3 is the default zstd compression level + val storageObject = source.runWith(sink) + storageObject.onComplete { _ => + channel.close() } - .via( - ZstdGroupedWeight(compressionLevel = 3, minSize = chunkSize.toLong) - ) // 3 is the default zstd compression level - val storageObject = source.runWith(sink) - storageObject.onComplete { _ => - channel.close() + storageObject + } catch { + // a throw before the onComplete callback is registered would leak the channel + case NonFatal(e) => + channel.close() + Future.failed(e) } - storageObject } /** This is used for initializing the sequencer when the domain is first bootstrapped. @@ -544,7 +531,7 @@ class SequencerAdminConnection( getSequencerId override def isNodeInitialized()(implicit traceContext: TraceContext): Future[Boolean] = { - getStatus.map { + getStatusWithoutRetries.map { case NodeStatus.Failure(_) => false case NodeStatus.NotInitialized(_, _, _) => false case NodeStatus.Success(_) => true diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/SequencerBftAdminConnection.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/SequencerBftAdminConnection.scala index a9bb045b33..8b283f3da2 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/SequencerBftAdminConnection.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/SequencerBftAdminConnection.scala @@ -41,7 +41,7 @@ trait SequencerBftAdminConnection { def listConfiguredPeerEndpoints()(implicit tc: TraceContext - ): Future[Seq[P2PEndpoint]] = { + ): Future[Seq[(P2PEndpoint, Option[SequencerId])]] = { runCmd( SequencerBftAdminCommands.ListConfiguredEndpoints ) diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/SpliceLedgerClient.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/SpliceLedgerClient.scala index e5ade0c2c5..1b08274706 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/SpliceLedgerClient.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/SpliceLedgerClient.scala @@ -64,7 +64,7 @@ class SpliceLedgerClient( ) val channel = builder.build - new LedgerClient(channel, applicationId, getToken, loggerFactory) + new LedgerClient(channel, applicationId, getToken, loggerFactory, timeouts) } private val inactiveContractsCallbacks = new AtomicReference[Seq[String => Unit]](Seq()) diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/SpliceLedgerConnection.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/SpliceLedgerConnection.scala index 6e7f5eb089..44040cfe1e 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/SpliceLedgerConnection.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/SpliceLedgerConnection.scala @@ -12,7 +12,8 @@ import com.daml.ledger.javaapi.data.codegen.{ContractId, Created, Exercised, Has import com.daml.ledger.javaapi.data.{Command, CreatedEvent, ExercisedEvent, Transaction, User} import com.digitalasset.base.error.ErrorResource import com.digitalasset.base.error.utils.ErrorDetails -import com.digitalasset.base.error.utils.ErrorDetails.ResourceInfoDetail +import com.digitalasset.base.error.utils.ErrorDetails.{ErrorInfoDetail, ResourceInfoDetail} +import io.grpc.protobuf.{StatusProto as GrpcStatusProto} import com.digitalasset.canton.SynchronizerAlias import com.digitalasset.canton.admin.api.client.data.parties.PartyDetails import com.digitalasset.canton.config.NonNegativeFiniteDuration @@ -35,7 +36,6 @@ import com.google.protobuf.field_mask.FieldMask import io.grpc.{Status, StatusRuntimeException} import org.apache.pekko.NotUsed import org.apache.pekko.actor.ActorSystem -import org.apache.pekko.pattern.CircuitBreakerOpenException import org.apache.pekko.stream.scaladsl.{Flow, Keep, RestartSource, Sink, Source} import org.apache.pekko.stream.{KillSwitch, KillSwitches, RestartSettings} import org.lfdecentralizedtrust.splice.environment.ledger.api.{ @@ -52,6 +52,7 @@ import org.lfdecentralizedtrust.splice.util.{ ContractWithState, DisclosedContracts, SpliceCircuitBreaker, + SpliceCircuitBreakerOpenException, } import shapeless.<:!< @@ -777,6 +778,23 @@ class SpliceLedgerConnection( callCallbacksOnCompletion(result)(x => (None, x)) } + // Returns Some(completionOffset) when the exception is DUPLICATE_COMMAND with accepted=true, + // allowing callers to fetch the already-completed transaction instead of failing. + private def parseDuplicateCommandAccepted(ex: StatusRuntimeException): Option[Long] = { + val statusProto = GrpcStatusProto.fromThrowable(ex) + if (statusProto == null) None + else + ErrorDetails + .from(statusProto) + .collectFirst { + case ErrorInfoDetail(errorCodeId, metadata) + if errorCodeId == "DUPLICATE_COMMAND" && + metadata.get("accepted").contains("true") => + metadata.get("completion_offset").flatMap(co => Try(co.toLong).toOption) + } + .flatten + } + private def verifyEnoughExtraTrafficRemains( synchronizerId: SynchronizerId, commandPriority: CommandPriority, @@ -863,6 +881,7 @@ class SpliceLedgerConnection( priority: CommandPriority, deadline: Option[NonNegativeFiniteDuration] = None, preferredPackageIds: Seq[String] = Seq.empty, + recoverAcceptedDuplicates: Boolean = false, ) { private type DedupNotSpecifiedYet = CmdId =:= Any private type SynchronizerIdRequired = DomId <:< SynchronizerId @@ -873,6 +892,7 @@ class SpliceLedgerConnection( disclosedContracts: DisclosedContracts = this.disclosedContracts, deadline: Option[NonNegativeFiniteDuration] = this.deadline, preferredPackageIds: Seq[String] = this.preferredPackageIds, + recoverAcceptedDuplicates: Boolean = this.recoverAcceptedDuplicates, ): submit[C, CmdId0, DomId0] = new submit( actAs, @@ -884,8 +904,18 @@ class SpliceLedgerConnection( priority, deadline, preferredPackageIds, + recoverAcceptedDuplicates, ) + /** Read an already-accepted duplicate back from the ledger and return its result, rather + * than failing the submission. + * + * For client calls, which cannot do anything useful with the failure. Automation leaves + * this off: a trigger needs the error so that its own retry re-runs the staleness check. + */ + def recoveringAcceptedDuplicates(enabled: Boolean = true): submit[C, CmdId, DomId] = + copy(recoverAcceptedDuplicates = enabled) + def withDedup(commandId: CommandId, deduplicationOffset: Long)(implicit cid: DedupNotSpecifiedYet ): submit[C, (CommandId, Long), DomId] = @@ -990,14 +1020,29 @@ class SpliceLedgerConnection( preferredPackageIds = preferredPackageIds, ) ) - .recover { case ex: CircuitBreakerOpenException => - // Expose a bit more info and turn it into our standard exceptions + .recover { case ex: SpliceCircuitBreakerOpenException => throw Status.ABORTED .withDescription( s"Command submission aborted by circuit breaker due to too many successive failures, next attempt in ${ex.remainingDuration.toSeconds}s" ) + .withCause(ex.getCause) .asRuntimeException } + .recoverWith { + case ex: StatusRuntimeException + if recoverAcceptedDuplicates && + ex.getStatus.getCode == Status.Code.ALREADY_EXISTS => + parseDuplicateCommandAccepted(ex) match { + case Some(completionOffset) => + client.recoverFromDuplicateCommand( + waitFor, + completionOffset, + actAs.map(_.toProtoPrimitive), + ) + case None => + Future.failed(ex) + } + } )(getOffsetAndResult) @annotation.tailrec diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/StatusAdminConnection.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/StatusAdminConnection.scala index 9d5e039ffd..c37fb48304 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/StatusAdminConnection.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/StatusAdminConnection.scala @@ -15,14 +15,18 @@ trait StatusAdminConnection { type Status <: NodeStatus.Status protected def getStatusRequest: GrpcAdminCommand[?, ?, NodeStatus[Status]] - def getStatus(implicit traceContext: TraceContext): Future[NodeStatus[Status]] = + def getStatus(implicit traceContext: TraceContext): Future[NodeStatus[Status]] = { retryProvider.retryForClientCalls( "status", "Get node status", - runCmd( - getStatusRequest - ), + getStatusWithoutRetries, logger, ) + } + def getStatusWithoutRetries(implicit traceContext: TraceContext): Future[NodeStatus[Status]] = { + runCmd( + getStatusRequest + ) + } } diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/ledger/api/LedgerClient.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/ledger/api/LedgerClient.scala index 6fd6f0584b..d51253eaef 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/ledger/api/LedgerClient.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/ledger/api/LedgerClient.scala @@ -27,6 +27,7 @@ import com.daml.ledger.javaapi.data.{ CreateUserResponse, ListUserRightsResponse, OffsetCheckpoint, + Transaction, User, } import com.daml.ledger.javaapi.data.codegen.ContractId @@ -37,7 +38,11 @@ import org.lfdecentralizedtrust.splice.store.MultiDomainAcsStore.IngestionFilter import org.lfdecentralizedtrust.splice.util.DisclosedContracts import com.digitalasset.canton.SynchronizerAlias import com.digitalasset.canton.admin.api.client.data.parties.PartyDetails -import com.digitalasset.canton.config.NonNegativeFiniteDuration +import com.digitalasset.canton.config.{ + NonNegativeDuration, + NonNegativeFiniteDuration, + ProcessingTimeout, +} import com.digitalasset.canton.crypto.Fingerprint import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.ledger.client.GrpcChannel @@ -49,13 +54,14 @@ import com.digitalasset.canton.tracing.{TraceContext, TraceContextGrpc} import com.digitalasset.canton.util.ErrorUtil import com.google.protobuf.{ByteString, Duration} import com.google.protobuf.field_mask.FieldMask -import io.grpc.{Channel, StatusRuntimeException, Status as GrpcStatus} +import io.grpc.{Channel, Deadline, StatusRuntimeException, Status as GrpcStatus} import io.grpc.stub.{AbstractStub, StreamObserver} import org.apache.pekko.NotUsed import org.apache.pekko.stream.scaladsl.Source import java.io.Closeable import java.util.concurrent.TimeUnit +import scala.concurrent.duration.FiniteDuration import scala.concurrent.{ExecutionContext, Future, Promise} import scala.jdk.CollectionConverters.* @@ -99,6 +105,7 @@ private[environment] class LedgerClient( expectedTokenUser: String, getToken: () => Future[Option[AuthToken]], override protected val loggerFactory: NamedLoggerFactory, + timeouts: ProcessingTimeout, )(implicit esf: ExecutionSequencerFactory, ec: ExecutionContext, @@ -118,17 +125,23 @@ private[environment] class LedgerClient( }) } - private def withCredentialsAndTraceContext[T <: AbstractStub[T]]( - stub: T + private def withGrpcContext[T <: AbstractStub[T]]( + stub: T, + timeout: Option[NonNegativeDuration] = Some(timeouts.default), )(implicit tc: TraceContext): Future[T] = { getToken().map { token => - token.fold(stub) { token => + val authedStub = token.fold(stub) { token => checkTokenUser(token) TraceContextGrpc.addTraceContextToCallOptions( stub .withCallCredentials(new AuthCallCredentials(token.accessToken)) ) } + timeout.map(_.duration) match { + case Some(finite: FiniteDuration) => + authedStub.withDeadline(Deadline.after(finite.length, finite.unit)) + case _ => authedStub + } } } private val commandServiceStub: CommandServiceGrpc.CommandServiceStub = @@ -169,7 +182,7 @@ private[environment] class LedgerClient( ): Future[Long] = { val req = lapi.state_service.GetLedgerEndRequest() for { - stub <- withCredentialsAndTraceContext(stateServiceStub) + stub <- withGrpcContext(stateServiceStub) resp <- stub.getLedgerEnd(req) } yield resp.offset } @@ -179,7 +192,7 @@ private[environment] class LedgerClient( ): Future[Long] = { val req = lapi.state_service.GetLatestPrunedOffsetsRequest() for { - stub <- withCredentialsAndTraceContext(stateServiceStub) + stub <- withGrpcContext(stateServiceStub) resp <- stub.getLatestPrunedOffsets(req) } yield resp.participantPrunedUpToInclusive } @@ -189,7 +202,7 @@ private[environment] class LedgerClient( )(implicit tc: TraceContext): Source[lapi.state_service.GetActiveContractsResponse, NotUsed] = toSource( for { - stub <- withCredentialsAndTraceContext(stateServiceStub) + stub <- withGrpcContext(stateServiceStub, timeout = Some(timeouts.unbounded)) } yield ClientAdapter .serverStreaming(request, stub.getActiveContracts) ) @@ -198,7 +211,7 @@ private[environment] class LedgerClient( tc: TraceContext ): Future[Option[CreatedEvent]] = { (for { - stub <- withCredentialsAndTraceContext(contractServiceStub) + stub <- withGrpcContext(contractServiceStub) contract <- stub.getContract( new lapi.contract_service.GetContractRequest( contractId.contractId, @@ -216,13 +229,44 @@ private[environment] class LedgerClient( )(implicit tc: TraceContext): Source[LedgerClient.GetTreeUpdatesResponse, NotUsed] = { toSource( for { - stub <- withCredentialsAndTraceContext(updateServiceStub) + stub <- withGrpcContext( + updateServiceStub, + timeout = Some(timeouts.unbounded), + ) } yield ClientAdapter .serverStreaming(request.toProto, stub.getUpdates) .mapConcat(GetTreeUpdatesResponse.fromProto) ) } + private[environment] def getTransactionByOffset( + offset: Long, + actAs: Seq[String], + )(implicit tc: TraceContext): Future[Transaction] = { + import lapi.update_service.GetUpdateResponse.Update as U + val updateFormat = LedgerClient.ledgerEffectsUpdateFormat(actAs) + for { + stub <- withGrpcContext(updateServiceStub) + response <- stub.getUpdateByOffset( + lapi.update_service + .GetUpdateByOffsetRequest(offset = offset, updateFormat = Some(updateFormat)) + ) + } yield response.update match { + case U.Transaction(tree) => LedgerClient.lapiTreeToJavaTree(tree) + case other => + throw GrpcStatus.INTERNAL + .withDescription(s"Expected transaction at offset $offset but got $other") + .asRuntimeException() + } + } + + private[environment] def recoverFromDuplicateCommand[W]( + waitFor: SubmitAndWaitFor[W], + completionOffset: Long, + actAs: Seq[String], + )(implicit tc: TraceContext): Future[W] = + waitFor.recoverFromDuplicate(completionOffset, getTransactionByOffset(_, actAs)) + def submitAndWait[Z]( synchronizerId: String, userId: String, @@ -291,7 +335,7 @@ private[environment] class LedgerClient( ) .build() for { - stubWithCredsAndTraceContext <- withCredentialsAndTraceContext(commandServiceStub) + stubWithCredsAndTraceContext <- withGrpcContext(commandServiceStub, Some(timeouts.unbounded)) stub = deadline .map(duration => stubWithCredsAndTraceContext @@ -317,7 +361,7 @@ private[environment] class LedgerClient( tc: TraceContext, ): Future[lapi.interactive.interactive_submission_service.PrepareSubmissionResponse] = { for { - stub <- withCredentialsAndTraceContext(interactiveSubmissionServiceStub) + stub <- withGrpcContext(interactiveSubmissionServiceStub) result <- stub.prepareSubmission( lapi.interactive.interactive_submission_service.PrepareSubmissionRequest( commands = commands.map(c => lapi.commands.Command.fromJavaProto(c.toProtoCommand)), @@ -352,7 +396,7 @@ private[environment] class LedgerClient( tc: TraceContext, ): Future[lapi.interactive.interactive_submission_service.ExecuteSubmissionResponse] = for { - stub <- withCredentialsAndTraceContext(interactiveSubmissionServiceStub) + stub <- withGrpcContext(interactiveSubmissionServiceStub) result <- stub.executeSubmission( lapi.interactive.interactive_submission_service.ExecuteSubmissionRequest( preparedTransaction = Some(preparedTransaction), @@ -385,7 +429,7 @@ private[environment] class LedgerClient( def listPackages()(implicit ec: ExecutionContext, tc: TraceContext): Future[Seq[String]] = { val request = ListPackagesRequest() for { - stub <- withCredentialsAndTraceContext(packageServiceStub) + stub <- withGrpcContext(packageServiceStub) res <- stub .listPackages(request) .map(_.packageIds) @@ -398,7 +442,7 @@ private[environment] class LedgerClient( )(implicit ec: ExecutionContext, tc: TraceContext): Future[Unit] = { val request = v1User.DeleteUserRequest(userId, identityProviderId.getOrElse("")) for { - stub <- withCredentialsAndTraceContext(userManagementServiceStub) + stub <- withGrpcContext(userManagementServiceStub) res <- stub.deleteUser(request).map(_ => ()) } yield res } @@ -418,7 +462,7 @@ private[environment] class LedgerClient( identityProviderId.getOrElse(""), ) for { - stub <- withCredentialsAndTraceContext(userManagementServiceStub) + stub <- withGrpcContext(userManagementServiceStub) res <- stub.listUsers(requestBuilder) } yield ( res.users.map(v1User.User.toJavaProto), @@ -443,7 +487,7 @@ private[environment] class LedgerClient( ): Future[UserManagementServiceOuterClass.User] = { val requestBuilder = v1User.GetUserRequest(userId, identityProviderId.getOrElse("")) for { - stub <- withCredentialsAndTraceContext(userManagementServiceStub) + stub <- withGrpcContext(userManagementServiceStub) res <- stub.getUser(requestBuilder).map(u => v1User.User.toJavaProto(u.getUser)) } yield res } @@ -475,7 +519,7 @@ private[environment] class LedgerClient( )(implicit ec: ExecutionContext, tc: TraceContext): Future[Seq[PartyDetails]] = { val request = GetPartiesRequest(parties.map(_.toProtoPrimitive), "") for { - stub <- withCredentialsAndTraceContext(partyManagementServiceStub) + stub <- withGrpcContext(partyManagementServiceStub) res <- stub .getParties(request) .map(r => r.partyDetails.map(details => PartyDetails.fromProtoPartyDetails(details))) @@ -509,7 +553,7 @@ private[environment] class LedgerClient( initialRights.map(javaRightToV1Right), ) for { - stub <- withCredentialsAndTraceContext(userManagementServiceStub) + stub <- withGrpcContext(userManagementServiceStub) res <- stub .createUser(request) .map(r => CreateUserResponse.fromProto(v1User.CreateUserResponse.toJavaProto(r)).getUser) @@ -560,7 +604,7 @@ private[environment] class LedgerClient( Some(mask), ) for { - stub <- withCredentialsAndTraceContext(userManagementServiceStub) + stub <- withGrpcContext(userManagementServiceStub) res <- stub.updateUser(request) } yield res }.map(_ => ()) @@ -571,7 +615,7 @@ private[environment] class LedgerClient( ): Future[Seq[User.Right]] = { val request = v1User.ListUserRightsRequest(userId, identityProviderId.getOrElse("")) for { - stub <- withCredentialsAndTraceContext(userManagementServiceStub) + stub <- withGrpcContext(userManagementServiceStub) res <- stub .listUserRights(request) .map(r => @@ -598,7 +642,7 @@ private[environment] class LedgerClient( ) for { - stub <- withCredentialsAndTraceContext(userManagementServiceStub) + stub <- withGrpcContext(userManagementServiceStub) res <- stub.grantUserRights(request).map(_ => ()) } yield res } @@ -617,7 +661,7 @@ private[environment] class LedgerClient( "", ) for { - stub <- withCredentialsAndTraceContext(userManagementServiceStub) + stub <- withGrpcContext(userManagementServiceStub) res <- stub.revokeUserRights(request).map(_ => ()) } yield res } @@ -631,7 +675,7 @@ private[environment] class LedgerClient( command: LedgerClient.ReassignmentCommand, )(implicit traceContext: TraceContext): Future[Unit] = for { - stub <- withCredentialsAndTraceContext(commandSubmissionServiceStub) + stub <- withGrpcContext(commandSubmissionServiceStub) res <- stub .submitReassignment( LedgerClient @@ -654,7 +698,10 @@ private[environment] class LedgerClient( )(implicit tc: TraceContext): Source[CompletionStreamResponse, NotUsed] = toSource( for { - stub <- withCredentialsAndTraceContext(multidomainCompletionServiceStub) + stub <- withGrpcContext( + multidomainCompletionServiceStub, + timeout = Some(timeouts.unbounded), + ) } yield ClientAdapter.serverStreaming( lapi.command_completion_service.CompletionStreamRequest( userId = userId, @@ -674,7 +721,7 @@ private[environment] class LedgerClient( "", ) for { - stub <- withCredentialsAndTraceContext(stateServiceStub) + stub <- withGrpcContext(stateServiceStub) res <- stub.getConnectedSynchronizers(req).map { resp => resp.connectedSynchronizers.map { cd => SynchronizerAlias.tryCreate(cd.synchronizerAlias) -> SynchronizerId.tryFromString( @@ -690,7 +737,7 @@ private[environment] class LedgerClient( tc: TraceContext ): Future[Seq[identity_provider_config_service.IdentityProviderConfig]] = { for { - stub <- withCredentialsAndTraceContext(identityProviderConfigServiceStub) + stub <- withGrpcContext(identityProviderConfigServiceStub) res <- stub .listIdentityProviderConfigs( identity_provider_config_service.ListIdentityProviderConfigsRequest() @@ -706,7 +753,7 @@ private[environment] class LedgerClient( audience: String, )(implicit tc: TraceContext): Future[Unit] = { for { - stub <- withCredentialsAndTraceContext(identityProviderConfigServiceStub) + stub <- withGrpcContext(identityProviderConfigServiceStub) _ <- stub.createIdentityProviderConfig( identity_provider_config_service.CreateIdentityProviderConfigRequest( Some( @@ -729,7 +776,7 @@ private[environment] class LedgerClient( vettingAsOfTime: CantonTimestamp, )(implicit tc: TraceContext): Future[Seq[PackageReference]] = { for { - stub <- withCredentialsAndTraceContext(interactiveSubmissionServiceStub) + stub <- withGrpcContext(interactiveSubmissionServiceStub) response <- stub.getPreferredPackages( lapi.interactive.interactive_submission_service.GetPreferredPackagesRequest( packageVettingRequirements = packageRequirements.map { case (pkg, parties) => @@ -806,6 +853,10 @@ object LedgerClient { private[LedgerClient] type RawResponse private[LedgerClient] val stubSubmit: StubSubmit[RawResponse] private[LedgerClient] val mapResponse: RawResponse => Z + private[LedgerClient] def recoverFromDuplicate( + completionOffset: Long, + fetchTransaction: Long => Future[Transaction], + ): Future[Z] } private[environment] object SubmitAndWaitFor { @@ -823,7 +874,7 @@ object LedgerClient { ) .map(r => command_service.SubmitAndWaitResponse.toJavaProto(r))(ec) } - ) + )((offset, _) => Future.successful(offset)) val TransactionTree: SubmitAndWaitFor[jdata.Transaction] = impl((response: CSOC.SubmitAndWaitForTransactionResponse) => @@ -836,7 +887,7 @@ object LedgerClient { ) .map(r => command_service.SubmitAndWaitForTransactionResponse.toJavaProto(r))(ec) } - } + }((offset, fetch) => fetch(offset)) private type StubSubmit[R] = ( CommandServiceGrpc.CommandServiceStub, @@ -846,16 +897,52 @@ object LedgerClient { private[this] def impl[R, Z](mapResponse0: R => Z)( stubSubmit0: StubSubmit[R] + )( + recover0: (Long, Long => Future[jdata.Transaction]) => Future[Z] ): SubmitAndWaitFor[Z] = new SubmitAndWaitFor[Z] { type RawResponse = R override val stubSubmit = stubSubmit0 override val mapResponse = mapResponse0 + override def recoverFromDuplicate( + completionOffset: Long, + fetchTransaction: Long => Future[jdata.Transaction], + ): Future[Z] = recover0(completionOffset, fetchTransaction) } } final case class GetTreeUpdatesResponse( updateOrCheckpoint: TreeUpdateOrOffsetCheckpoint ) + private def wildcardFilter(party: String) = + party -> transaction_filter.Filters( + Seq( + transaction_filter.CumulativeFilter( + transaction_filter.CumulativeFilter.IdentifierFilter + .WildcardFilter(transaction_filter.WildcardFilter(false)) + ) + ) + ) + + private[environment] def ledgerEffectsUpdateFormat( + actAs: Seq[String] + ): transaction_filter.UpdateFormat = + transaction_filter.UpdateFormat( + includeTransactions = Some( + transaction_filter.TransactionFormat( + eventFormat = Some( + transaction_filter.EventFormat( + filtersByParty = actAs.map(wildcardFilter).toMap, + filtersForAnyParty = None, + verbose = false, + ) + ), + transactionShape = transaction_filter.TransactionShape.TRANSACTION_SHAPE_LEDGER_EFFECTS, + ) + ), + includeReassignments = None, + includeTopologyEvents = None, + ) + def lapiTreeToJavaTree( tree: lapi.transaction.Transaction ): com.daml.ledger.javaapi.data.Transaction = { diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/http/ClientIpDirectives.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/http/ClientIpDirectives.scala new file mode 100644 index 0000000000..0cea65550d --- /dev/null +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/http/ClientIpDirectives.scala @@ -0,0 +1,50 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.http + +import org.apache.pekko.http.scaladsl.model.headers.`X-Real-Ip` +import org.apache.pekko.http.scaladsl.model.RemoteAddress +import org.apache.pekko.http.scaladsl.server.Directive1 +import org.apache.pekko.http.scaladsl.server.Directives.* + +object ClientIpDirectives { + + /** Extracts the address of the client the request originated from, if it can be determined. + * + * The headers in `clientIpHeaders` are tried in order and the first one that is present and + * yields an IP literal determines the address. Note that headers set by the client itself (such + * as `X-Forwarded-For` and `X-Real-Ip`) can be spoofed unless they are overwritten by a reverse + * proxy the client cannot bypass. + * + * @param clientIpHeaders + * names of the headers carrying the client IP, in order of precedence. Matched + * case-insensitively, as the configured header names are not required to be lowercase. An + * empty list disables the extraction. + */ + def extractClientIp(clientIpHeaders: Seq[String]): Directive1[Option[RemoteAddress]] = + firstDefined(clientIpHeaders.map(_.trim).filter(_.nonEmpty).map(clientIpFromHeader)*) + + private def clientIpFromHeader(headerName: String): Directive1[Option[RemoteAddress]] = + optionalHeaderValueByName(headerName).map(_.flatMap(parseFirstIpLiteral)) + + /** The value of the first directive that extracts a defined value, [[None]] if there is none. */ + private def firstDefined[A]( + directives: Directive1[Option[A]]* + ): Directive1[Option[A]] = + directives.foldRight(provide(Option.empty[A])) { (directive, fallback) => + directive.flatMap { + case defined @ Some(_) => provide(defined) + case None => fallback + } + } + + private def parseFirstIpLiteral(value: String): Option[RemoteAddress] = + value.split(',').headOption.flatMap(parseIpLiteral) + + private def parseIpLiteral(value: String): Option[RemoteAddress] = + `X-Real-Ip` + .parseFromValueString(value.trim) + .toOption + .map(_.address) +} diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/http/HttpClient.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/http/HttpClient.scala index c8eff10a11..a09cfb7fa5 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/http/HttpClient.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/http/HttpClient.scala @@ -17,6 +17,8 @@ import org.apache.pekko.http.scaladsl.model.{ HttpHeader, HttpRequest, HttpResponse, + MediaType, + MediaTypes, StatusCode, StatusCodes, } @@ -50,6 +52,37 @@ trait HttpClient { } object HttpClient { + private object ResponseErrorByStatus { + def unapply(resp: HttpResponse): Option[StatusCode] = + resp.status match { + case code @ (StatusCodes.ServerError(_) | StatusCodes.ClientError(_)) => Some(code) + case _ => None + } + } + + private object ResponseErrorByContentType { + private val validContentTypes: Set[MediaType] = Set( + MediaTypes.`application/json`, + MediaTypes.`application/octet-stream`, + MediaTypes.`text/plain`, + ) + + def unapply(resp: HttpResponse): Boolean = + resp.entity.contentType match { + // Responses with `NoContentType` are always considered valid + case ContentTypes.NoContentType => false + // Otherwise a response is valid if its content type is contained in `validContentTypes` + case contentType => !validContentTypes.contains(contentType.mediaType) + } + } + + private def httpFnErrors( + nonErrorStatusCode: Set[StatusCode] + ): PartialFunction[HttpResponse, Unit] = { + case ResponseErrorByStatus(code) if !nonErrorStatusCode.contains(code) => + case ResponseErrorByContentType() => + } + def createHttpFn( clientName: String, operationName: String, @@ -61,10 +94,7 @@ object HttpClient { ): HttpRequest => Future[HttpResponse] = { httpClientWithErrors( httpClient.executeRequest(clientName, operationName), - { - case code @ (StatusCodes.ServerError(_) | StatusCodes.ClientError(_)) - if !nonErrorStatusCode.contains(code) => - }, + httpFnErrors(nonErrorStatusCode), ) } @@ -91,7 +121,7 @@ object HttpClient { private def httpClientWithErrors( nextClient: HttpRequest => Future[HttpResponse], - errors: PartialFunction[StatusCode, Unit], + errors: PartialFunction[HttpResponse, Unit], )( req: HttpRequest )(implicit ec: ExecutionContext, mat: Materializer) = { @@ -102,7 +132,7 @@ object HttpClient { Future.failed[HttpResponse](error) } ) - .applyOrElse(_resp.status, (_: StatusCode) => Future.successful(_resp)) + .applyOrElse(_resp, Future.successful(_: HttpResponse)) } } diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/http/HttpRateLimiter.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/http/HttpRateLimiter.scala index 2dc1c8ad1f..4cf216ee81 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/http/HttpRateLimiter.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/http/HttpRateLimiter.scala @@ -6,12 +6,16 @@ package org.lfdecentralizedtrust.splice.http import com.daml.metrics.api.MetricHandle.LabeledMetricsFactory import com.daml.metrics.api.MetricsContext import com.digitalasset.canton.logging.TracedLogger -import org.apache.pekko.http.scaladsl.model.{HttpEntity, StatusCodes} -import org.apache.pekko.http.scaladsl.server.Directive0 +import org.apache.pekko.http.scaladsl.model.{HttpEntity, RemoteAddress, StatusCodes} +import org.apache.pekko.http.scaladsl.server.{Directive0, Directive1} import org.lfdecentralizedtrust.splice.config.RateLimitersConfig -import org.lfdecentralizedtrust.splice.util.{SpliceRateLimitMetrics, SpliceRateLimiter} +import org.lfdecentralizedtrust.splice.util.{ + PerAttributeRateLimiter, + SpliceRateLimiter, + SpliceRateLimitMetrics, +} -import java.time.Instant +import java.net.{Inet6Address, InetAddress} class HttpRateLimiter( config: RateLimitersConfig, @@ -19,12 +23,20 @@ class HttpRateLimiter( logger: TracedLogger, ) extends AutoCloseable { - // need to cache it as the pekko reoutes get evaluated for each request - private val rateLimiters = scala.collection.concurrent.TrieMap[String, SpliceRateLimiter]() + // need to cache it as the pekko routes get evaluated for each request + // keyed by (service, operation) as the same operation name can be used by multiple services + private val rateLimiters = + scala.collection.concurrent.TrieMap[ + (String, String), + (SpliceRateLimiter, PerAttributeRateLimiter), + ]() private val metrics = scala.collection.concurrent.TrieMap[String, SpliceRateLimitMetrics]() - def withRateLimit(service: String)(operation: String): Directive0 = { - val rateLimiterMetrics = metrics.getOrElseUpdate( + private val clientIpHeaders: Seq[String] = + config.clientIpHeaders.map(_.trim).filter(_.nonEmpty) + + private def metricsFor(service: String): SpliceRateLimitMetrics = + metrics.getOrElseUpdate( service, SpliceRateLimitMetrics(metricsFactory, logger)( MetricsContext( @@ -32,33 +44,128 @@ class HttpRateLimiter( ) ), ) - val rateLimiter = rateLimiters.getOrElseUpdate( - operation, + + private val globalRateLimiter: (SpliceRateLimiter, PerAttributeRateLimiter) = { + val globalMetrics = metricsFor(HttpRateLimiter.GlobalService) + ( new SpliceRateLimiter( - operation, - config.forRateLimiter(operation), - rateLimiterMetrics, - // the rate limiter has a cold start, to avoid the first request being rejected - // we enforce the rate limit only after 1 second - Instant.now().plusSeconds(1), + HttpRateLimiter.GlobalLimiter, + config.global, + globalMetrics, + ), + new PerAttributeRateLimiter( + HttpRateLimiter.GlobalLimiter, + HttpRateLimiter.ClientIpAttribute, + config.global, + config.global.perClientIp, + globalMetrics, + logger, ), ) + } - import org.apache.pekko.http.scaladsl.server.Directives.* - - extractRequestContext.flatMap { _ => - if (rateLimiter.markRun()) { - pass - } else { - complete( - StatusCodes.TooManyRequests, - HttpEntity( - "Too Many Requests: Server is busy, please try again later." + private def operationRateLimiter( + service: String, + operation: String, + ): (SpliceRateLimiter, PerAttributeRateLimiter) = + rateLimiters.getOrElseUpdate( + (service, operation), { + val rateLimiterMetrics = metricsFor(service) + val operationConfig = config.forRateLimiter(operation) + ( + new SpliceRateLimiter( + operation, + operationConfig, + rateLimiterMetrics, + ), + new PerAttributeRateLimiter( + operation, + HttpRateLimiter.ClientIpAttribute, + operationConfig, + operationConfig.perClientIp, + rateLimiterMetrics, + logger, ), ) + }, + ) + + def withRateLimit(service: String)(operation: String): Directive0 = { + val (globalLimiter, globalClientIpLimiter) = globalRateLimiter + val (operationLimiter, operationClientIpLimiter) = operationRateLimiter(service, operation) + + import org.apache.pekko.http.scaladsl.server.Directives.* + + HttpRateLimiter + .extractClientIpKey(clientIpHeaders) + .flatMap { clientIp => + // The per client IP limiters are checked first (and `&&` short-circuits) so that a request + // rejected because of its own client IP does not consume budget from the shared overall + // limiters. Otherwise a single abusive client could exhaust the overall limits and thereby + // deny service to all other clients. + // Within each of those two groups the narrower per operation limiter is checked before the + // global one, so that a request rejected for its operation does not consume global budget. + val allowed = + operationClientIpLimiter.markRun(clientIp) && + globalClientIpLimiter.markRun(clientIp) && + operationLimiter.markRun() && + globalLimiter.markRun() + if (allowed) { + pass + } else { + complete( + StatusCodes.TooManyRequests, + HttpEntity( + "Too Many Requests: Server is busy, please try again later." + ), + ) + } } - } } def close(): Unit = metrics.view.values.foreach(_.close()) } + +object HttpRateLimiter { + + private val ClientIpAttribute = "client_ip" + + private[splice] val GlobalLimiter = "global" + private[splice] val GlobalService = "global" + + private[splice] def extractClientIpKey( + clientIpHeaders: Seq[String] = RateLimitersConfig.DefaultClientIpHeaders + ): Directive1[Option[String]] = + ClientIpDirectives + .extractClientIp(clientIpHeaders) + .map(_.collect { case RemoteAddress.IP(ip, _) => rateLimitKey(ip) }) + + /** Single clients are typically assigned a whole IPv6 /64 (or larger) network, so limiting per + * full IPv6 address would allow a single client to trivially bypass the per client IP limit. + * IPv6 addresses are therefore grouped by their /64 prefix, IPv4 addresses are used as is. + */ + private def rateLimitKey(address: InetAddress): String = address match { + case ipv6: Inet6Address => + val bytes = ipv6.getAddress + ipv4Mapped(bytes) match { + // connections accepted on a dual stack socket can report IPv4 clients as IPv4-mapped IPv6 + // addresses (e.g. ::ffff:192.0.2.1), those must use the same key as the plain IPv4 address + case Some(ipv4) => ipv4.getHostAddress + case None => + // zero out the lower 64 bits (the interface identifier), keeping the /64 network prefix + // note that this also drops any scope/zone id, which is not meaningful for rate limiting + val prefix = bytes.take(8) ++ Array.fill[Byte](8)(0) + s"${InetAddress.getByAddress(prefix).getHostAddress}/64" + } + case ip => ip.getHostAddress + } + + /** ::ffff:0:0/96, see [[https://www.rfc-editor.org/rfc/rfc4291#section-2.5.5.2]] */ + private val Ipv4MappedPrefix: Seq[Byte] = + Seq.fill[Byte](10)(0) ++ Seq[Byte](0xff.toByte, 0xff.toByte) + + private def ipv4Mapped(bytes: Array[Byte]): Option[InetAddress] = + Option.when(bytes.length == 16 && bytes.startsWith(Ipv4MappedPrefix))( + InetAddress.getByAddress(bytes.drop(12)) + ) +} diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/metrics/ScanConnectionMetrics.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/metrics/ScanConnectionMetrics.scala index a4ac00a1f1..a388fab313 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/metrics/ScanConnectionMetrics.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/metrics/ScanConnectionMetrics.scala @@ -38,7 +38,9 @@ class ScanConnectionMetrics(metricsFactory: LabeledMetricsFactory) { summary = "Count of succeeded and failed requests to a scan connection", qualification = Traffic, labelsWithDescription = perConnectionLabels ++ Map( - "outcome" -> "Category of failure or success" + "outcome" -> "Category of failure or success", + "http_status" -> ("For failures, the HTTP status code of the response when available, " + + "'none' otherwise (e.g. transport-level failures). Absent for successful requests."), ), ) ) diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/DbVotesStoreQueryBuilder.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/DbVotesStoreQueryBuilder.scala index bc602a8b3e..cf5227001a 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/DbVotesStoreQueryBuilder.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/DbVotesStoreQueryBuilder.scala @@ -23,47 +23,25 @@ trait DbVotesTxLogStoreQueryBuilder[TXE] with LimitHelpers with NamedLogging { - def listVoteRequestResultsQuery( - txLogTableName: String, - txLogStoreId: TxLogStoreId, + private def voteRequestResultsConditions( dbType: String3, actionNameColumnName: String, acceptedColumnName: String, requesterNameColumnName: String, - actionName: Option[String], - accepted: Option[Boolean], - requester: Option[String], - effectiveFrom: Option[String], - effectiveTo: Option[String], - limit: Limit, - after: Option[Long] = None, - ): SqlStreamingAction[Vector[ - TxLogQueries.SelectFromTxLogTableResult - ], TxLogQueries.SelectFromTxLogTableResult, Effect.Read] = { - // Sort key: the vote's effective date, falling back to the result's completedAt for non-accepted votes that have none. - val effectiveAtSortKey = - "coalesce(vote_effective_at, entry_data->'result'->>'completedAt')" - val afterCondition = after match { - case Some(a) => - Some( - // Keyset pagination past the previous page's last entry_number `a`. Lexicographical row comparison, - // expands to: effectiveAt < cursorEffectiveAt OR (effectiveAt = cursorEffectiveAt AND entry_number < a). - sql"""(#$effectiveAtSortKey, entry_number) < ((select #$effectiveAtSortKey from #$txLogTableName where store_id = $txLogStoreId and entry_number = $a), $a)""" - ) - case None => None - } - val actionNameCondition = actionName match { + filters: VoteResultsFilters, + ) = { + val actionNameCondition = filters.actionName match { case Some(actionName) => Some(sql"""#$actionNameColumnName like ${lengthLimited( s"%${lengthLimited(actionName)}%" )}""") case None => None } - val executedCondition = accepted match { + val executedCondition = filters.accepted match { case Some(accepted) => Some(sql"""#$acceptedColumnName = ${accepted}""") case None => None } - val effectivenessCondition = (effectiveFrom, effectiveTo) match { + val effectivenessCondition = (filters.effectiveFrom, filters.effectiveTo) match { case (Some(effectiveFrom), Some(effectiveTo)) => Some(sql"""vote_effective_at between ${lengthLimited( effectiveFrom @@ -76,23 +54,56 @@ trait DbVotesTxLogStoreQueryBuilder[TXE] Some(sql"""vote_effective_at < ${lengthLimited(effectiveTo)}""") case (None, None) => None } - val requesterCondition = requester match { + val requesterCondition = filters.requester match { case Some(requester) => Some(sql"""#$requesterNameColumnName like ${lengthLimited( s"%${lengthLimited(requester)}%" )}""") case None => None } - val conditions = NonEmptyList( + NonEmptyList( sql"""entry_type = ${dbType}""", List( actionNameCondition, executedCondition, requesterCondition, effectivenessCondition, - afterCondition, ).flatten, ) + } + + def listVoteRequestResultsQuery( + txLogTableName: String, + txLogStoreId: TxLogStoreId, + dbType: String3, + actionNameColumnName: String, + acceptedColumnName: String, + requesterNameColumnName: String, + filters: VoteResultsFilters, + limit: Limit, + after: Option[Long] = None, + ): SqlStreamingAction[Vector[ + TxLogQueries.SelectFromTxLogTableResult + ], TxLogQueries.SelectFromTxLogTableResult, Effect.Read] = { + // Sort key: the vote's effective date, falling back to the result's completedAt for non-accepted votes that have none. + val effectiveAtSortKey = + "coalesce(vote_effective_at, entry_data->'result'->>'completedAt')" + val afterCondition = after match { + case Some(a) => + Some( + // Keyset pagination past the previous page's last entry_number `a`. Lexicographical row comparison, + // expands to: effectiveAt < cursorEffectiveAt OR (effectiveAt = cursorEffectiveAt AND entry_number < a). + sql"""(#$effectiveAtSortKey, entry_number) < ((select #$effectiveAtSortKey from #$txLogTableName where store_id = $txLogStoreId and entry_number = $a), $a)""" + ) + case None => None + } + val conditions = voteRequestResultsConditions( + dbType, + actionNameColumnName, + acceptedColumnName, + requesterNameColumnName, + filters, + ) ++ afterCondition.toList val whereClause = conditions.reduceLeft((a, b) => (a ++ sql""" and """ ++ b).toActionBuilder) selectFromTxLogTable( @@ -103,6 +114,26 @@ trait DbVotesTxLogStoreQueryBuilder[TXE] sql"""order by #$effectiveAtSortKey desc, entry_number desc limit ${sqlLimit(limit)}""", ) } + + def countVoteRequestResultsQuery( + txLogTableName: String, + txLogStoreId: TxLogStoreId, + dbType: String3, + actionNameColumnName: String, + acceptedColumnName: String, + requesterNameColumnName: String, + filters: VoteResultsFilters, + ): SqlStreamingAction[Vector[Long], Long, Effect.Read] = { + val whereClause = voteRequestResultsConditions( + dbType, + actionNameColumnName, + acceptedColumnName, + requesterNameColumnName, + filters, + ).reduceLeft((a, b) => (a ++ sql""" and """ ++ b).toActionBuilder) + (sql"""select count(*) from #$txLogTableName where store_id = $txLogStoreId and """ ++ whereClause).toActionBuilder + .as[Long] + } } /** All column names will be unsafely interpolated, as they're expected to be constant strings. diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/HistoryMetrics.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/HistoryMetrics.scala index 1e4193c680..7bdb6fe322 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/HistoryMetrics.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/HistoryMetrics.scala @@ -438,11 +438,15 @@ class HistoryMetrics(metricsFactory: LabeledMetricsFactory)(implicit ) )(metricsContext) - def incAcsSnapshotObjects(): Unit = - objectsCount.inc()(MetricsContext("object_type" -> "ACS_snapshots")) + def incAcsSnapshotObjects(encoding: String, bucket: String): Unit = + objectsCount.inc()( + MetricsContext("object_type" -> "ACS_snapshots", "encoding" -> encoding, "bucket" -> bucket) + ) - def incUpdateObjects(): Unit = - objectsCount.inc()(MetricsContext("object_type" -> "updates")) + def incUpdateObjects(encoding: String, bucket: String): Unit = + objectsCount.inc()( + MetricsContext("object_type" -> "updates", "encoding" -> encoding, "bucket" -> bucket) + ) def incUpdatesCount(count: Int): Unit = updatesCount.inc(count.toLong)(MetricsContext.Empty) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/IgnoredPartiesStore.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/IgnoredPartiesStore.scala similarity index 85% rename from apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/IgnoredPartiesStore.scala rename to apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/IgnoredPartiesStore.scala index de73bfed60..c082ce12e9 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/IgnoredPartiesStore.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/IgnoredPartiesStore.scala @@ -1,13 +1,14 @@ // Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -package org.lfdecentralizedtrust.splice.sv.store +package org.lfdecentralizedtrust.splice.store import com.digitalasset.canton.topology.PartyId import java.util.concurrent.ConcurrentHashMap import scala.jdk.CollectionConverters.* +// TODO(#6817): Remove in-memory in favor of DbUnavailablePartiesStore class IgnoredPartiesStore(initialParties: Set[PartyId]) { private val parties: ConcurrentHashMap.KeySetView[PartyId, java.lang.Boolean] = { diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/IngestionSummary.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/IngestionSummary.scala index 8e818d04ac..7ff7b168fe 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/IngestionSummary.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/IngestionSummary.scala @@ -21,7 +21,7 @@ import scala.collection.mutable final case class IngestionSummary( offset: Option[Long], synchronizerIdToRecordTime: Map[SynchronizerId, CantonTimestamp], - newAcsSize: Int, + acsSizeDiff: Int, ingestedCreatedEvents: Vector[CreatedEvent], numFilteredCreatedEvents: Int, ingestedArchivedEvents: Vector[ExercisedEvent], @@ -43,7 +43,7 @@ private[store] object IngestionSummary { private val Empty: IngestionSummary = IngestionSummary( offset = None, synchronizerIdToRecordTime = Map.empty, - newAcsSize = 0, + acsSizeDiff = 0, ingestedCreatedEvents = Vector.empty, updatedContractStates = Vector.empty, numFilteredCreatedEvents = 0, @@ -69,7 +69,7 @@ private[store] object IngestionSummary { prettyNode( "", // intentionally left empty, as that worked better in the log messages above paramIfDefined("offset", _.offset), - param("newAcsSize", _.newAcsSize), + param("acsSizeDiff", _.acsSizeDiff), param("synchronizerIdToRecordTime", _.synchronizerIdToRecordTime), paramIfNonEmpty("ingestedCreatedEvents", _.ingestedCreatedEvents), paramIfNonZero("numFilteredCreatedEvents", _.numFilteredCreatedEvents), @@ -122,12 +122,16 @@ case class MutableIngestionSummary( def toIngestionSummary( synchronizerIdToRecordTime: Map[SynchronizerId, CantonTimestamp], offset: Long, - newAcsSize: Int, + acsSizeDiff: Int, metrics: StoreMetrics, ): IngestionSummary = { // We update the metrics in here as it's the easiest way // to not miss any place that might need updating. - metrics.acsSize.updateValue(newAcsSize.toLong) + if (acsSizeDiff > 0) { + metrics.acsSizeIncrease.mark(acsSizeDiff.toLong)(MetricsContext.Empty) + } else if (acsSizeDiff < 0) { + metrics.acsSizeDecrease.mark(-acsSizeDiff.toLong)(MetricsContext.Empty) + } metrics.ingestedTxLogEntries.mark(ingestedTxLogEntries.size.toLong)(MetricsContext.Empty) metrics.eventCount.inc(this.ingestedCreatedEvents.length.toLong)( MetricsContext("event_type" -> "created") @@ -144,7 +148,7 @@ case class MutableIngestionSummary( IngestionSummary( offset = Some(offset), synchronizerIdToRecordTime = synchronizerIdToRecordTime, - newAcsSize = newAcsSize, + acsSizeDiff = acsSizeDiff, ingestedCreatedEvents = this.ingestedCreatedEvents.toVector, numFilteredCreatedEvents = this.numFilteredCreatedEvents, ingestedArchivedEvents = this.ingestedArchivedEvents.toVector, diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/MultiDomainAcsStore.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/MultiDomainAcsStore.scala index 280f64fa57..5961cc6f65 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/MultiDomainAcsStore.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/MultiDomainAcsStore.scala @@ -196,7 +196,9 @@ trait MultiDomainAcsStore extends HasIngestionSink with AutoCloseable with Named ): Future[Seq[Contract[TCid, T]]] private[splice] def listExpiredFromPayloadExpiry[C, TCid <: ContractId[T], T <: Template]( - companion: C + companion: C, + ignoredPartiesStore: Option[IgnoredPartiesStore] = None, + ignoredPartyFields: Seq[String] = Seq.empty, )(implicit companionClass: ContractCompanion[C, TCid, T] ): ListExpiredContracts[TCid, T] diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/S3BucketConnection.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/S3BucketConnection.scala index 44a8c89caa..66b0c89c50 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/S3BucketConnection.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/S3BucketConnection.scala @@ -117,27 +117,39 @@ class S3BucketConnection( def getChecksums( objectKeys: Seq[String] - )(implicit ec: ExecutionContext, as: ActorSystem): Future[Seq[ObjectKeyAndChecksum]] = { + )(implicit + ec: ExecutionContext, + as: ActorSystem, + tc: TraceContext, + ): Future[Seq[ObjectKeyAndChecksum]] = { Source(objectKeys.toList) .mapAsync(4) { key => // TODO(#3429): make this parallelism configurable readChecksum(key) - .map(checksum => ObjectKeyAndChecksum(key, checksum)) + .map(checksum => checksum.map(ObjectKeyAndChecksum(key, _))) } + .collect { case Some(obj) => obj } .runWith(Sink.seq[ObjectKeyAndChecksum]) } - private def readChecksum(key: String)(implicit ec: ExecutionContext): Future[String] = { + private def readChecksum( + key: String + )(implicit ec: ExecutionContext, tc: TraceContext): Future[Option[String]] = { val headRequest = HeadObjectRequest .builder() .bucket(bucketName) .key(key) .build() for { - head <- s3Client.headObject(headRequest).asScala - checksum = head - .metadata() - .asScala - .getOrElse("splice-checksum", throw new RuntimeException("Missing checksum metadata")) + head <- s3Client.headObject(headRequest).asScala.map(Some(_)).recover { case e => + // TODO(#3429): distinguish between "object not found" and other errors, probably want to catch only NoSuchKeyException, and throw everything else + logger + .debug(s"Failed to read checksum for object $key, object may not exist: ${e.getMessage}") + None + } + checksum = head.map( + _.metadata().asScala + .getOrElse("splice-checksum", throw new RuntimeException("Missing checksum metadata")) + ) } yield checksum } @@ -210,6 +222,14 @@ class S3BucketConnection( private val parts = TrieMap.empty[Integer, CompletedPart] private val md = MessageDigest.getInstance("SHA-256") + /** The checksum of the whole object. Computing it via a `lazy val` to support idempotent `finish()` calls. + */ + private lazy val objectChecksum: String = Base64.getEncoder.encodeToString(md.digest()) + + /** `lazy val` to ensure that multi-part upload is completed at most once. + */ + private lazy val finishResult: Future[Unit] = doFinish() + /** Call this once before uploading a new part. * The content must already be provided for checksums, but will not be uploaded yet. */ @@ -261,7 +281,12 @@ class S3BucketConnection( } } - def finish(): Future[Unit] = { + /** Completes the multi-part upload and stores the object checksum in the object's metadata. + * Idempotent, safe to call more than once (will just return the Future from the first call again). + */ + def finish(): Future[Unit] = finishResult + + private def doFinish(): Future[Unit] = { require(numParts.get() > 0) require( parts.size == numParts.get(), @@ -285,7 +310,7 @@ class S3BucketConnection( _ <- s3Client.completeMultipartUpload(completeRequest).asScala // Copy-in-place of the object to add the final checksum to its metadata - metadata = Map("splice-checksum" -> Base64.getEncoder.encodeToString(md.digest())) + metadata = Map("splice-checksum" -> objectChecksum) copyReq = CopyObjectRequest .builder() diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/StoreMetrics.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/StoreMetrics.scala index bba1e1a35f..a1a15a539d 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/StoreMetrics.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/StoreMetrics.scala @@ -59,15 +59,28 @@ class StoreMetrics(metricsFactory: LabeledMetricsFactory)(metricsContext: Metric ) ) - val acsSize: Gauge[Long] = - metricsFactory.gauge( + // we track increase and decrease separately as promql's increase doesn't work for counters that go up and down + // and delta will not handle resets to 0 after a restart properly. + val acsSizeIncrease: Meter = + metricsFactory.meter( MetricInfo( - name = prefix :+ "acs-size", - summary = "The number of active contracts in this store", + name = prefix :+ "acs-size-increase", + summary = "Counter for the number of active contracts added to the store", Traffic, - "The number of active contracts in this store. Note that this is only in the given store. The participant might have contracts we do not ingest.", - ), - 0L, + "Counter for the number of active contracts added to the store. This is _not_ an absolute value of the size, it can only be used to track changes. Note that for an individual transaction this is netted against acsSizeDecrease so ony one of the two will increase. Note that this is only in the given store. The participant might have contracts we do not ingest.", + ) + )(metricsContext) + + // we track increase and decrease separately as promql's increase doesn't work for counters that go up and down + // and delta will not handle resets to 0 after a restart properly. + val acsSizeDecrease: Meter = + metricsFactory.meter( + MetricInfo( + name = prefix :+ "acs-size-decrease", + summary = "Counter for the number of active contracts removed to the store", + Traffic, + "Counter for the number of active contracts removed from the store. This is _not_ an absolute value of the size, it can only be used to track changes. Note that for an individual transaction this is netted against acsSizeIncrease so ony one of the two will increase. Note that this is only in the given store. The participant might have contracts we do not ingest.", + ) )(metricsContext) val ingestedTxLogEntries: Meter = metricsFactory.meter( @@ -158,7 +171,6 @@ class StoreMetrics(metricsFactory: LabeledMetricsFactory)(metricsContext: Metric } override def close(): Unit = { - acsSize.close() perSynchronizerLastIngestedRecordTimeMs.values.foreach(_.close()) perSynchronizerLastSeenRecordTimeMs.values.foreach(_.close()) } diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/UnavailablePartiesStore.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/UnavailablePartiesStore.scala new file mode 100644 index 0000000000..06acc54fd4 --- /dev/null +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/UnavailablePartiesStore.scala @@ -0,0 +1,25 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.store + +import com.digitalasset.canton.topology.PartyId +import com.digitalasset.canton.tracing.TraceContext + +import scala.concurrent.Future + +trait UnavailablePartiesStore { + + // Adds or updates parties + def addParties(parties: Seq[PartyId], nowMicros: Long)(implicit tc: TraceContext): Future[Unit] + + // Removes specific parties from the store + def removeParties(parties: Seq[PartyId])(implicit tc: TraceContext): Future[Int] + + // Removes parties from the table with matching store ID + def removePartiesUpToStoreId(storeId: Long)(implicit tc: TraceContext): Future[Int] + + // Lists parties that are being ignored + def listParties(nowMicros: Long)(implicit tc: TraceContext): Future[Seq[PartyId]] + +} diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/UpdateHistory.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/UpdateHistory.scala index 11e1e3b111..3554789a01 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/UpdateHistory.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/UpdateHistory.scala @@ -28,6 +28,7 @@ import org.lfdecentralizedtrust.splice.store.HistoryBackfilling.{ } import org.lfdecentralizedtrust.splice.store.MultiDomainAcsStore.{HasIngestionSink, IngestionFilter} import org.lfdecentralizedtrust.splice.store.db.{AcsJdbcTypes, AcsQueries} +import db.AsUpdateReturning.* import org.lfdecentralizedtrust.splice.util.{ Contract, DomainRecordTimeRange, @@ -39,6 +40,8 @@ import com.digitalasset.canton.config.CantonRequireTypes.String256M import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.lifecycle.CloseContext import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} +import com.digitalasset.canton.logging.pretty.Pretty +import com.digitalasset.canton.logging.pretty.Pretty.{param, prettyOfClass} import com.digitalasset.canton.resource.DbStorage import com.digitalasset.canton.topology.{ParticipantId, PartyId, SynchronizerId} import com.digitalasset.canton.tracing.TraceContext @@ -905,14 +908,14 @@ class UpdateHistory( } private def afterFilters( - afterO: Option[(Long, CantonTimestamp)], + afterO: Option[TimestampWithMigrationId], includeImportUpdates: Boolean, ): NonEmptyList[SQLActionBuilder] = { val gtMin = if (includeImportUpdates) ">=" else ">" afterO match { case None => NonEmptyList.of(sql"migration_id >= 0 and record_time #$gtMin ${CantonTimestamp.MinValue}") - case Some((afterMigrationId, afterRecordTime)) => + case Some(TimestampWithMigrationId(afterRecordTime, afterMigrationId)) => // This makes it so that the two queries use updt_hist_tran_hi_mi_rt_di, NonEmptyList.of( sql"migration_id = ${afterMigrationId} and record_time > ${afterRecordTime} ", @@ -1100,7 +1103,7 @@ class UpdateHistory( } def getUpdatesWithoutImportUpdates( - afterO: Option[(Long, CantonTimestamp)], + afterO: Option[TimestampWithMigrationId], limit: Limit, )(implicit tc: TraceContext): Future[Seq[TreeUpdateWithMigrationId]] = { val filters = afterFilters(afterO, includeImportUpdates = false) @@ -1131,7 +1134,7 @@ class UpdateHistory( } def getAllUpdates( - afterO: Option[(Long, CantonTimestamp)], + afterO: Option[TimestampWithMigrationId], limit: PageLimit, )(implicit tc: TraceContext): Future[Seq[TreeUpdateWithMigrationId]] = { val filters = afterFilters(afterO, includeImportUpdates = true) @@ -2594,6 +2597,12 @@ final case class TimestampWithMigrationId( object TimestampWithMigrationId { implicit val ordering: Ordering[TimestampWithMigrationId] = Ordering.by(x => (x.migrationId, x.timestamp)) + + implicit val prettyTimestampWithMigrationId: Pretty[TimestampWithMigrationId] = + prettyOfClass( + param("timestamp", _.timestamp), + param("migrationId", _.migrationId), + ) } final case class TreeUpdateWithMigrationId( diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/VotesStore.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/VotesStore.scala index c293f1be99..7f91f5f958 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/VotesStore.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/VotesStore.scala @@ -124,18 +124,28 @@ trait ActiveVotesStore extends AppStore with DsoRulesStore with HasAmuletRules { } +final case class VoteResultsFilters( + actionName: Option[String] = None, + accepted: Option[Boolean] = None, + requester: Option[String] = None, + effectiveFrom: Option[String] = None, + effectiveTo: Option[String] = None, +) + trait VotesStore extends ActiveVotesStore { def listVoteRequestResults( - actionName: Option[String], - accepted: Option[Boolean], - requester: Option[String], - effectiveFrom: Option[String], - effectiveTo: Option[String], + filters: VoteResultsFilters, limit: Limit = defaultLimit, after: Option[Long] = None, )(implicit tc: TraceContext ): Future[ResultsPage[DsoRules_CloseVoteRequestResult]] + def countVoteRequestResults( + filters: VoteResultsFilters + )(implicit + tc: TraceContext + ): Future[Long] + } diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/bulk/ZstdGroupedWeight.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/bulk/ZstdGroupedWeight.scala index 66edda79fd..4d9cd8997f 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/bulk/ZstdGroupedWeight.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/bulk/ZstdGroupedWeight.scala @@ -9,6 +9,8 @@ import org.apache.pekko.stream.stage.{GraphStage, GraphStageLogic, InHandler, Ou import org.apache.pekko.stream.{Attributes, FlowShape, Inlet, Outlet} import org.apache.pekko.util.ByteString +import scala.util.control.NonFatal + /** A Pekko GraphStage that zstd-compresses a stream of bytestrings, and splits the output into zstd objects of size (minWeight + delta). * Somewhat similar to Pekko's built-in GroupedWeight, but outputs valid zstd compressed objects. */ @@ -47,15 +49,25 @@ case class ZstdGroupedWeight( val bufferAllocator = PooledByteBufAllocator.DEFAULT val tmpBuffer = bufferAllocator.directBuffer(zstdTmpBufferSize) - val tmpNioBuffer = tmpBuffer.nioBuffer(0, tmpBuffer.capacity()) - val compressingStream = - new ZstdDirectBufferCompressingStreamNoFinalizer(tmpNioBuffer, compressionLevel) + val (tmpNioBuffer, compressingStream) = + try { + val nioBuffer = tmpBuffer.nioBuffer(0, tmpBuffer.capacity()) + (nioBuffer, new ZstdDirectBufferCompressingStreamNoFinalizer(nioBuffer, compressionLevel)) + } catch { + // a failed construction never reaches close(), so release the buffer here + case NonFatal(e) => + val _ = tmpBuffer.release() + throw e + } def compress(input: ByteString): ByteString = { val inputBB = bufferAllocator.directBuffer(input.size) - inputBB.writeBytes(input.toArrayUnsafe()) - compressingStream.compress(inputBB.nioBuffer()) - inputBB.release() + try { + inputBB.writeBytes(input.toArrayUnsafe()) + compressingStream.compress(inputBB.nioBuffer()) + } finally { + val _ = inputBB.release() + } compressingStream.flush() tmpNioBuffer.flip() val result = ByteString.fromByteBuffer(tmpNioBuffer) @@ -72,8 +84,11 @@ case class ZstdGroupedWeight( } override def close(): Unit = { - compressingStream.close() - val _ = tmpBuffer.release() + try { + compressingStream.close() + } finally { + val _ = tmpBuffer.release() + } } } diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/AcsJdbcTypes.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/AcsJdbcTypes.scala index 9eb7b7732c..4ec07e59c7 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/AcsJdbcTypes.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/AcsJdbcTypes.scala @@ -10,7 +10,7 @@ import com.daml.ledger.javaapi.data.codegen.{ContractId, DamlRecord, DefinedData import com.digitalasset.canton.config.CantonRequireTypes.{String2066, String3, String300} import com.digitalasset.canton.data.{CantonTimestamp, Offset} import com.digitalasset.canton.daml.lf.value.json.ApiCodecCompressed -import com.digitalasset.canton.topology.{Member, PartyId, SynchronizerId} +import com.digitalasset.canton.topology.{Member, SynchronizerId} import com.digitalasset.daml.lf.data.Ref.HexString import com.digitalasset.daml.lf.data.Time.Timestamp import com.google.protobuf.ByteString @@ -32,14 +32,12 @@ import com.digitalasset.canton.LfValue import com.digitalasset.canton.logging.ErrorLoggingContext import spray.json.{JsString, JsValue, JsonFormat, deserializationError} -import java.sql.{JDBCType, PreparedStatement, ResultSet} +import java.sql.{PreparedStatement, ResultSet} import java.io.StringWriter -trait AcsJdbcTypes { +trait AcsJdbcTypes extends JdbcTypes { import AcsJdbcTypes.JsonString - val profile: slick.jdbc.JdbcProfile - import profile.api.* protected implicit lazy val byteArrayGetResult: GetResult[Array[Byte]] = @@ -194,13 +192,6 @@ trait AcsJdbcTypes { (timestamps: Array[CantonTimestamp], pp: PositionedParameters) => longArraySetParameter(timestamps.map(_.toMicros), pp) - protected implicit lazy val stringArraySetParameter: SetParameter[Array[String]] = - (strings: Array[String], pp: PositionedParameters) => - pp.setObject( - pp.ps.getConnection.createArrayOf("text", strings.map(x => x)), - JDBCType.ARRAY.getVendorTypeNumber, - ) - protected implicit lazy val stringSeqSetParameter: SetParameter[Seq[String]] = (strings: Seq[String], pp: PositionedParameters) => stringArraySetParameter(strings.toArray, pp) @@ -208,10 +199,6 @@ trait AcsJdbcTypes { (strings: Array[String3], pp: PositionedParameters) => stringArraySetParameter(strings.map(_.str), pp) - protected implicit lazy val string2066ArraySetParameter: SetParameter[Array[String2066]] = - (strings: Array[String2066], pp: PositionedParameters) => - stringArraySetParameter(strings.map(_.str), pp) - protected implicit lazy val string2066SeqSetParameter: SetParameter[Seq[String2066]] = (strings: Seq[String2066], pp: PositionedParameters) => stringArraySetParameter(strings.map(_.str).toArray, pp) @@ -224,12 +211,6 @@ trait AcsJdbcTypes { (ids: Array[ContractId[?]], pp: PositionedParameters) => stringArraySetParameter(ids.map(_.contractId), pp) - protected implicit def partyIdGetResult[T]: GetResult[PartyId] = - GetResult.GetString.andThen(PartyId.tryFromProtoPrimitive) - - protected implicit def partyIdGetResultOption[T]: GetResult[Option[PartyId]] = - GetResult.GetStringOption.andThen(_.map(PartyId.tryFromProtoPrimitive)) - protected implicit lazy val offsetJdbcType: JdbcType[Offset] = MappedColumnType.base[Offset, String]( offset => LegacyOffset.fromLong(offset.unwrap).toHexString, @@ -273,19 +254,6 @@ trait AcsJdbcTypes { protected implicit lazy val synchronizerIdJdbcType: JdbcType[SynchronizerId] = MappedColumnType.base[SynchronizerId, String](_.toProtoPrimitive, SynchronizerId.tryFromString) - protected implicit lazy val partyIdJdbcType: JdbcType[PartyId] = - MappedColumnType.base[PartyId, String](_.toProtoPrimitive, PartyId.tryFromProtoPrimitive) - - protected implicit lazy val partyIdSetParameterOption: SetParameter[Option[PartyId]] = - (partyId: Option[PartyId], pp: PositionedParameters) => - implicitly[SetParameter[Option[String2066]]] - .apply(partyId.map(party => lengthLimited(party.toProtoPrimitive)), pp) - - protected implicit lazy val partyIdSetParameterArray: SetParameter[Array[PartyId]] = - (partyId: Array[PartyId], pp: PositionedParameters) => - implicitly[SetParameter[Array[String2066]]] - .apply(partyId.map(party => lengthLimited(party.toProtoPrimitive)), pp) - protected implicit lazy val memberIdSetParameter: SetParameter[Member] = (memberId: Member, pp: PositionedParameters) => implicitly[SetParameter[String300]].apply(memberId.toLengthLimitedString, pp) @@ -375,11 +343,6 @@ trait AcsJdbcTypes { data: DefinedDataType[?] ): Json = AcsJdbcTypes.payloadJsonFromDefinedDataType(data) - /** The DB may truncate strings of unbounded length, so it's advised to use a LengthLimitedString instead. - * We use String2066 because it's the max length of an [[com.digitalasset.canton.protocol.LfTemplateId]]. - */ - protected def lengthLimited(s: String): String2066 = String2066.tryCreate(s) - private def lengthLimitedByteString(bs: ByteString, maxLength: Int): ByteString = { require( bs.size() <= maxLength, diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/AcsQueries.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/AcsQueries.scala index b3c05fb390..16ce755ef4 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/AcsQueries.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/AcsQueries.scala @@ -6,7 +6,6 @@ package org.lfdecentralizedtrust.splice.store.db import com.daml.ledger.javaapi.data.Identifier import com.daml.ledger.javaapi.data.codegen.ContractId import com.digitalasset.canton.resource.DbStorage.Implicits.BuilderChain.toSQLActionBuilderChain -import com.digitalasset.canton.resource.DbStorage.SQLActionBuilderChain import com.digitalasset.canton.topology.{PartyId, SynchronizerId} import com.digitalasset.daml.lf.data.Time.Timestamp import com.google.protobuf.ByteString @@ -25,13 +24,11 @@ import org.lfdecentralizedtrust.splice.util.PrettyInstances.* import scalaz.{@@, Tag} import slick.jdbc.canton.ActionBasedSQLInterpolation.Implicits.actionBasedSQLInterpolationCanton import slick.jdbc.canton.SQLActionBuilder -import slick.jdbc.{GetResult, PositionedResult, SetParameter} +import slick.jdbc.{GetResult, PositionedResult} import slick.dbio.Effect import slick.sql.SqlStreamingAction -import scala.reflect.ClassTag - -trait AcsQueries extends AcsJdbcTypes { +trait AcsQueries extends Queries with AcsJdbcTypes { /** @param tableName Must be SQL-safe, as it needs to be interpolated unsafely. * This is fine, as all calls to this method should use static string constants. @@ -267,39 +264,6 @@ trait AcsQueries extends AcsJdbcTypes { ) } - /** Constructions like `seq.mkString("(", ",", ")")` are dangerous because they can lead to SQL injection. - * Prefer using this instead. - */ - protected def sqlCommaSeparated( - seq: Iterable[SQLActionBuilder] - ): SQLActionBuilderChain = { - seq - .map(SQLActionBuilderChain(_)) - .reduceOption { (acc, next) => - acc ++ sql"," ++ next - } - .getOrElse(SQLActionBuilderChain(sql"")) - } - - /* - * TODO(#3900) move to use toInClause when canton fork has it: https://github.com/canton-network/splice/issues/3900 - */ - protected def inClause[V: ClassTag]( - field: String, - seq: Iterable[V], - )(implicit - arraySetParameter: SetParameter[Array[V]] - ): SQLActionBuilder = - sql" #$field = ANY(${seq.toArray[V]})" - - protected def notInClause[V: ClassTag]( - field: String, - seq: Iterable[V], - )(implicit - arraySetParameter: SetParameter[Array[V]] - ): SQLActionBuilder = - sql" NOT (#$field = ANY(${seq.toArray[V]}))" - protected def contractFromRow[C, TCId <: ContractId[?], T](companion: C)( row: AcsQueries.SelectFromAcsTableResult )(implicit diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/AdvisoryLockIds.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/AdvisoryLockIds.scala index ea5543b472..58d789fea4 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/AdvisoryLockIds.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/AdvisoryLockIds.scala @@ -16,4 +16,5 @@ object AdvisoryLockIds { private val base: Long = 0x73706c00 final val acsSnapshotDataInsert: Long = base + 1 + final val ddlStatement: Long = base + 2 } diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/AdvisoryLocks.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/AdvisoryLocks.scala new file mode 100644 index 0000000000..1201db3c21 --- /dev/null +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/AdvisoryLocks.scala @@ -0,0 +1,63 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.store.db + +import slick.dbio.{DBIOAction, Effect, NoStream} +import slick.jdbc.JdbcProfile +import slick.jdbc.canton.ActionBasedSQLInterpolation.Implicits.actionBasedSQLInterpolationCanton + +import scala.concurrent.ExecutionContext + +object AdvisoryLocks { + final case class FailedToAcquireLockException( + lockType: String, + lockId: Long, + ) extends RuntimeException(s"Failed to acquire $lockType advisory lock $lockId.") + + private def withLock[T, E <: Effect](lockType: String, lockId: Long)( + acquire: DBIOAction[Boolean, NoStream, Effect.Read], + onAcquired: DBIOAction[T, NoStream, E], + )(implicit ec: ExecutionContext): DBIOAction[T, NoStream, Effect.Read & E] = + acquire.flatMap(acquired => + if (acquired) onAcquired + else DBIOAction.failed(FailedToAcquireLockException(lockType, lockId)) + ) + + private def acquireSessionLock(lockId: Long): DBIOAction[Boolean, NoStream, Effect.Read] = + sql"select pg_try_advisory_lock($lockId)".as[Boolean].head + + private def releaseSessionLock(lockId: Long): DBIOAction[Boolean, NoStream, Effect.Read] = + sql"select pg_advisory_unlock($lockId)".as[Boolean].head + + /** Wraps the given action in a session-scoped advisory lock; useful for acquiring locks for + * queries like DDL that can't run in a transaction. + */ + def withSessionLock[T, E <: Effect](lockId: Long, action: DBIOAction[T, NoStream, E])(implicit + ec: ExecutionContext + ): DBIOAction[T, NoStream, Effect.Read & E] = + withLock("session-scoped", lockId)( + acquireSessionLock(lockId), + action.andFinally(releaseSessionLock(lockId)), + ).withPinnedSession + + def withDdlLock[T, E <: Effect](action: DBIOAction[T, NoStream, E])(implicit + ec: ExecutionContext + ): DBIOAction[T, NoStream, Effect.Read & E] = + withSessionLock(AdvisoryLockIds.ddlStatement, action) + + private def acquireTransactionalLock(lockId: Long): DBIOAction[Boolean, NoStream, Effect.Read] = + sql"SELECT pg_try_advisory_xact_lock($lockId)".as[Boolean].head + + /** Wraps the given action in a transactional advisory lock. */ + def withTransactionalLock[T, E <: Effect]( + profile: JdbcProfile, + lockId: Long, + action: DBIOAction[T, NoStream, E], + )(implicit + ec: ExecutionContext + ): DBIOAction[T, NoStream, Effect.Read & Effect.Transactional & E] = { + import profile.api.jdbcActionExtensionMethods + withLock("transactional", lockId)(acquireTransactionalLock(lockId), action).transactionally + } +} diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/AsUpdateReturning.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/AsUpdateReturning.scala new file mode 100644 index 0000000000..95bf108fc3 --- /dev/null +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/AsUpdateReturning.scala @@ -0,0 +1,39 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.store.db + +import slick.dbio.Effect +import slick.jdbc.GetResult +import slick.jdbc.canton.SQLActionBuilder +import slick.sql.SqlStreamingAction + +/** Syntax for running data-modifying statements that also return rows, such as + * PostgreSQL's `insert ... returning`, `insert ... on conflict do nothing returning`, + * and `delete ... returning`. + */ +private[store] object AsUpdateReturning { + + implicit class `SQLActionBuilder asUpdateReturning`(private val builder: SQLActionBuilder) + extends AnyVal { + + /** Run this statement as one that both writes and reads back rows. + * + * Neither of the combinators that come with [[slick.jdbc.canton.SQLActionBuilder]] + * fits `... returning ...` statements: + * + * - `asUpdate` yields the JDBC update count as a single `Int` and throws + * away the result set. + * - `as[R]` does decode the result set, but types the action as + * `Effect.Read` alone, which would allow its usage with unsafe + * combinators. + * + * So this simply relabels the read action as also writing by widening the + * result of `as[R]`. + */ + def asUpdateReturning[R](implicit + rconv: GetResult[R] + ): SqlStreamingAction[Vector[R], R, Effect.Read & Effect.Write] = + builder.as[R] + } +} diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/DbMultiDomainAcsStore.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/DbMultiDomainAcsStore.scala index c5e8825705..470d5e445b 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/DbMultiDomainAcsStore.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/DbMultiDomainAcsStore.scala @@ -37,7 +37,7 @@ import com.digitalasset.canton.discard.Implicits.DiscardOps import com.digitalasset.canton.lifecycle.{CloseContext, FutureUnlessShutdown} import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.resource.DbStorage -import com.digitalasset.canton.topology.SynchronizerId +import com.digitalasset.canton.topology.{PartyId, SynchronizerId} import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.util.ShowUtil.showPretty @@ -54,6 +54,7 @@ import org.lfdecentralizedtrust.splice.store.db.AcsQueries.{ SelectFromAcsTableWithStateResult, } import org.lfdecentralizedtrust.splice.store.db.AcsTables.ContractStateRowData +import AsUpdateReturning.* import com.daml.nonempty.NonEmpty import com.digitalasset.canton.data.CantonTimestamp import com.daml.metrics.api.MetricHandle.LabeledMetricsFactory @@ -378,9 +379,23 @@ final class DbMultiDomainAcsStore[TXE]( override private[splice] def listExpiredFromPayloadExpiry[C, TCid <: ContractId[ T - ], T <: Template](companion: C)(implicit + ], T <: Template]( + companion: C, + ignoredPartiesStore: Option[IgnoredPartiesStore] = None, + ignoredPartyFields: Seq[String] = Seq.empty, + )(implicit companionClass: ContractCompanion[C, TCid, T] ): ListExpiredContracts[TCid, T] = { (now, limit) => implicit traceContext => + val ignoredParties = ignoredPartiesStore.fold(Set.empty[PartyId])(_.getAll) + val ignoredPartiesFilter: SQLActionBuilder = + if (ignoredParties.isEmpty || ignoredPartyFields.isEmpty) sql"" + else + ignoredPartyFields.foldLeft(sql"") { (acc, field) => + (acc ++ sql" and " ++ notInClause( + s"acs.create_arguments->>'$field'", + ignoredParties, + )).toActionBuilder + } for { _ <- waitUntilAcsIngested() result <- storage @@ -390,7 +405,8 @@ final class DbMultiDomainAcsStore[TXE]( acsStoreId, domainMigrationId, companion, - additionalWhere = sql"""and acs.contract_expires_at < $now""", + additionalWhere = + (sql"""and acs.contract_expires_at < $now""" ++ ignoredPartiesFilter).toActionBuilder, orderLimit = sql"""limit ${sqlLimit(limit)}""", ), "listExpiredFromPayloadExpiry", @@ -977,21 +993,6 @@ final class DbMultiDomainAcsStore[TXE]( case Some(descriptor) => initializeDescriptor(descriptor).map(TxLogStoreId.subst) case None => Future.successful(StoreNotUsed[TxLogStoreId]()) } - - acsSizeInDb <- acsInitResult match { - case StoreHasData(acsStoreId, _) => - storage - .querySingle( - sql""" - select count(*) - from #$acsTableName - where store_id = ${acsStoreId} and migration_id = $domainMigrationId - """.as[Int].headOption, - "initialize.getAcsCount", - ) - .getOrElse(0) - case _ => FutureUnlessShutdown.pure(0) - } } yield { def initState( acsStoreId: AcsStoreId, @@ -1004,7 +1005,6 @@ final class DbMultiDomainAcsStore[TXE]( _.withInitialState( acsStoreId = acsStoreId, txLogStoreId = txLogStoreId, - acsSizeInDb = acsSizeInDb, lastIngestedOffset = lastIngestedOffset, ) ) @@ -1166,46 +1166,43 @@ final class DbMultiDomainAcsStore[TXE]( // This is fine because all clients are expected to use [[waitUntilAcsIngested()]] to avoid // reading ACS data before it has finished ingesting. _ <- clearDataForCurrentMigrationId() - acsSize <- source.runWith( - Sink.foldAsync[Int, Seq[BaseLedgerConnection.ActiveContractsItem]](0) { - case (acsSizeSoFar, batch) => - val summaryState = MutableIngestionSummary.empty - logger.debug( - s"Ingesting ACS batch with size: ${batch.size}, total ingested size so far: $acsSizeSoFar" - ) - metrics.ingestionTimePerACSBatch - .timeFuture { - ingestAcsBatch( - offset, - batch.collect { case ActiveContractsItem.ActiveContract(contract) => - contract - }, - batch.collect { case ActiveContractsItem.IncompleteUnassign(unassign) => - unassign - }, - batch.collect { case ActiveContractsItem.IncompleteAssign(assign) => assign }, - summaryState, + _ <- source.runWith( + Sink.foreachAsync[Seq[BaseLedgerConnection.ActiveContractsItem]](1) { batch => + val summaryState = MutableIngestionSummary.empty + logger.debug( + s"Ingesting ACS batch with size: ${batch.size}" + ) + metrics.ingestionTimePerACSBatch + .timeFuture { + ingestAcsBatch( + offset, + batch.collect { case ActiveContractsItem.ActiveContract(contract) => + contract + }, + batch.collect { case ActiveContractsItem.IncompleteUnassign(unassign) => + unassign + }, + batch.collect { case ActiveContractsItem.IncompleteAssign(assign) => assign }, + summaryState, + ) + } + .map { _ => + val summary = summaryState + .toIngestionSummary( + synchronizerIdToRecordTime = Map.empty, + offset = offset, + acsSizeDiff = summaryState.acsSizeDiff, + metrics = metrics, ) - } - .map { _ => - val newAcsSize = summaryState.acsSizeDiff + acsSizeSoFar - val summary = summaryState - .toIngestionSummary( - synchronizerIdToRecordTime = Map.empty, - offset = offset, - newAcsSize = newAcsSize, - metrics = metrics, - ) - handleIngestionSummary(summary) - logger.debug(show"Ingested ACS batch $summary") - newAcsSize - } + handleIngestionSummary(summary) + logger.debug(show"Ingested ACS batch $summary") + } } ) // A store is considered initialized if the last ingested offset is set // Therefore, we must do that after the ACS is ingested, // so that in case of failure the whole ACS ingestion will be retried. - _ <- markAcsIngestedAsOf(offset, acsSize) + _ <- markAcsIngestedAsOf(offset) } yield () } } @@ -1367,13 +1364,13 @@ final class DbMultiDomainAcsStore[TXE]( } } - private def markAcsIngestedAsOf(offset: Long, acsSize: Int)(implicit + private def markAcsIngestedAsOf(offset: Long)(implicit traceContext: TraceContext ): Future[Unit] = { storage.update(updateOffset(offset), "markAcsIngestedAsOf").map { _ => state .getAndUpdate( - _.withUpdate(acsSize, offset) + _.withUpdate(offset) ) .signalOffsetChanged(offset) @@ -1413,7 +1410,6 @@ final class DbMultiDomainAcsStore[TXE]( state .getAndUpdate(s => s.withUpdate( - s.acsSize + summaryState.acsSizeDiff, lastTree.getOffset, synchronizerIdToRecordTime.toMap, ) @@ -1423,7 +1419,7 @@ final class DbMultiDomainAcsStore[TXE]( summaryState.toIngestionSummary( offset = lastTree.getOffset, synchronizerIdToRecordTime = synchronizerIdToRecordTime.toMap, - newAcsSize = state.get().acsSize, + acsSizeDiff = summaryState.acsSizeDiff, metrics = metrics, ) logger.debug( @@ -1442,7 +1438,6 @@ final class DbMultiDomainAcsStore[TXE]( state .getAndUpdate(s => s.withUpdate( - s.acsSize + summaryState.acsSizeDiff, reassignment.offset, reassignmentRecordTimes, ) @@ -1452,7 +1447,7 @@ final class DbMultiDomainAcsStore[TXE]( summaryState.toIngestionSummary( synchronizerIdToRecordTime = reassignmentRecordTimes, offset = reassignment.offset, - newAcsSize = state.get().acsSize, + acsSizeDiff = summaryState.acsSizeDiff, metrics = metrics, ) logger.debug(show"Ingested reassignment $summary") @@ -1472,13 +1467,13 @@ final class DbMultiDomainAcsStore[TXE]( ) .map { _ => state - .getAndUpdate(s => s.withUpdate(s.acsSize, offset, synchronizerIdToRecordTime)) + .getAndUpdate(s => s.withUpdate(offset, synchronizerIdToRecordTime)) .signalWaiters(offset, synchronizerIdToRecordTime) val summary = MutableIngestionSummary.empty.toIngestionSummary( synchronizerIdToRecordTime = synchronizerIdToRecordTime, offset = offset, - newAcsSize = state.get().acsSize, + acsSizeDiff = 0, metrics = metrics, ) logger.debug(show"Ingested offset checkpoint $offset") @@ -2234,7 +2229,6 @@ object DbMultiDomainAcsStore { /** @param acsStoreId The primary key of this stores ACS entry in the store_descriptors table * @param txLogStoreId The primary key of this stores TxLog entry in the store_descriptors table * @param offset The last ingested offset, if any - * @param acsSize The number of active contracts in the store * @param offsetChanged A promise that is not yet completed, and will be completed the next time the offset changes * @param offsetIngestionsToSignal A map from offsets to promises. The keys are offsets that are not ingested yet. * The values are promises that are not completed, and will be completed when @@ -2246,7 +2240,6 @@ object DbMultiDomainAcsStore { acsStoreId: Option[AcsStoreId], txLogStoreId: Option[TxLogStoreId], offset: Option[Long], - acsSize: Int, offsetChanged: Promise[Unit], offsetIngestionsToSignal: SortedMap[Long, Promise[Unit]], lastIngestedRecordTimes: Map[SynchronizerId, CantonTimestamp], @@ -2255,7 +2248,6 @@ object DbMultiDomainAcsStore { def withInitialState( acsStoreId: AcsStoreId, txLogStoreId: Option[TxLogStoreId], - acsSizeInDb: Int, lastIngestedOffset: Option[Long], ): State = { assert( @@ -2268,14 +2260,12 @@ object DbMultiDomainAcsStore { this.copy( acsStoreId = Some(acsStoreId), txLogStoreId = txLogStoreId, - acsSize = acsSizeInDb, offset = lastIngestedOffset, offsetChanged = nextOffsetChanged, ) } def withUpdate( - newAcsSize: Int, newOffset: Long, recordTimes: Map[SynchronizerId, CantonTimestamp] = Map.empty, ): State = { @@ -2295,7 +2285,6 @@ object DbMultiDomainAcsStore { } } this.copy( - acsSize = newAcsSize, offset = Some(newOffset), offsetChanged = nextOffsetChanged, offsetIngestionsToSignal = offsetIngestionsToSignal.filter { case (offsetToSignal, _) => @@ -2388,7 +2377,6 @@ object DbMultiDomainAcsStore { acsStoreId = None, txLogStoreId = None, offset = None, - acsSize = 0, offsetChanged = Promise(), offsetIngestionsToSignal = SortedMap.empty, lastIngestedRecordTimes = Map.empty, diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/DbUnavailablePartiesStore.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/DbUnavailablePartiesStore.scala new file mode 100644 index 0000000000..e12f9ce6f2 --- /dev/null +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/DbUnavailablePartiesStore.scala @@ -0,0 +1,121 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.store.db + +import com.digitalasset.canton.config.NonNegativeFiniteDuration +import com.digitalasset.canton.discard.Implicits.DiscardOps +import com.digitalasset.canton.lifecycle.CloseContext +import com.digitalasset.canton.logging.{ErrorLoggingContext, NamedLoggerFactory, NamedLogging} +import com.digitalasset.canton.resource.DbStorage +import com.digitalasset.canton.topology.PartyId +import com.digitalasset.canton.tracing.TraceContext +import org.lfdecentralizedtrust.splice.store.UnavailablePartiesStore +import org.lfdecentralizedtrust.splice.util.FutureUnlessShutdownUtil.futureUnlessShutdownToFuture +import slick.jdbc.JdbcProfile +import slick.jdbc.canton.ActionBasedSQLInterpolation.Implicits.actionBasedSQLInterpolationCanton + +import scala.concurrent.{ExecutionContext, Future} + +class DbUnavailablePartiesStore( + storage: DbStorage, + val storeId: Int, + baseDuration: NonNegativeFiniteDuration, + maxIgnoreDuration: NonNegativeFiniteDuration, + val loggerFactory: NamedLoggerFactory, +)(implicit + val ec: ExecutionContext, + val loggingContext: ErrorLoggingContext, + val closeContext: CloseContext, +) extends UnavailablePartiesStore + with Queries + with NamedLogging { + + val profile: JdbcProfile = storage.profile.jdbc + + private val baseMicros = baseDuration.underlying.toMicros + private val maxMicros = maxIgnoreDuration.underlying.toMicros + + /** Adds or updates parties only outside the ignore window. + * a. For new parties, it sets updated_at to now and the ignore_duration to base_duration. + * b. For existing parties, it updates updated_at to now and doubles the ignore_duration (up to max_ignore_duration) + */ + def addParties(parties: Seq[PartyId], nowMicros: Long)(implicit + tc: TraceContext + ): Future[Unit] = + if (parties.isEmpty) Future.unit + else { + val partyArray = parties.distinct.toArray + logger.debug(s"Marking ${partyArray.length} parties as unavailable at $nowMicros") + storage + .update( + sql"""insert into dso_unavailable_parties + (party, updated_at, ignore_duration, store_id) + select u.party, $nowMicros, $baseMicros, $storeId + from unnest($partyArray) as u(party) + on conflict (party) do update + set updated_at = excluded.updated_at, + ignore_duration = least( + dso_unavailable_parties.ignore_duration * 2, + $maxMicros) + where dso_unavailable_parties.updated_at + dso_unavailable_parties.ignore_duration <= excluded.updated_at + """.asUpdate, + "addParties", + ) + .map(_.discard) + } + + // Removes specific parties from the table upon successful transaction processing. + def removeParties(parties: Seq[PartyId])(implicit tc: TraceContext): Future[Int] = + if (parties.isEmpty) Future.successful(0) + else { + val partyArray = parties.distinct.toArray + storage.update( + sqlu"""delete from dso_unavailable_parties where party = any($partyArray)""", + "removeParties", + ) + } + + // Removes parties from the table with matching store ID. + def removePartiesUpToStoreId(maxStoreId: Long)(implicit tc: TraceContext): Future[Int] = + storage.update( + sqlu"""delete from dso_unavailable_parties where store_id <= $maxStoreId""", + "removePartiesUpToStoreId", + ) + + // List all parties for which updated_at + ignore_duration > now. + def listParties(nowMicros: Long)(implicit tc: TraceContext): Future[Seq[PartyId]] = + storage.query( + sql"""select party + from dso_unavailable_parties + where updated_at + ignore_duration > $nowMicros""".as[PartyId], + "listParties", + ) + +} + +object DbUnavailablePartiesStore { + def apply( + storeDescriptor: StoreDescriptor, + storage: DbStorage, + baseDuration: NonNegativeFiniteDuration, + maxIgnoreDuration: NonNegativeFiniteDuration, + loggerFactory: NamedLoggerFactory, + )(implicit + ec: ExecutionContext, + lc: ErrorLoggingContext, + cc: CloseContext, + tc: TraceContext, + ): Future[DbUnavailablePartiesStore] = + StoreDescriptorStore + .getStoreIdForDescriptor(storeDescriptor, storage) + .map(storeId => + new DbUnavailablePartiesStore( + storage, + storeId, + baseDuration, + maxIgnoreDuration, + loggerFactory, + ) + ) +} diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/JdbcTypes.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/JdbcTypes.scala new file mode 100644 index 0000000000..6e4b98b32d --- /dev/null +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/JdbcTypes.scala @@ -0,0 +1,52 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.store.db + +import com.digitalasset.canton.config.CantonRequireTypes.String2066 +import com.digitalasset.canton.topology.PartyId +import slick.jdbc.* + +import java.sql.JDBCType + +trait JdbcTypes { + + val profile: slick.jdbc.JdbcProfile + import profile.api.* + + /** The DB may truncate strings of unbounded length, so it's advised to use a LengthLimitedString instead. + * We use String2066 because it's the max length of an [[com.digitalasset.canton.protocol.LfTemplateId]]. + */ + protected def lengthLimited(s: String): String2066 = String2066.tryCreate(s) + + protected implicit def partyIdGetResult[T]: GetResult[PartyId] = + GetResult.GetString.andThen(PartyId.tryFromProtoPrimitive) + + protected implicit def partyIdGetResultOption[T]: GetResult[Option[PartyId]] = + GetResult.GetStringOption.andThen(_.map(PartyId.tryFromProtoPrimitive)) + + protected implicit lazy val partyIdJdbcType: JdbcType[PartyId] = + MappedColumnType.base[PartyId, String](_.toProtoPrimitive, PartyId.tryFromProtoPrimitive) + + protected implicit lazy val partyIdSetParameterOption: SetParameter[Option[PartyId]] = + (partyId: Option[PartyId], pp: PositionedParameters) => + implicitly[SetParameter[Option[String2066]]] + .apply(partyId.map(party => lengthLimited(party.toProtoPrimitive)), pp) + + protected implicit lazy val partyIdSetParameterArray: SetParameter[Array[PartyId]] = + (partyId: Array[PartyId], pp: PositionedParameters) => + implicitly[SetParameter[Array[String2066]]] + .apply(partyId.map(party => lengthLimited(party.toProtoPrimitive)), pp) + + protected implicit lazy val stringArraySetParameter: SetParameter[Array[String]] = + (strings: Array[String], pp: PositionedParameters) => + pp.setObject( + pp.ps.getConnection.createArrayOf("text", strings.map(x => x)), + JDBCType.ARRAY.getVendorTypeNumber, + ) + + protected implicit lazy val string2066ArraySetParameter: SetParameter[Array[String2066]] = + (strings: Array[String2066], pp: PositionedParameters) => + stringArraySetParameter(strings.map(_.str), pp) + +} diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/Queries.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/Queries.scala new file mode 100644 index 0000000000..1a55b834e8 --- /dev/null +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/Queries.scala @@ -0,0 +1,48 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.store.db + +import com.digitalasset.canton.resource.DbStorage.SQLActionBuilderChain +import slick.jdbc.SetParameter +import slick.jdbc.canton.ActionBasedSQLInterpolation.Implicits.actionBasedSQLInterpolationCanton +import slick.jdbc.canton.SQLActionBuilder + +import scala.reflect.ClassTag + +trait Queries extends JdbcTypes { + + /** Constructions like `seq.mkString("(", ",", ")")` are dangerous because they can lead to SQL injection. + * Prefer using this instead. + */ + protected def sqlCommaSeparated( + seq: Iterable[SQLActionBuilder] + ): SQLActionBuilderChain = { + seq + .map(SQLActionBuilderChain(_)) + .reduceOption { (acc, next) => + acc ++ sql"," ++ next + } + .getOrElse(SQLActionBuilderChain(sql"")) + } + + /* + * TODO(#3900) move to use toInClause when canton fork has it: https://github.com/canton-network/splice/issues/3900 + */ + protected def inClause[V: ClassTag]( + field: String, + seq: Iterable[V], + )(implicit + arraySetParameter: SetParameter[Array[V]] + ): SQLActionBuilder = + sql" #$field = ANY(${seq.toArray[V]})" + + protected def notInClause[V: ClassTag]( + field: String, + seq: Iterable[V], + )(implicit + arraySetParameter: SetParameter[Array[V]] + ): SQLActionBuilder = + sql" NOT (#$field = ANY(${seq.toArray[V]}))" + +} diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/DarResourcesUtil.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/DarResourcesUtil.scala index 3cb46ac417..45eef8fabe 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/DarResourcesUtil.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/DarResourcesUtil.scala @@ -9,7 +9,7 @@ import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.daml.lf.data.Ref.{PackageName, PackageVersion} import org.lfdecentralizedtrust.splice.environment.DarResource import org.lfdecentralizedtrust.splice.environment.DarResources.{ - packageResources, + corePackageResources, pkgIdToDarResource, pkgMetadataToDarResource, } @@ -18,12 +18,12 @@ object DarResourcesUtil extends NamedLogging { override protected def loggerFactory: NamedLoggerFactory = NamedLoggerFactory.root - val minimalPackageVersions: Seq[DarResource] = packageResources.flatMap(pkg => + val minimalPackageVersions: Seq[DarResource] = corePackageResources.flatMap(pkg => pkg.all.filter(p => p.metadata.version == pkg.minimumInitialization.metadata.version) ) val supportedPackageVersions: Seq[DarResource] = - packageResources.flatMap(pkg => + corePackageResources.flatMap(pkg => pkg.all.filter(p => p.metadata.version >= pkg.minimumInitialization.metadata.version) ) @@ -37,7 +37,7 @@ object DarResourcesUtil extends NamedLogging { pkgMetadataToDarResource.get((name, version)) def lookupAllPackageVersions(name: PackageName): Seq[DarResource] = - packageResources.view.flatMap(_.all).toSeq.filter(_.metadata.name == name) + corePackageResources.view.flatMap(_.all).toSeq.filter(_.metadata.name == name) // TODO(canton-network/splice#4049): remove `enableUnsupportedDarsUnvetting` once not needed anymore def getRequiredPackageVersions( @@ -61,7 +61,7 @@ object DarResourcesUtil extends NamedLogging { false } } - packageResources.view + corePackageResources.view .flatMap(_.all) .toSeq .filter(_.metadata.name == name) @@ -86,7 +86,7 @@ object DarResourcesUtil extends NamedLogging { packageConfigMap: Map[PackageName, PackageVersion], )(implicit tc: TraceContext): Seq[DarResource] = { val allSupportedVersionsPackageIds = - packageResources + corePackageResources .flatMap { pkg => val versionFromAmuletRules = packageConfigMap.getOrElse( pkg.latest.metadata.name, @@ -107,7 +107,7 @@ object DarResourcesUtil extends NamedLogging { } private def lookupMinimumPackageResource(name: PackageName): DarResource = - packageResources + corePackageResources .find(_.latest.metadata.name == name) .getOrElse(throw new NoSuchElementException(s"Could not find PackageResource for $name.")) .minimumInitialization diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/PackageVetting.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/PackageVetting.scala index 978f396b1f..7936cb0e05 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/PackageVetting.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/PackageVetting.scala @@ -152,7 +152,6 @@ class PackageVetting( .map(_ => ()) } - // See https://github.com/DACH-NY/canton/issues/29834: make it work for non-sv validators as well def unvetPackages( domainId: SynchronizerId, additionalPackagesToUnvet: Map[PackageName, Set[PackageVersion]], diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/SpliceCircuitBreaker.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/SpliceCircuitBreaker.scala index 8ca808a8f8..c6146bdb1e 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/SpliceCircuitBreaker.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/SpliceCircuitBreaker.scala @@ -21,13 +21,15 @@ import com.digitalasset.canton.topology.PartyId import com.digitalasset.canton.tracing.TraceContext import io.grpc.StatusRuntimeException import org.apache.pekko.actor.Scheduler -import org.apache.pekko.pattern.{CircuitBreaker, CircuitBreakerOpenException} +import org.apache.pekko.pattern.CircuitBreaker import org.lfdecentralizedtrust.splice.config.CircuitBreakerConfig import java.util.concurrent.atomic.AtomicReference +import scala.concurrent.duration.FiniteDuration import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success} +@SuppressWarnings(Array("org.wartremover.warts.Null")) class SpliceCircuitBreaker( name: String, config: CircuitBreakerConfig, @@ -40,6 +42,7 @@ class SpliceCircuitBreaker( ) extends NamedLogging { private val lastFailure: AtomicReference[Option[CantonTimestamp]] = new AtomicReference(None) + private val lastException: AtomicReference[Option[Throwable]] = new AtomicReference(None) private val errorCategoriesToIgnore: Set[ErrorCategory] = Set( InvalidIndependentOfSystemState, @@ -53,7 +56,7 @@ class SpliceCircuitBreaker( LockedContracts ) - val underlying = new CircuitBreaker( + private val underlying: CircuitBreaker = new CircuitBreaker( scheduler, maxFailures = config.maxFailures, callTimeout = config.callTimeout.underlying, @@ -63,7 +66,8 @@ class SpliceCircuitBreaker( randomFactor = config.randomFactor, ).onOpen { logger.warn( - s"Circuit breaker $name tripped after ${config.maxFailures} failures" + s"Circuit breaker $name tripped after ${config.maxFailures} failures. Attaching last failure", + lastException.get().orNull, )(TraceContext.empty) }.onHalfOpen { logger.info(s"Circuit breaker $name moving to half-open state")(TraceContext.empty) @@ -76,9 +80,10 @@ class SpliceCircuitBreaker( callAndMark(body) } else { Future.failed( - new CircuitBreakerOpenException( + new SpliceCircuitBreakerOpenException( underlying.resetTimeout, s"Circuit breaker $name is open, calls are failing fast", + lastException.get().orNull, ) ) } @@ -106,8 +111,11 @@ class SpliceCircuitBreaker( if (!isFailureIgnored(exception)) { underlying.fail() lastFailure.set(Some(clock.now)) + lastException.set(Some(exception)) } - case Success(_) => underlying.succeed() + case Success(_) => + underlying.succeed() + lastException.set(None) } } @@ -157,3 +165,9 @@ object SpliceCircuitBreaker { loggerFactory, ) } + +class SpliceCircuitBreakerOpenException( + val remainingDuration: FiniteDuration, + message: String, + cause: Throwable, +) extends RuntimeException(message, cause) diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/SpliceRateLimiter.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/SpliceRateLimiter.scala index 6d6f66019d..c1885094dd 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/SpliceRateLimiter.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/SpliceRateLimiter.scala @@ -3,22 +3,31 @@ package org.lfdecentralizedtrust.splice.util +import com.daml.metrics.CacheMetrics import com.daml.metrics.api.MetricHandle.LabeledMetricsFactory import com.daml.metrics.api.MetricQualification.Saturation import com.daml.metrics.api.{MetricHandle, MetricInfo, MetricsContext} +import com.digitalasset.canton.caching.{CaffeineCache, ConcurrentCache} import com.digitalasset.canton.discard.Implicits.DiscardOps import com.digitalasset.canton.lifecycle.LifeCycle import com.digitalasset.canton.logging.TracedLogger -import com.google.common.util.concurrent.RateLimiter +import com.digitalasset.canton.tracing.TraceContext +import com.github.benmanes.caffeine.cache.{Caffeine, RemovalCause, RemovalListener} +import com.google.common.util.concurrent.{BurstyRateLimiterFactory, RateLimiter} import org.lfdecentralizedtrust.splice.environment.SpliceMetrics -import java.time.Instant +import java.time.Duration import java.util import java.util.Collections +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong import scala.concurrent.Future import scala.jdk.CollectionConverters.CollectionHasAsScala -case class SpliceRateLimitMetrics(otelFactory: LabeledMetricsFactory, logger: TracedLogger)(implicit +case class SpliceRateLimitMetrics( + otelFactory: LabeledMetricsFactory, + private val logger: TracedLogger, +)(implicit mc: MetricsContext ) extends AutoCloseable { @@ -32,6 +41,17 @@ case class SpliceRateLimitMetrics(otelFactory: LabeledMetricsFactory, logger: Tr ) ) + val unknownAttributeNotLimited: MetricHandle.Meter = otelFactory.meter( + MetricInfo( + SpliceMetrics.MetricsPrefix :+ "rate_limiting_unknown_attribute_not_limited", + "Number of requests not rate limited by a per-attribute limiter because the attribute value is unknown", + Saturation, + ) + ) + + def recordUnknownAttributeNotLimited()(implicit extraMc: MetricsContext): Unit = + unknownAttributeNotLimited.mark()(mc.merge(extraMc)) + /*we need to pass the full context when we create it to avoid duplicate values warnings*/ def recordMaxLimit(limit: Double)(implicit extraMc: MetricsContext): Unit = { val createdGauge = otelFactory.gauge[Double]( @@ -53,39 +73,116 @@ case class SpliceRateLimitMetrics(otelFactory: LabeledMetricsFactory, logger: Tr } -case class SpliceRateLimitConfig( +sealed trait SpliceRateLimitConfig { + + def enabled: Boolean + + def ratePerSecond: Double + + def sustainedRatePerSecond: Option[Double] + + def sustainedWindowSeconds: Long +} + +object SpliceRateLimitConfig { + + final case class Simple( + enabled: Boolean = true, + ratePerSecond: Double, + sustainedRatePerSecond: Option[Double] = None, + sustainedWindowSeconds: Long = SpliceRateLimiter.DefaultSustainedWindowSeconds, + ) extends SpliceRateLimitConfig + + final case class WithPerClientIp( + enabled: Boolean = true, + ratePerSecond: Double, + sustainedRatePerSecond: Option[Double] = None, + sustainedWindowSeconds: Long = SpliceRateLimiter.DefaultSustainedWindowSeconds, + perClientIp: PerAttributeRateLimitConfig = PerAttributeRateLimitConfig.Disabled, + ) extends SpliceRateLimitConfig + + def apply( + enabled: Boolean = true, + ratePerSecond: Double, + sustainedRatePerSecond: Option[Double] = None, + sustainedWindowSeconds: Long = SpliceRateLimiter.DefaultSustainedWindowSeconds, + ): Simple = + Simple(enabled, ratePerSecond, sustainedRatePerSecond, sustainedWindowSeconds) +} + +case class PerAttributeRateLimitConfig( enabled: Boolean = true, - ratePerSecond: Double, -) + limit: SpliceRateLimitConfig.Simple = PerAttributeRateLimitConfig.DefaultLimit, + maxAttributeValues: Long = 10000, +) { + + def rateLimitFor(overall: SpliceRateLimitConfig): SpliceRateLimitConfig.Simple = + limit.copy(enabled = enabled && limit.enabled && overall.enabled) +} + +object PerAttributeRateLimitConfig { + val DefaultLimit: SpliceRateLimitConfig.Simple = SpliceRateLimitConfig(ratePerSecond = 10) + val Disabled: PerAttributeRateLimitConfig = PerAttributeRateLimitConfig(enabled = false) +} +object SpliceRateLimiter { + + val GlobalLimiterType = "global" + val PerAttributeLimiterType = "per-attribute" + + val DefaultSustainedWindowSeconds: Long = 60 + + private[util] def sustainedWindow(config: SpliceRateLimitConfig): Duration = + Duration.ofSeconds(Math.max(1L, config.sustainedWindowSeconds)) +} + +// noinspection UnstableApiUsage class SpliceRateLimiter( name: String, config: SpliceRateLimitConfig, metrics: SpliceRateLimitMetrics, - enforceAfter: Instant = Instant.now(), + limiterType: String = SpliceRateLimiter.GlobalLimiterType, + extraLabels: Map[String, String] = Map.empty, + // must be disabled for the per-attribute limiters as they'd all report the same value + // and would explode the number of registered gauges + reportMaxLimit: Boolean = true, ) { - // noinspection UnstableApiUsage - private val limiter = RateLimiter.create(config.ratePerSecond) - // lazy to ensure metrics get registered only if the limiter is actually used - private lazy val rateLimiter = { - metrics - .recordMaxLimit(config.ratePerSecond)( - MetricsContext("limiter" -> name) + private val metricsContext = MetricsContext( + extraLabels ++ Map("limiter" -> name, "limiter_type" -> limiterType) + ) + + // The limiters are created with one second worth of permits already available + private val limiter: Option[RateLimiter] = + Option.when(config.enabled)(BurstyRateLimiterFactory.create(config.ratePerSecond)) + // enforces the sustained limit over the sustained window, while still allowing bursts within its budget. + private val sustainedLimiter: Option[RateLimiter] = + Option + .when(config.enabled)(config.sustainedRatePerSecond) + .flatten + .map( + BurstyRateLimiterFactory + .create(_, SpliceRateLimiter.sustainedWindow(config).toSeconds.toDouble) ) + // lazy to ensure metrics get registered only if the limiter is actually used + private lazy val rateLimiter: Option[RateLimiter] = { + if (reportMaxLimit) { + metrics + .recordMaxLimit(config.ratePerSecond)(metricsContext) + } limiter } def markRun(): Boolean = { - if (config.enabled && Instant.now().isAfter(enforceAfter)) { - val canRun = rateLimiter.tryAcquire() + if (config.enabled) { + val canRun = rateLimiter.forall(_.tryAcquire()) && sustainedLimiter.forall(_.tryAcquire()) if (canRun) { metrics.meter.mark()( - MetricsContext("result" -> "accepted", "limiter" -> name) + metricsContext.merge(MetricsContext("result" -> "accepted")) ) } else { metrics.meter.mark()( - MetricsContext("result" -> "rejected", "limiter" -> name) + metricsContext.merge(MetricsContext("result" -> "rejected")) ) } canRun @@ -105,3 +202,106 @@ class SpliceRateLimiter( } } + +class PerAttributeRateLimiter( + name: String, + attribute: String, + config: SpliceRateLimitConfig, + attributeConfig: PerAttributeRateLimitConfig, + metrics: SpliceRateLimitMetrics, + logger: TracedLogger, +) { + + private val perAttributeConfig = attributeConfig.rateLimitFor(config) + private val isEnabled = perAttributeConfig.enabled && perAttributeConfig.ratePerSecond > 0 + private val attributeLabel = Map("limiter_attribute" -> attribute) + + // evictions by size can happen for every single request (e.g. when a large number of distinct + // attribute values is seen), so the warning is throttled to avoid flooding the logs + private val lastSizeEvictionWarning = + new AtomicLong(System.nanoTime() - PerAttributeRateLimiter.EvictionWarningIntervalNanos) + + private val evictionListener: RemovalListener[String, SpliceRateLimiter] = + (key: String, _: SpliceRateLimiter, cause: RemovalCause) => { + if (cause == RemovalCause.SIZE) { + implicit val tc: TraceContext = TraceContext.empty + val message = + s"Rate limiter cache for $name (attribute '$attribute') exceeded its maximum size of " + + s"${attributeConfig.maxAttributeValues}; evicting the rate limiter for attribute value '$key'. " + + "Its rate limiting state is lost. Consider increasing max-attribute-values." + val now = System.nanoTime() + val last = lastSizeEvictionWarning.get() + if ( + now - last >= PerAttributeRateLimiter.EvictionWarningIntervalNanos && lastSizeEvictionWarning + .compareAndSet(last, now) + ) { + logger.warn(message) + } else { + logger.debug(message) + } + } + } + + // lazy so that neither the cache nor its metrics are created if the limiter is disabled + private lazy val cache: ConcurrentCache[String, SpliceRateLimiter] = CaffeineCache[ + String, + SpliceRateLimiter, + ]( + Caffeine + .newBuilder() + .maximumSize(attributeConfig.maxAttributeValues) + // Evict limiters that have not been used for a full sustained rate limiting window (the bucket + // size of the interval rate limiter): after that time an idle limiter would have refilled its + // budget anyway, so dropping it does not change the enforced rate. + .expireAfterAccess(SpliceRateLimiter.sustainedWindow(perAttributeConfig)) + .evictionListener(evictionListener), + Some(new CacheMetrics(s"$name-$attribute-rate-limiter", metrics.otelFactory)), + ) + + private lazy val reportedMaxLimit: Unit = + metrics.recordMaxLimit(perAttributeConfig.ratePerSecond)( + MetricsContext( + attributeLabel ++ Map( + "limiter" -> name, + "limiter_type" -> SpliceRateLimiter.PerAttributeLimiterType, + ) + ) + ) + + def markRun(attributeValue: Option[String]): Boolean = + if (isEnabled) attributeValue match { + case Some(value) => limiterFor(value).markRun() + case None => + metrics.recordUnknownAttributeNotLimited()( + MetricsContext( + attributeLabel ++ Map( + "limiter" -> name, + "limiter_type" -> SpliceRateLimiter.PerAttributeLimiterType, + ) + ) + ) + true + } + else true + + private def limiterFor(attributeValue: String): SpliceRateLimiter = { + reportedMaxLimit + cache.getOrAcquire( + attributeValue, + (_: String) => + new SpliceRateLimiter( + name, + perAttributeConfig, + metrics, + limiterType = SpliceRateLimiter.PerAttributeLimiterType, + extraLabels = attributeLabel, + reportMaxLimit = false, + ), + ) + } +} + +object PerAttributeRateLimiter { + + private val EvictionWarningIntervalNanos: Long = TimeUnit.MINUTES.toNanos(1) +} diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/TemplateJsonDecoder.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/TemplateJsonDecoder.scala index 19246976ec..71d6e2f8c0 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/TemplateJsonDecoder.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/TemplateJsonDecoder.scala @@ -18,7 +18,7 @@ import com.digitalasset.canton.ledger.api.util.LfEngineToApi import com.digitalasset.canton.logging.{ErrorLoggingContext, NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.util.ErrorUtil -import com.digitalasset.daml.lf.archive.{ArchivePayload, Dar, DarReader} +import com.digitalasset.daml.lf.archive.DarReader import com.digitalasset.daml.lf.data.Ref import com.digitalasset.daml.lf.data.Ref.{PackageId, QualifiedName} import com.digitalasset.daml.lf.typesig @@ -27,6 +27,7 @@ import io.circe.Json import org.lfdecentralizedtrust.splice.environment.DarResource import java.util.zip.ZipInputStream +import scala.util.Using abstract class TemplateJsonDecoder { def decodeTemplate[TCid <: ContractId[T], T]( @@ -148,12 +149,16 @@ object ResourceTemplateDecoder { if (inputStream == null) { throw new IllegalArgumentException("Resource not found: " + path) } - val dar: Dar[ArchivePayload] = DarReader - .readArchive(resource.path, new ZipInputStream(inputStream)) - .valueOr(e => - throw new IllegalArgumentException(s"Failed to read DAR at path ${resource.path}: $e") - ) - dar.all.map(a => a.pkgId -> typesig.reader.SignatureReader.readPackageSignature(a)._2).toMap + Using(new ZipInputStream(inputStream)) { zip => + DarReader + .readArchive(resource.path, zip) + .valueOr(e => + throw new IllegalArgumentException(s"Failed to read DAR at path ${resource.path}: $e") + ) + }.fold( + e => throw e, + _.all.map(a => a.pkgId -> typesig.reader.SignatureReader.readPackageSignature(a)._2).toMap, + ) }, ) } diff --git a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/admin/api/HttpRequestLoggerTest.scala b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/admin/api/HttpRequestLoggerTest.scala index 2037a0cc93..0f8121f2fe 100644 --- a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/admin/api/HttpRequestLoggerTest.scala +++ b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/admin/api/HttpRequestLoggerTest.scala @@ -6,19 +6,22 @@ package org.lfdecentralizedtrust.splice.admin.api import com.digitalasset.canton.config.ApiLoggingConfig import com.digitalasset.canton.logging.SuppressionRule import com.digitalasset.canton.BaseTest -import org.apache.pekko.http.scaladsl.model.StatusCodes +import org.apache.pekko.http.scaladsl.model.{HttpRequest, RemoteAddress, StatusCodes} +import org.apache.pekko.http.scaladsl.model.headers.{RawHeader, `X-Forwarded-For`} import org.apache.pekko.http.scaladsl.server.{RejectionHandler, Route} import org.apache.pekko.http.scaladsl.server.Directives.* import org.apache.pekko.http.scaladsl.testkit.ScalatestRouteTest import org.scalatest.wordspec.AnyWordSpec import org.slf4j.event.Level +import org.lfdecentralizedtrust.splice.config.RateLimitersConfig class HttpRequestLoggerTest extends AnyWordSpec with BaseTest with ScalatestRouteTest { private val apiLoggingConfig = ApiLoggingConfig() - private def loggerDirective = HttpRequestLogger( + private def loggerDirective(clientIpHeaders: Seq[String]) = HttpRequestLogger( apiLoggingConfig, + clientIpHeaders, loggerFactory, ) @@ -28,8 +31,10 @@ class HttpRequestLoggerTest extends AnyWordSpec with BaseTest with ScalatestRout * all outcomes and logs exactly one "Responding with status code" per request — * whether matched or rejected. */ - private def route: Route = - loggerDirective { + private def routeFor( + clientIpHeaders: Seq[String] = RateLimitersConfig.DefaultClientIpHeaders + ): Route = + loggerDirective(clientIpHeaders) { handleRejections(RejectionHandler.default) { concat( pathPrefix("api" / "admin") { @@ -42,8 +47,44 @@ class HttpRequestLoggerTest extends AnyWordSpec with BaseTest with ScalatestRout } } + private lazy val route = routeFor() + + private def assertLoggedClientIp(request: HttpRequest, expectedClientIp: String): Unit = + loggerFactory.assertLogsSeq(SuppressionRule.Level(Level.DEBUG))( + { + request ~> routeFor(Seq("x-envoy-external-address")) ~> check { + status shouldBe StatusCodes.OK + } + }, + logEntries => + forExactly(1, logEntries) { entry => + entry.message should include("received request") + entry.message should include(s"from ($expectedClientIp)") + }, + ) + "HttpRequestLogger" should { + "prefer the configured client IP header" in { + assertLoggedClientIp( + Get("/api/app").withHeaders( + RawHeader("X-Envoy-External-Address", "5.5.5.5"), + RawHeader("X-Forwarded-For", "1.1.1.1"), + ), + "5.5.5.5", + ) + } + + "fall back to the existing client IP extraction" in { + assertLoggedClientIp( + Get("/api/app").withHeaders( + RawHeader("X-Envoy-External-Address", "not-an-ip"), + `X-Forwarded-For`(Seq(RemoteAddress(Array[Byte](2, 2, 2, 2)))), + ), + "2.2.2.2", + ) + } + "log exactly one 'received request' and one response for a matching route" in { loggerFactory.assertLogsSeq(SuppressionRule.Level(Level.DEBUG))( { @@ -84,7 +125,7 @@ class HttpRequestLoggerTest extends AnyWordSpec with BaseTest with ScalatestRout "one log entry per request when methods conflict across siblings" in { val newStyleMethodRoute: Route = - loggerDirective { + loggerDirective(RateLimitersConfig.DefaultClientIpHeaders) { handleRejections(RejectionHandler.default) { concat( pathPrefix("api" / "data") { diff --git a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/automation/SqlIndexInitializationTriggerStoreTest.scala b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/automation/SqlIndexInitializationTriggerStoreTest.scala index 9afeefda60..0fbbcfe077 100644 --- a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/automation/SqlIndexInitializationTriggerStoreTest.scala +++ b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/automation/SqlIndexInitializationTriggerStoreTest.scala @@ -4,6 +4,7 @@ import com.daml.metrics.api.noop.NoOpMetricsFactory import org.lfdecentralizedtrust.splice.config.AutomationConfig import org.lfdecentralizedtrust.splice.environment.RetryProvider import org.lfdecentralizedtrust.splice.store.{StoreErrors, StoreTestBase} +import org.lfdecentralizedtrust.splice.store.db.AdvisoryLocksTestHelper import com.digitalasset.canton.concurrent.{FutureSupervisor, Threading} import com.digitalasset.canton.lifecycle.FutureUnlessShutdown import com.digitalasset.canton.logging.SuppressionRule @@ -13,13 +14,18 @@ import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.util.MonadUtil import com.digitalasset.canton.{FutureHelpers, HasActorSystem, HasExecutionContext} import org.lfdecentralizedtrust.splice.automation.SqlIndexInitializationTrigger.IndexAction -import org.lfdecentralizedtrust.splice.store.db.{AcsJdbcTypes, AcsTables, SplicePostgresTest} +import org.lfdecentralizedtrust.splice.store.db.{ + AcsJdbcTypes, + AcsTables, + AdvisoryLocks, + SplicePostgresTest, +} import org.slf4j.event.Level import slick.dbio.DBIOAction import slick.jdbc.{GetResult, PositionedResult} import slick.jdbc.canton.ActionBasedSQLInterpolation.Implicits.actionBasedSQLInterpolationCanton -import scala.concurrent.Future +import scala.concurrent.{Future, Promise} class SqlIndexInitializationTriggerStoreTest extends StoreTestBase @@ -29,7 +35,15 @@ class SqlIndexInitializationTriggerStoreTest with SplicePostgresTest with AcsJdbcTypes with AcsTables - with FutureHelpers { + with FutureHelpers + with AdvisoryLocksTestHelper { + + private val expectedIndexNames = Seq( + "updt_hist_crea_hi_mi_ci_import_updates", + "updt_hist_tran_hi_eth", + "dso_acs_store_sid_mid_pn_tid_rbio", + "scan_txlog_store_sid_effat_en_vot", + ) "SqlIndexInitializationTrigger" should { @@ -56,12 +70,7 @@ class SqlIndexInitializationTriggerStoreTest indexNames <- listIndexNames() _ <- dumpIndexes() } yield { - indexNames should contain allElementsOf Seq( - "updt_hist_crea_hi_mi_ci_import_updates", - "updt_hist_tran_hi_eth", - "dso_acs_store_sid_mid_pn_tid_rbio", - "scan_txlog_store_sid_effat_en_vot", - ) + indexNames should contain allElementsOf expectedIndexNames indexNames should not contain "scan_txlog_store_sid_en_vot" } } @@ -342,6 +351,35 @@ class SqlIndexInitializationTriggerStoreTest tasksResult.value shouldBe empty } } + + "skip index DDL quietly while another process holds the advisory lock" in { + val trigger = SqlIndexInitializationTrigger( + storage = storage, + triggerContext = triggerContext, + indexActions = List( + IndexAction.Create( + "test_index", + sqlu"create index concurrently if not exists test_index on update_history_creates (record_time)", + ) + ), + ) + val releaseLock = Promise[Unit]() + val (lockAcquired, lockReleased) = + holdLock(AdvisoryLocks.withDdlLock, DBIOAction.unit, releaseLock.future) + + for { + _ <- lockAcquired + _ <- trigger.runOnce() + indexNamesWhileLocked <- listIndexNames() + _ = indexNamesWhileLocked should not contain "test_index" + // The contended action is still pending and succeeds once the lock is released + _ = releaseLock.success(()) + _ <- lockReleased + _ <- runTriggerUntilAllTasksDone(trigger) + indexNamesAfter <- listIndexNames() + } yield indexNamesAfter should contain("test_index") + } + } private def listIndexNames(): Future[Seq[String]] = { @@ -416,6 +454,7 @@ class SqlIndexInitializationTriggerStoreTest loggerFactory, NoOpMetricsFactory, ) + override protected def cleanDb( storage: DbStorage )(implicit traceContext: TraceContext): FutureUnlessShutdown[?] = for { diff --git a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/http/HttpRateLimiterTest.scala b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/http/HttpRateLimiterTest.scala new file mode 100644 index 0000000000..da4306e880 --- /dev/null +++ b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/http/HttpRateLimiterTest.scala @@ -0,0 +1,555 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.http + +import com.daml.metrics.api.testing.InMemoryMetricsFactory +import com.digitalasset.canton.BaseTest +import org.apache.pekko.http.scaladsl.model.headers.{RawHeader, `X-Forwarded-For`, `X-Real-Ip`} +import org.apache.pekko.http.scaladsl.model.{ + AttributeKeys, + HttpRequest, + RemoteAddress, + StatusCode, + StatusCodes, +} +import org.apache.pekko.http.scaladsl.server.Directives.* +import org.apache.pekko.http.scaladsl.server.Route +import org.apache.pekko.http.scaladsl.testkit.ScalatestRouteTest +import org.lfdecentralizedtrust.splice.config.RateLimitersConfig +import org.lfdecentralizedtrust.splice.util.{ + PerAttributeRateLimitConfig, + SpliceRateLimitConfig, + SpliceRateLimiter, +} +import org.scalatest.wordspec.AnyWordSpec + +import java.net.InetAddress + +class HttpRateLimiterTest extends AnyWordSpec with BaseTest with ScalatestRouteTest { + + "clientIp" should { + + "prefer X-Forwarded-For" in { + clientIp( + HttpRequest() + .withHeaders( + `X-Forwarded-For`(RemoteAddress(InetAddress.getByName("1.1.1.1"))), + `X-Real-Ip`(RemoteAddress(InetAddress.getByName("2.2.2.2"))), + ) + .withAttributes( + Map( + AttributeKeys.remoteAddress -> RemoteAddress(InetAddress.getByName("3.3.3.3")) + ) + ) + ) should be(Some("1.1.1.1")) + } + + "fall back to X-Real-Ip" in { + clientIp( + HttpRequest().withHeaders(`X-Real-Ip`(RemoteAddress(InetAddress.getByName("2.2.2.2")))) + ) should be(Some("2.2.2.2")) + } + + "ignore a non-IP value and fall back to the next header" in { + clientIp( + HttpRequest() + .withHeaders( + RawHeader("X-Forwarded-For", "evil.example.com"), + `X-Real-Ip`(RemoteAddress(InetAddress.getByName("2.2.2.2"))), + ) + ) should be(Some("2.2.2.2")) + } + + "use the first address of a comma separated header value" in { + clientIp( + HttpRequest().withHeaders(RawHeader("X-Forwarded-For", "1.1.1.1, 2.2.2.2, 3.3.3.3")) + ) should be(Some("1.1.1.1")) + } + + "use the configured headers in order" in { + val request = HttpRequest().withHeaders( + RawHeader("X-Envoy-External-Address", "4.4.4.4"), + `X-Forwarded-For`(RemoteAddress(InetAddress.getByName("1.1.1.1"))), + `X-Real-Ip`(RemoteAddress(InetAddress.getByName("2.2.2.2"))), + ) + clientIp( + request, + clientIpHeaders = Seq("x-envoy-external-address", "x-forwarded-for"), + ) should be(Some("4.4.4.4")) + clientIp( + request, + clientIpHeaders = Seq("x-real-ip", "x-envoy-external-address"), + ) should be(Some("2.2.2.2")) + } + + "not use headers that are not configured" in { + clientIp( + HttpRequest().withHeaders( + `X-Forwarded-For`(RemoteAddress(InetAddress.getByName("1.1.1.1"))), + `X-Real-Ip`(RemoteAddress(InetAddress.getByName("2.2.2.2"))), + ), + clientIpHeaders = Seq("x-envoy-external-address"), + ) should be(None) + } + + "match the configured headers case-insensitively" in { + clientIp( + HttpRequest().withHeaders(RawHeader("X-Envoy-External-Address", "4.4.4.4")), + clientIpHeaders = Seq("X-Envoy-External-Address"), + ) should be(Some("4.4.4.4")) + } + + "not extract any IP when no headers are configured" in { + clientIp( + HttpRequest().withHeaders( + `X-Forwarded-For`(RemoteAddress(InetAddress.getByName("1.1.1.1"))) + ), + clientIpHeaders = Seq.empty, + ) should be(None) + } + + "not use the remote address of the transport connection" in { + // the remote address is not exposed by the server, so it must not be relied upon + clientIp( + HttpRequest().withAttributes( + Map(AttributeKeys.remoteAddress -> RemoteAddress(InetAddress.getByName("3.3.3.3"))) + ) + ) should be(None) + } + + "return None if no IP can be determined" in { + clientIp(HttpRequest()) should be(None) + clientIp( + HttpRequest().withHeaders(RawHeader("X-Forwarded-For", "not-an-ip")) + ) should be(None) + } + } + + "the client IP used for rate limiting" should { + + "use the full address for IPv4 clients" in { + clientIpOf("1.2.3.4") should be(Some("1.2.3.4")) + } + + "group IPv6 clients by their /64 prefix" in { + // the lower 64 bits (the interface identifier) are freely chosen by the client + clientIpOf("2001:db8:0:1:1:2:3:4") should be(Some("2001:db8:0:1:0:0:0:0/64")) + clientIpOf("2001:db8:0:1:ffff:ffff:ffff:ffff") should be( + clientIpOf("2001:db8:0:1:1:2:3:4") + ) + clientIpOf("2001:db8:0:1::") should be(clientIpOf("2001:db8:0:1:1:2:3:4")) + } + + "not group IPv6 clients of different /64 networks" in { + clientIpOf("2001:db8:0:2:1:2:3:4") should not be clientIpOf("2001:db8:0:1:1:2:3:4") + clientIpOf("2001:db9:0:1:1:2:3:4") should not be clientIpOf("2001:db8:0:1:1:2:3:4") + } + + "reject IPv6 addresses carrying a zone id" in { + // zone ids are only meaningful locally and are not valid in an IP literal of a header + clientIpOf("fe80::1:2:3:4%7") should be(None) + } + + "use the IPv4 address for IPv4-mapped IPv6 clients" in { + // clients behind a dual stack proxy can be reported as ::ffff:a.b.c.d, those must not end up + // in a single /64 bucket shared by all IPv4 clients + clientIpOf("::ffff:1.2.3.4") should be(Some("1.2.3.4")) + clientIpOf("::ffff:1.2.3.4") should be(clientIpOf("1.2.3.4")) + clientIpOf("::ffff:4.3.2.1") should not be clientIpOf("::ffff:1.2.3.4") + } + + "apply the same grouping to all client IP sources" in { + val expected = Some("2001:db8:0:1:0:0:0:0/64") + val address = RemoteAddress(InetAddress.getByName("2001:db8:0:1:1:2:3:4")) + clientIp( + HttpRequest().withHeaders(RawHeader("X-Envoy-External-Address", "2001:db8:0:1:1:2:3:4")), + clientIpHeaders = Seq("x-envoy-external-address"), + ) should be(expected) + clientIp( + HttpRequest().withHeaders(`X-Forwarded-For`(address)) + ) should be(expected) + clientIp( + HttpRequest().withHeaders(`X-Real-Ip`(address)) + ) should be(expected) + } + } + + "the http rate limiter" should { + + "reject requests of a client IP over the global per client IP limit" in { + // the global per client IP limiter is enabled by default + withRoutes( + globalPerClientIp = perClientIp(1) + )("testOperation") { routes => + val route = routes("testOperation") + val results = (1 to 20).map(_ => call(route, ip = Some("1.1.1.1"))) + // 1 request per second per client IP, with 1 permit available from the creation of the + // limiter plus guava's deferred payment for the next one => the rest of the burst is rejected + results.count(_ == StatusCodes.OK) should be(2) + results.count(_ == StatusCodes.TooManyRequests) should be(18) + } + } + + "not reject requests of other client IPs" in { + withRoutes( + globalPerClientIp = perClientIp(1) + )("testOperation") { routes => + val route = routes("testOperation") + (1 to 20) + .map(_ => call(route, ip = Some("1.1.1.1"))) + .count(_ == StatusCodes.TooManyRequests) should be > 0 + call(route, ip = Some("2.2.2.2")) should be(StatusCodes.OK) + } + } + + "limit IPv6 clients of the same /64 network together" in { + withRoutes( + globalPerClientIp = perClientIp(1) + )("testOperation") { routes => + val route = routes("testOperation") + // drain the budget of the /64 network + (1 to 20) + .map(_ => call(route, ip = Some("2001:db8:0:1:1:2:3:4"))) + .count(_ == StatusCodes.OK) should be > 0 + // a different address of the same /64 shares the limiter, so it is rejected + call(route, ip = Some("2001:db8:0:1:ffff:ffff:ffff:ffff")) should be( + StatusCodes.TooManyRequests + ) + // a different /64 is a different client + call(route, ip = Some("2001:db8:0:2:1:2:3:4")) should be(StatusCodes.OK) + } + } + + "not apply the per client IP limiter if no client IP is known" in { + withRoutes( + globalPerClientIp = perClientIp(1) + )("testOperation") { routes => + val route = routes("testOperation") + (1 to 20).map(_ => call(route, ip = None)) should contain only StatusCodes.OK + val results = (1 to 20).map(_ => call(route, ip = Some("1.1.1.1"))) + results.count(_ == StatusCodes.OK) should be(2) + results.count(_ == StatusCodes.TooManyRequests) should be(18) + } + } + + "not apply the per client IP limiters if no client IP headers are configured" in { + withRoutes( + globalPerClientIp = perClientIp(1), + perClientIpOverrides = Map("testOperation" -> perClientIp(1)), + clientIpHeaders = Seq.empty, + )("testOperation") { fixture => + val route = fixture("testOperation") + (1 to 20).map(_ => call(route, ip = Some("1.1.1.1"))) should contain only StatusCodes.OK + forEvery(Seq("testOperation", HttpRateLimiter.GlobalLimiter)) { limiter => + fixture.requestsRejectedBy( + limiter, + SpliceRateLimiter.PerAttributeLimiterType, + ) should be(0L) + } + } + } + + "apply the global per client IP limiter across operations" in { + // the same client IP is limited regardless of the operation + withRoutes( + globalPerClientIp = perClientIp(1) + )("operationA", "operationB") { routes => + (1 to 20) + .map(_ => call(routes("operationA"), ip = Some("1.1.1.1"))) + .count(_ == StatusCodes.OK) should be > 0 + call(routes("operationB"), ip = Some("1.1.1.1")) should be(StatusCodes.TooManyRequests) + } + } + + "apply the global overall limiter across operations" in { + withRoutes( + global = SpliceRateLimitConfig(ratePerSecond = 1), + globalPerClientIp = PerAttributeRateLimitConfig.Disabled, + )("operationA", "operationB") { routes => + // exhaust the global budget via operationA + (1 to 20).map(_ => call(routes("operationA"), ip = Some("1.1.1.1"))) + // the global limiter ignores the operation and the client IP, so operationB is rejected too + call(routes("operationB"), ip = Some("2.2.2.2")) should be(StatusCodes.TooManyRequests) + } + } + + "not apply the per operation client IP limiter by default" in { + // no per client IP limiting configured for operations => requests from a single IP are only + // bounded by the (high) overall limiters + withRoutes()("testOperation") { routes => + val route = routes("testOperation") + (1 to 20).map(_ => call(route, ip = Some("1.1.1.1"))) should contain only StatusCodes.OK + } + } + + "apply the per operation client IP limiter when enabled for an operation" in { + withRoutes( + perClientIpOverrides = Map("limitedOperation" -> perClientIp(1)) + )("limitedOperation", "otherOperation") { routes => + val results = + (1 to 20).map(_ => call(routes("limitedOperation"), ip = Some("1.1.1.1"))) + results.count(_ == StatusCodes.OK) should be(2) + // a different operation is not affected by the per operation client IP limiter + call(routes("otherOperation"), ip = Some("1.1.1.1")) should be(StatusCodes.OK) + } + } + + "apply the per operation overall limiter" in { + withRoutes( + rateLimiters = Map("limitedOperation" -> SpliceRateLimitConfig(ratePerSecond = 1)) + )("limitedOperation", "otherOperation") { routes => + val results = (1 to 20).map(_ => call(routes("limitedOperation"), ip = Some("1.1.1.1"))) + results.count(_ == StatusCodes.TooManyRequests) should be > 0 + // a different operation uses a separate overall limiter and is not affected + call(routes("otherOperation"), ip = Some("1.1.1.1")) should be(StatusCodes.OK) + } + } + + "use separate per operation limiters for equally named operations of different services" in { + val rateLimiter = new HttpRateLimiter( + RateLimitersConfig( + default = withPerClientIp( + SpliceRateLimitConfig(ratePerSecond = 1), + PerAttributeRateLimitConfig.Disabled, + ), + rateLimiters = Map.empty, + global = withPerClientIp( + SpliceRateLimitConfig(ratePerSecond = 1000), + PerAttributeRateLimitConfig.Disabled, + ), + ), + new InMemoryMetricsFactory(), + loggerFactory.getTracedLogger(classOf[HttpRateLimiterTest]), + ) + try { + val routeV1 = + rateLimiter.withRateLimit("serviceV1")("sharedOperation")(complete(StatusCodes.OK)) + val routeV2 = + rateLimiter.withRateLimit("serviceV2")("sharedOperation")(complete(StatusCodes.OK)) + (1 to 20) + .map(_ => call(routeV1, ip = Some("1.1.1.1"))) + .count(_ == StatusCodes.TooManyRequests) should be > 0 + call(routeV2, ip = Some("1.1.1.1")) should be(StatusCodes.OK) + } finally { + rateLimiter.close() + } + } + } + + "the order in which the rate limiters are applied" should { + + // A limiter only records (and thereby only consumes budget for) the requests that actually + // reach it, as the limiters are combined with a short-circuiting `&&`. The tests below send a + // burst of requests that is rejected by one limiter and assert that the limiters which must be + // applied later only saw the requests that were accepted by the rejecting one. + val Burst = 20 + val Rejecting = SpliceRateLimitConfig(ratePerSecond = 1) + // high enough to never reject, so that the recorded requests are exactly the ones that got here + val Downstream = SpliceRateLimitConfig(ratePerSecond = 1000) + val PerAttribute = SpliceRateLimiter.PerAttributeLimiterType + val Overall = SpliceRateLimiter.GlobalLimiterType + + // Sends a burst of requests from a single client IP and returns how many were accepted. + def burst(fixture: HttpRateLimiterTest.Fixture, operation: String): Long = { + val results = (1 to Burst).map(_ => call(fixture(operation), ip = Some("1.1.1.1"))) + results.count(_ == StatusCodes.TooManyRequests) should be > 0 + results.count(_ == StatusCodes.OK).toLong + } + + def onlySawAcceptedRequests( + fixture: HttpRateLimiterTest.Fixture, + accepted: Long, + )(limiters: (String, String)*) = + forEvery(limiters) { case (limiter, limiterType) => + withClue(s"requests seen by the $limiterType limiter '$limiter': ") { + fixture.requestsSeenBy(limiter, limiterType) should be(accepted) + fixture.requestsRejectedBy(limiter, limiterType) should be(0L) + } + } + + "apply the per operation client IP limiter before the overall limiters" in { + withRoutes( + rateLimiters = Map("limitedOperation" -> Downstream), + global = Downstream, + perClientIpOverrides = Map("limitedOperation" -> perClientIp(1)), + )("limitedOperation") { fixture => + val accepted = burst(fixture, "limitedOperation") + onlySawAcceptedRequests(fixture, accepted)( + "limitedOperation" -> Overall, + HttpRateLimiter.GlobalLimiter -> Overall, + ) + } + } + + "apply the global per client IP limiter before the overall limiters" in { + withRoutes( + rateLimiters = Map("limitedOperation" -> Downstream), + global = Downstream, + globalPerClientIp = perClientIp(1), + )("limitedOperation") { fixture => + val accepted = burst(fixture, "limitedOperation") + onlySawAcceptedRequests(fixture, accepted)( + "limitedOperation" -> Overall, + HttpRateLimiter.GlobalLimiter -> Overall, + ) + } + } + + "apply the per operation client IP limiter before the global per client IP limiter" in { + withRoutes( + globalPerClientIp = perClientIp(1000), + perClientIpOverrides = Map("limitedOperation" -> perClientIp(1)), + )("limitedOperation") { fixture => + val accepted = burst(fixture, "limitedOperation") + onlySawAcceptedRequests(fixture, accepted)( + HttpRateLimiter.GlobalLimiter -> PerAttribute + ) + } + } + + "apply the per operation overall limiter before the global overall limiter" in { + withRoutes( + rateLimiters = Map("limitedOperation" -> Rejecting), + global = Downstream, + )("limitedOperation") { fixture => + val accepted = burst(fixture, "limitedOperation") + onlySawAcceptedRequests(fixture, accepted)( + HttpRateLimiter.GlobalLimiter -> Overall + ) + } + } + + "still reject requests that pass the per client IP limiters but exceed an overall limit" in { + withRoutes( + global = SpliceRateLimitConfig(ratePerSecond = 2), + globalPerClientIp = perClientIp(1000), + perClientIpOverrides = Map("testOperation" -> perClientIp(1000)), + )("testOperation") { fixture => + // every request is below both per client IP limits, but the overall global limit applies + val results = (1 to Burst).map(i => call(fixture("testOperation"), ip = Some(s"1.1.1.$i"))) + results.count(_ == StatusCodes.OK) should be < Burst + results.count(_ == StatusCodes.TooManyRequests) should be > 0 + } + } + } + + private def perClientIp(ratePerSecond: Double): PerAttributeRateLimitConfig = + PerAttributeRateLimitConfig(limit = SpliceRateLimitConfig(ratePerSecond = ratePerSecond)) + + private def clientIp( + request: HttpRequest, + clientIpHeaders: Seq[String] = RateLimitersConfig.DefaultClientIpHeaders, + ): Option[String] = { + val route = + HttpRateLimiter.extractClientIpKey(clientIpHeaders) { extracted => + complete(extracted.getOrElse[String](HttpRateLimiterTest.NoClientIp)) + } + request ~> route ~> check { + status should be(StatusCodes.OK) + Some(responseAs[String]).filterNot(_ == HttpRateLimiterTest.NoClientIp) + } + } + + private def clientIpOf(ip: String): Option[String] = + clientIp(HttpRequest().withHeaders(RawHeader("X-Forwarded-For", ip))) + + private def call(route: Route, ip: Option[String]): StatusCode = { + val request = ip match { + case Some(value) => + Get("/") ~> addHeader(`X-Forwarded-For`(RemoteAddress(InetAddress.getByName(value)))) + case None => Get("/") + } + request ~> route ~> check(status) + } + + private def withRoutes[A]( + // high enough by default so that only the explicitly configured limiter kicks in + default: SpliceRateLimitConfig = SpliceRateLimitConfig(ratePerSecond = 1000), + rateLimiters: Map[String, SpliceRateLimitConfig] = Map.empty, + global: SpliceRateLimitConfig = SpliceRateLimitConfig(ratePerSecond = 1000), + globalPerClientIp: PerAttributeRateLimitConfig = PerAttributeRateLimitConfig.Disabled, + perClientIpOverrides: Map[String, PerAttributeRateLimitConfig] = Map.empty, + clientIpHeaders: Seq[String] = RateLimitersConfig.DefaultClientIpHeaders, + )(operations: String*)(f: HttpRateLimiterTest.Fixture => A): A = { + // Any operation with a per client IP override needs its own overall limiter entry so that the + // embedded per client IP limiter is used instead of the `default` one. + val perOperationConfigs: Map[String, SpliceRateLimitConfig.WithPerClientIp] = + (rateLimiters.keySet ++ perClientIpOverrides.keySet).map { operation => + operation -> withPerClientIp( + rateLimiters.getOrElse(operation, default), + perClientIpOverrides.getOrElse(operation, PerAttributeRateLimitConfig.Disabled), + ) + }.toMap + val metricsFactory = new InMemoryMetricsFactory() + val rateLimiter = new HttpRateLimiter( + RateLimitersConfig( + default = withPerClientIp(default, PerAttributeRateLimitConfig.Disabled), + rateLimiters = perOperationConfigs, + global = withPerClientIp(global, globalPerClientIp), + clientIpHeaders = clientIpHeaders, + ), + metricsFactory, + loggerFactory.getTracedLogger(classOf[HttpRateLimiterTest]), + ) + try { + val routes = operations.map { operation => + operation -> rateLimiter.withRateLimit("testService")(operation) { + complete(StatusCodes.OK) + } + }.toMap + f(HttpRateLimiterTest.Fixture(routes, metricsFactory)) + } finally { + rateLimiter.close() + } + } + + private def withPerClientIp( + overall: SpliceRateLimitConfig, + perClientIp: PerAttributeRateLimitConfig, + ): SpliceRateLimitConfig.WithPerClientIp = + SpliceRateLimitConfig.WithPerClientIp( + enabled = overall.enabled, + ratePerSecond = overall.ratePerSecond, + sustainedRatePerSecond = overall.sustainedRatePerSecond, + sustainedWindowSeconds = overall.sustainedWindowSeconds, + perClientIp = perClientIp, + ) +} + +object HttpRateLimiterTest { + private val NoClientIp = "" + + /** The routes of the rate limited operations together with the metrics recorded by their rate + * limiters. Requests are only recorded by the limiters they actually reach, which is what allows + * asserting on the order in which the limiters are applied. + */ + private final case class Fixture( + routes: Map[String, Route], + metricsFactory: InMemoryMetricsFactory, + ) { + + def apply(operation: String): Route = routes(operation) + + /** Number of requests recorded by the given limiter, i.e. that were actually evaluated by it. */ + def requestsSeenBy(limiter: String, limiterType: String): Long = + marks(limiter, limiterType, result = None) + + /** Number of requests the given limiter rejected. */ + def requestsRejectedBy(limiter: String, limiterType: String): Long = + marks(limiter, limiterType, result = Some("rejected")) + + private def marks(limiter: String, limiterType: String, result: Option[String]): Long = + metricsFactory.metrics.meters.values + .flatMap(_.values) + .flatMap(_.markers.toSeq) + .collect { + case (context, value) + if context.labels.get("limiter").contains(limiter) && + context.labels.get("limiter_type").contains(limiterType) && + result.forall(expected => context.labels.get("result").contains(expected)) => + value.get() + } + .sum + } +} diff --git a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/http/InvalidResponseContentTest.scala b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/http/InvalidResponseContentTest.scala new file mode 100644 index 0000000000..2de16000da --- /dev/null +++ b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/http/InvalidResponseContentTest.scala @@ -0,0 +1,89 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.http + +import cats.data.EitherT +import com.digitalasset.canton.{BaseTest, HasActorSystem, HasExecutionContext} +import com.digitalasset.canton.config.NonNegativeDuration +import io.circe.syntax.* +import org.lfdecentralizedtrust.splice.auth.AuthToken +import org.lfdecentralizedtrust.splice.config.AuthTokenSourceConfig +import org.lfdecentralizedtrust.splice.http.v0.definitions.Version +import org.apache.pekko.http.scaladsl.model.* +import org.apache.pekko.stream.Materializer +import org.lfdecentralizedtrust.splice.http.v0.external.common_admin.{ + CommonAdminClient, + GetVersionResponse, + IsReadyResponse, +} +import org.scalatest.compatible.Assertion +import org.scalatest.wordspec.AnyWordSpec + +import java.time.{OffsetDateTime, ZoneOffset} +import scala.concurrent.Future +import scala.concurrent.duration.Duration + +class InvalidResponseContentTest + extends AnyWordSpec + with BaseTest + with HasActorSystem + with HasExecutionContext { + + private implicit val mat: Materializer = Materializer(actorSystem) + + private def runEndpoint[A]( + endpoint: CommonAdminClient => EitherT[Future, Either[Throwable, HttpResponse], A], + resp: ResponseEntity, + )( + expectation: Either[Either[Throwable, HttpResponse], A] => Assertion + ): Assertion = { + implicit val httpClient: HttpClient = new HttpClient { + val requestParameters = HttpClient.HttpRequestParameters(NonNegativeDuration(Duration.Zero)) + def withOverrideParameters(newParameters: HttpClient.HttpRequestParameters): HttpClient = this + def executeRequest(client: String, operation: String)( + request: HttpRequest + ): Future[HttpResponse] = Future.successful(HttpResponse(StatusCodes.OK, entity = resp)) + def getToken(authConfig: AuthTokenSourceConfig): Future[Option[AuthToken]] = + Future.successful(None) + } + + val client = CommonAdminClient.httpClient(HttpClient.createHttpFn("", ""), "http://localhost") + expectation(endpoint(client).value.futureValue) + } + + "CommonAdminClient.getVersion" should { + "include the response body in the error when the content type is invalid" in { + val testBody = "test" + runEndpoint(_.getVersion(), HttpEntity(ContentTypes.`text/html(UTF-8)`, testBody)) { + case Left(Left(err)) => err.getMessage should endWith(testBody) + case other => fail(s"expected Left(Left(throwable)), got: $other") + } + } + + "decode a well-formed application/json response" in { + runEndpoint( + _.getVersion(), + HttpEntity( + ContentTypes.`application/json`, + Version( + "1.2.3", + OffsetDateTime.of(2026, 7, 23, 0, 0, 0, 0, ZoneOffset.UTC), + ).asJson.noSpaces, + ), + ) { + case Right(_: GetVersionResponse.OK) => succeed + case other => fail(s"expected GetVersionResponse.OK, got: $other") + } + } + } + + "CommonAdminClient.isReady" should { + "handle an empty response with no content type" in { + runEndpoint(_.isReady(), HttpEntity.Empty) { + case Right(IsReadyResponse.OK) => succeed + case other => fail(s"expected IsReadyResponse.OK, got: $other") + } + } + } +} diff --git a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/store/DbUnavailablePartiesStoreTest.scala b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/store/DbUnavailablePartiesStoreTest.scala new file mode 100644 index 0000000000..3d01deb390 --- /dev/null +++ b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/store/DbUnavailablePartiesStoreTest.scala @@ -0,0 +1,330 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.store + +import com.digitalasset.canton.HasExecutionContext +import com.digitalasset.canton.config.NonNegativeFiniteDuration +import com.digitalasset.canton.lifecycle.FutureUnlessShutdown +import com.digitalasset.canton.resource.DbStorage +import com.digitalasset.canton.tracing.TraceContext +import com.digitalasset.canton.util.MonadUtil +import org.lfdecentralizedtrust.splice.store.db.{ + DbUnavailablePartiesStore, + SplicePostgresTest, + StoreDescriptor, +} +import org.scalatest.matchers.should.Matchers + +import scala.concurrent.duration.* + +class DbUnavailablePartiesStoreTest + extends StoreTestBase + with Matchers + with HasExecutionContext + with SplicePostgresTest { + + private val storeDescriptor = StoreDescriptor( + version = 1, + name = "DbUnavailablePartiesStoreTest", + party = dsoParty, + participant = mkParticipantId("participant"), + key = Map(), + ) + + private val storeDescriptor2 = storeDescriptor.copy(version = 2) + + private val baseDuration = NonNegativeFiniteDuration(1.second) + private val maxIgnoreDuration = NonNegativeFiniteDuration(4.seconds) + private val oneSecond = 1_000_000L + + private def atSeconds(seconds: Long): Long = seconds * oneSecond + + private def mkStore(descriptor: StoreDescriptor = storeDescriptor) = + DbUnavailablePartiesStore( + descriptor, + storage, + baseDuration, + maxIgnoreDuration, + loggerFactory, + ) + + "DbUnavailablePartiesStore" should { + + "addParties" should { + + "be a no-op for an empty sequence" in { + for { + store <- mkStore() + _ <- store.addParties(Seq.empty, atSeconds(0)) + parties <- store.listParties(atSeconds(0)) + } yield parties shouldBe empty + } + + "ignores new parties for the base duration" in { + for { + store <- mkStore() + _ <- store.addParties(Seq(userParty(1), userParty(2)), atSeconds(0)) + justBefore <- store.listParties(atSeconds(1) - 1) + atExpiry <- store.listParties(atSeconds(1)) + } yield { + justBefore should contain theSameElementsAs Seq(userParty(1), userParty(2)) + atExpiry shouldBe empty + } + } + + "double the ignore duration when a party is re-added later" in { + for { + store <- mkStore() + _ <- store.addParties(Seq(userParty(1)), atSeconds(0)) // expires at 1s + _ <- store.addParties( + Seq(userParty(1)), + atSeconds(1), + ) // should bump ignore duration to 2s => expires at 3s + justBefore <- store.listParties(atSeconds(3) - 1) + atExpiry <- store.listParties(atSeconds(3)) + } yield { + justBefore should contain(userParty(1)) + atExpiry shouldBe empty + } + } + + "not double the ignore duration when a party is re-added within its window" in { + val midWindow = atSeconds(1) / 2 + for { + store <- mkStore() + _ <- store.addParties(Seq(userParty(1)), atSeconds(0)) // expires at 1s + _ <- store.addParties(Seq(userParty(1)), midWindow) // already ignored => no-op + justBefore <- store.listParties(atSeconds(1) - 1) + atExpiry <- store.listParties(atSeconds(1)) + } yield { + justBefore should contain(userParty(1)) + atExpiry shouldBe empty + } + } + + "cap the ignore duration at maxIgnoreDuration" in { + for { + store <- mkStore() + // 0s => 1s, 1s => 2s, 3s => 4s, 7s => 4s (capped), 11s => 4s (capped) => expires at 15s + _ <- MonadUtil.sequentialTraverse(Seq(0L, 1L, 3L, 7L, 11L))(n => + store.addParties(Seq(userParty(1)), atSeconds(n)) + ) + justBefore <- store.listParties(atSeconds(15) - 1) + atExpiry <- store.listParties(atSeconds(15)) + } yield { + justBefore should contain(userParty(1)) + atExpiry shouldBe empty + } + } + + "is idempotent when replayed with the same timestamp" in { + for { + store <- mkStore() + // DbStorage.update may retry the statement, which must not double the duration: + // the expiry must stay at 1s, not move to 2s + _ <- store.addParties(Seq(userParty(1)), atSeconds(0)) + _ <- store.addParties(Seq(userParty(1)), atSeconds(0)) + justBefore <- store.listParties(atSeconds(1) - 1) + atExpiry <- store.listParties(atSeconds(1)) + } yield { + justBefore should contain(userParty(1)) + atExpiry shouldBe empty + } + } + + "restart the expiry window from the latest marking" in { + for { + store <- mkStore() + _ <- store.addParties(Seq(userParty(1)), atSeconds(0)) // 1s => expires at 1s + _ <- store.addParties(Seq(userParty(1)), atSeconds(2)) // 2s from 2s => expires at 4s + atOldExpiry <- store.listParties(atSeconds(1)) + justBefore <- store.listParties(atSeconds(4) - 1) + atExpiry <- store.listParties(atSeconds(4)) + } yield { + atOldExpiry should contain(userParty(1)) + justBefore should contain(userParty(1)) + atExpiry shouldBe empty + } + } + + "deduplicate parties within a single call" in { + for { + store <- mkStore() + _ <- store.addParties(Seq(userParty(1), userParty(1)), atSeconds(0)) + justBefore <- store.listParties(atSeconds(1) - 1) + atExpiry <- store.listParties(atSeconds(1)) + } yield { + justBefore should contain(userParty(1)) + atExpiry shouldBe empty + } + } + + "apply the insert and the doubling branch independently within one call" in { + for { + store <- mkStore() + _ <- store.addParties(Seq(userParty(1)), atSeconds(0)) // expires at 1s + // party 1's window has just elapsed => doubles to 2s => expires at 3s + // party 2 is new => base duration => expires at 2s + _ <- store.addParties(Seq(userParty(1), userParty(2)), atSeconds(1)) + beforeParty2Expiry <- store.listParties(atSeconds(2) - 1) + atParty2Expiry <- store.listParties(atSeconds(2)) + atParty1Expiry <- store.listParties(atSeconds(3)) + } yield { + beforeParty2Expiry should contain theSameElementsAs Seq(userParty(1), userParty(2)) + atParty2Expiry should contain theSameElementsAs Seq(userParty(1)) + atParty1Expiry shouldBe empty + } + } + + "keep the later expiry when a marking arrives out of order" in { + for { + store <- mkStore() + _ <- store.addParties(Seq(userParty(1)), atSeconds(2)) // 1s => expires at 3s + // had the stale marking landed, updated_at would rewind to 0s and the duration + // would double to 2s, expiring at 2s instead + _ <- store.addParties(Seq(userParty(1)), atSeconds(0)) + atRewoundExpiry <- store.listParties(atSeconds(2)) + atExpiry <- store.listParties(atSeconds(3)) + } yield { + atRewoundExpiry should contain(userParty(1)) + atExpiry shouldBe empty + } + } + + } + + "removeParties" should { + + "remove only the given parties" in { + for { + store <- mkStore() + _ <- store.addParties(Seq(userParty(1), userParty(2), userParty(3)), atSeconds(0)) + deleted <- store.removeParties(Seq(userParty(1), userParty(3))) + parties <- store.listParties(atSeconds(0)) + } yield { + deleted shouldBe 2 + parties should contain theSameElementsAs Seq(userParty(2)) + } + } + + "be a no-op for unknown parties" in { + for { + store <- mkStore() + _ <- store.addParties(Seq(userParty(1)), atSeconds(0)) + deleted <- store.removeParties(Seq(userParty(99))) + parties <- store.listParties(atSeconds(0)) + } yield { + deleted shouldBe 0 + parties should contain(userParty(1)) + } + } + + "be a no-op for an empty sequence" in { + for { + store <- mkStore() + _ <- store.addParties(Seq(userParty(1)), atSeconds(0)) + deleted <- store.removeParties(Seq.empty) + parties <- store.listParties(atSeconds(0)) + } yield { + deleted shouldBe 0 + parties should contain(userParty(1)) + } + } + + "reset the backoff, so a re-added party starts from the base duration again" in { + for { + store <- mkStore() + _ <- store.addParties(Seq(userParty(1)), atSeconds(0)) // 1s => expires at 1s + _ <- store.addParties(Seq(userParty(1)), atSeconds(1)) // 2s => expires at 3s + deleted <- store.removeParties(Seq(userParty(1))) + afterRemoval <- store.listParties(atSeconds(1)) + _ <- store.addParties(Seq(userParty(1)), atSeconds(3)) + justBefore <- store.listParties(atSeconds(4) - 1) + atExpiry <- store.listParties(atSeconds(4)) + } yield { + deleted shouldBe 1 + afterRemoval shouldBe empty + justBefore should contain(userParty(1)) + atExpiry shouldBe empty + } + } + + "remove parties regardless of which store recorded them" in { + for { + store1 <- mkStore(storeDescriptor) + store2 <- mkStore(storeDescriptor2) + _ <- store1.addParties(Seq(userParty(1)), atSeconds(0)) + deleted <- store2.removeParties(Seq(userParty(1))) + parties <- store1.listParties(atSeconds(0)) + } yield { + deleted shouldBe 1 + parties shouldBe empty + } + } + } + + "removePartiesUpToStoreId" should { + + "remove every party recorded at or below the given store id" in { + for { + store <- mkStore() + _ <- store.addParties(Seq(userParty(1), userParty(2)), atSeconds(0)) + deleted <- store.removePartiesUpToStoreId(store.storeId.toLong) + parties <- store.listParties(atSeconds(0)) + } yield { + deleted shouldBe 2 + parties shouldBe empty + } + } + + "leave parties recorded by a later store id in place" in { + for { + store1 <- mkStore(storeDescriptor) + store2 <- mkStore(storeDescriptor2) + _ = store1.storeId should be < store2.storeId + _ <- store1.addParties(Seq(userParty(1)), atSeconds(0)) + _ <- store2.addParties(Seq(userParty(2)), atSeconds(0)) + deleted <- store1.removePartiesUpToStoreId(store1.storeId.toLong) + remaining <- store2.listParties(atSeconds(0)) + } yield { + deleted shouldBe 1 + remaining should contain theSameElementsAs Seq(userParty(2)) + } + } + } + + "listParties" should { + + "only return entries whose ignore window has not elapsed" in { + for { + store <- mkStore() + _ <- store.addParties(Seq(userParty(1)), atSeconds(0)) // expires at 1s + _ <- store.addParties(Seq(userParty(2)), atSeconds(1)) // expires at 2s + parties <- store.listParties(atSeconds(1)) + } yield { + parties should contain theSameElementsAs Seq(userParty(2)) + } + } + + "return entries recorded by any store" in { + for { + store1 <- mkStore(storeDescriptor) + store2 <- mkStore(storeDescriptor2) + _ <- store1.addParties(Seq(userParty(1)), atSeconds(0)) + _ <- store2.addParties(Seq(userParty(2)), atSeconds(0)) + parties1 <- store1.listParties(atSeconds(0)) + parties2 <- store2.listParties(atSeconds(0)) + } yield { + parties1 should contain theSameElementsAs Seq(userParty(1), userParty(2)) + parties2 should contain theSameElementsAs Seq(userParty(1), userParty(2)) + } + } + } + } + + override protected def cleanDb( + storage: DbStorage + )(implicit traceContext: TraceContext): FutureUnlessShutdown[?] = + resetAllAppTables(storage) +} diff --git a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/store/UpdateHistoryTest.scala b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/store/UpdateHistoryTest.scala index 7e8cf83157..112279eb34 100644 --- a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/store/UpdateHistoryTest.scala +++ b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/store/UpdateHistoryTest.scala @@ -424,7 +424,10 @@ class UpdateHistoryTest extends UpdateHistoryTestBase { store .getAllUpdates( after.map { case (migrationId, recordTime) => - (migrationId, CantonTimestamp.assertFromInstant(recordTime)) + TimestampWithMigrationId( + CantonTimestamp.assertFromInstant(recordTime), + migrationId, + ) }, PageLimit.tryCreate(1), ) diff --git a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/store/db/AdvisoryLocksTest.scala b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/store/db/AdvisoryLocksTest.scala new file mode 100644 index 0000000000..e97c0d9485 --- /dev/null +++ b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/store/db/AdvisoryLocksTest.scala @@ -0,0 +1,124 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.store.db + +import com.digitalasset.canton.lifecycle.FutureUnlessShutdown +import com.digitalasset.canton.resource.DbStorage +import com.digitalasset.canton.store.db.DbTest +import com.digitalasset.canton.tracing.TraceContext +import com.digitalasset.canton.HasExecutionContext +import org.lfdecentralizedtrust.splice.store.StoreTestBase +import slick.dbio.{DBIOAction, Effect, NoStream} +import slick.jdbc.canton.ActionBasedSQLInterpolation.Implicits.actionBasedSQLInterpolationCanton + +import scala.concurrent.{Future, Promise} + +trait AdvisoryLocksTestHelper { _: DbTest with StoreTestBase with HasExecutionContext => + + /** Acquires a lock by calling [[withLock]] and holds it until `release` completes. Returns a + * future that completes once the lock is held, and a future that completes once the lock has + * been released. + */ + final def holdLock( + withLock: DBIOAction[Unit, NoStream, Effect.All] => DBIOAction[Unit, NoStream, Effect.All], + action: DBIOAction[Unit, NoStream, Effect.All], + release: Future[Unit], + ): (Future[Unit], Future[Unit]) = { + val acquired = Promise[Unit]() + val released = storage.underlying + .queryAndUpdate( + withLock(action.map(_ => acquired.success(())).flatMap(_ => DBIOAction.from(release))), + "hold lock", + ) + .failOnShutdown + released.failed.foreach(acquired.tryFailure) + (acquired.future, released) + } +} + +class AdvisoryLocksTest + extends StoreTestBase + with HasExecutionContext + with SplicePostgresTest + with AcsJdbcTypes + with AcsTables + with AdvisoryLocksTestHelper { + + private val testTable = "a" + + private def commonTests( + lockType: String, + lockId: Long, + withLock: ( + Long, + DBIOAction[Unit, NoStream, Effect.All], + ) => DBIOAction[Unit, NoStream, Effect.All], + ) = { + "release the lock after running an action" in { + for { + _ <- storage.underlying + .queryAndUpdate(withLock(lockId, DBIOAction.unit), "test lock") + .failOnShutdown + lockIsFree <- lockIsFree(lockId) + } yield lockIsFree shouldBe true + } + + "release the lock after an action fails" in { + for { + failure <- storage.underlying + .queryAndUpdate( + withLock(lockId, DBIOAction.failed(new RuntimeException("error"))), + "test lock", + ) + .failOnShutdown + .failed + _ = failure shouldBe a[RuntimeException] + lockIsFree <- lockIsFree(lockId) + } yield lockIsFree shouldBe true + } + + "fail fast while another session holds the lock" in { + val releaseLock = Promise[Unit]() + val createTable = sqlu"create table #$testTable (id int)".map(_ => ()) + val (lockAcquired, lockReleased) = + holdLock(withLock(lockId, _), createTable, releaseLock.future) + for { + _ <- lockAcquired + failure <- storage.underlying + .queryAndUpdate(withLock(lockId, createTable), "contended query") + .failOnShutdown + .failed + _ = releaseLock.success(()) + _ <- lockReleased + } yield failure shouldBe AdvisoryLocks.FailedToAcquireLockException(lockType, lockId) + } + } + + "AdvisoryLocks.withSessionLock" should commonTests( + "session-scoped", + AdvisoryLockIds.ddlStatement, + AdvisoryLocks.withSessionLock, + ) + + "AdvisoryLocks.withTransactionalLock" should commonTests( + "transactional", + AdvisoryLockIds.acsSnapshotDataInsert, + AdvisoryLocks.withTransactionalLock(profile, _, _), + ) + + /** Whether [[lockId]] can be acquired, i.e. nothing is holding it. Session-scoped and + * transactional locks share one lock space, so a session-scoped check also detects a + * detects a transactional holder. + */ + private def lockIsFree(lockId: Long): Future[Boolean] = + storage.underlying + .query(AdvisoryLocks.withSessionLock(lockId, DBIOAction.successful(true)), "check lock") + .failOnShutdown + .recover { case _: AdvisoryLocks.FailedToAcquireLockException => false } + + override protected def cleanDb( + storage: DbStorage + )(implicit traceContext: TraceContext): FutureUnlessShutdown[?] = + storage.update(sqlu"drop table if exists #$testTable", s"drop $testTable") +} diff --git a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/store/db/SpliceDbTest.scala b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/store/db/SpliceDbTest.scala index 0a1d07bac9..42c6268f59 100644 --- a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/store/db/SpliceDbTest.scala +++ b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/store/db/SpliceDbTest.scala @@ -102,7 +102,8 @@ trait SpliceDbTest extends DbTest with BeforeAndAfterAll { this: Suite => key_value_store, acs_incremental_snapshot_data_next, acs_incremental_snapshot_data_backfill, - acs_incremental_snapshot + acs_incremental_snapshot, + dso_unavailable_parties RESTART IDENTITY CASCADE""".asUpdate _ <- debugPrintPgActivity() } yield (), diff --git a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/util/SpliceCircuitBreakerTest.scala b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/util/SpliceCircuitBreakerTest.scala index 55abc8ad50..6956ebd7c8 100644 --- a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/util/SpliceCircuitBreakerTest.scala +++ b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/util/SpliceCircuitBreakerTest.scala @@ -9,7 +9,6 @@ import com.digitalasset.canton.time.SimClock import com.digitalasset.canton.topology.PartyId import io.grpc.StatusRuntimeException import org.apache.pekko.actor.Scheduler -import org.apache.pekko.pattern.CircuitBreakerOpenException import org.lfdecentralizedtrust.splice.config.CircuitBreakerConfig import org.scalatest.concurrent.ScalaFutures import org.scalatest.time.{Millis, Seconds, Span} @@ -70,11 +69,86 @@ class SpliceCircuitBreakerTest val future3 = cb.withCircuitBreaker(Future.successful("should not reach here")) whenReady(future3.failed) { ex => - ex shouldBe a[CircuitBreakerOpenException] + ex shouldBe a[SpliceCircuitBreakerOpenException] cb.isOpen shouldBe true } } + "attach the last failure as the cause of the CircuitBreakerOpenException" in { + val cb = createCircuitBreaker() + + val lastFailure = new RuntimeException("root cause failure") + + val future1 = cb.withCircuitBreaker(Future.failed(new RuntimeException("test failure 1"))) + whenReady(future1.failed) { ex => + ex shouldBe a[RuntimeException] + cb.isClosed shouldBe true + } + loggerFactory.suppressWarnings { + val future2 = cb.withCircuitBreaker(Future.failed(lastFailure)) + whenReady(future2.failed) { ex => + ex shouldBe a[RuntimeException] + cb.isOpen shouldBe true + } + } + + val future3 = cb.withCircuitBreaker(Future.successful("should not reach here")) + whenReady(future3.failed) { ex => + ex shouldBe a[SpliceCircuitBreakerOpenException] + ex.getCause shouldBe lastFailure + ex.getCause.getMessage shouldBe "root cause failure" + } + + eventually() { + cb.isHalfOpen shouldBe true + } + + val newFailure = new RuntimeException("new root cause failure") + loggerFactory.suppressWarnings { + val future4 = cb.withCircuitBreaker(Future.failed(newFailure)) + whenReady(future4.failed) { ex => + ex shouldBe newFailure + cb.isOpen shouldBe true + } + } + val future5 = cb.withCircuitBreaker(Future.successful("should not reach here")) + whenReady(future5.failed) { ex => + ex shouldBe a[SpliceCircuitBreakerOpenException] + ex.getCause shouldBe newFailure + ex.getCause.getMessage shouldBe "new root cause failure" + } + + eventually() { + cb.isHalfOpen shouldBe true + } + val successFuture = cb.withCircuitBreaker(Future.successful("success")) + whenReady(successFuture) { result => + result shouldBe "success" + cb.isClosed shouldBe true + } + + val thirdFailure = new RuntimeException("third root cause failure") + val future6 = cb.withCircuitBreaker(Future.failed(new RuntimeException("test failure 3"))) + whenReady(future6.failed) { ex => + ex shouldBe a[RuntimeException] + cb.isClosed shouldBe true + } + loggerFactory.suppressWarnings { + val future7 = cb.withCircuitBreaker(Future.failed(thirdFailure)) + whenReady(future7.failed) { ex => + ex shouldBe thirdFailure + cb.isOpen shouldBe true + } + } + + val future8 = cb.withCircuitBreaker(Future.successful("should not reach here")) + whenReady(future8.failed) { ex => + ex shouldBe a[SpliceCircuitBreakerOpenException] + ex.getCause shouldBe thirdFailure + ex.getCause.getMessage shouldBe "third root cause failure" + } + } + "not open after failures with ignored error categories" in { val cb = createCircuitBreaker() @@ -159,7 +233,7 @@ class SpliceCircuitBreakerTest val future4 = cb.withCircuitBreaker(Future.successful("should not reach here")) whenReady(future4.failed) { ex => - ex shouldBe a[CircuitBreakerOpenException] + ex shouldBe a[SpliceCircuitBreakerOpenException] cb.isOpen shouldBe true } } diff --git a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/util/SpliceRateLimiterTest.scala b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/util/SpliceRateLimiterTest.scala index f89db4a51c..d673e1ff0e 100644 --- a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/util/SpliceRateLimiterTest.scala +++ b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/util/SpliceRateLimiterTest.scala @@ -1,3 +1,6 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + package org.lfdecentralizedtrust.splice.util import com.daml.metrics.api.MetricsContext @@ -26,7 +29,7 @@ class SpliceRateLimiterTest "accept requests under limit" in { val elementsToRun = 100 - withRateLimiter { case (rateLimitMetrics, rateLimiter) => + withRateLimiter() { case (rateLimitMetrics, rateLimiter) => runThroughRateLimiter(rateLimiter, 9, elementsToRun).reduce(_ && _) shouldBe true rateLimitMetrics.meter.valueFilteredOnLabels( @@ -43,7 +46,7 @@ class SpliceRateLimiterTest } "reject requests that are over the limit" in { - withRateLimiter { case (rateLimitMetrics, rateLimiter) => + withRateLimiter() { case (rateLimitMetrics, rateLimiter) => val results = runThroughRateLimiter(rateLimiter, 100, 1000) val (accepted, rejected) = results.partition(identity) @@ -76,6 +79,163 @@ class SpliceRateLimiterTest } + "start with the configured rate per second worth of permits" in { + withRateLimiter(SpliceRateLimitConfig(ratePerSecond = 10)) { case (_, rateLimiter) => + // the limiter must not have to warm up first: it holds its configured rate worth of permits + // (10) right from its creation, plus guava's deferred payment for the next one + val results = Seq.fill(50)(rateLimiter.markRun()) + results.take(11) should contain only true + results.count(!_) should be > 35 + } + } + + "not create any limiter if disabled" in { + // a disabled limiter must not fail even for a rate that guava would reject + withRateLimiter(SpliceRateLimitConfig(enabled = false, ratePerSecond = 0)) { + case (_, rateLimiter) => + Seq.fill(100)(rateLimiter.markRun()) should contain only true + } + } + + } + + "the per attribute rate limiter" should { + + "gate the per attribute limit on the overall limiter being enabled" in { + PerAttributeRateLimitConfig(limit = SpliceRateLimitConfig(ratePerSecond = 42)) + .rateLimitFor(SpliceRateLimitConfig(ratePerSecond = 100)) + .ratePerSecond should be(42d) + + PerAttributeRateLimitConfig(limit = SpliceRateLimitConfig(ratePerSecond = 42)) + .rateLimitFor(SpliceRateLimitConfig(enabled = false, ratePerSecond = 100)) + .enabled should be(false) + + PerAttributeRateLimitConfig( + enabled = false, + limit = SpliceRateLimitConfig(ratePerSecond = 42), + ) + .rateLimitFor(SpliceRateLimitConfig(ratePerSecond = 100)) + .enabled should be(false) + + PerAttributeRateLimitConfig.Disabled + .rateLimitFor(SpliceRateLimitConfig(ratePerSecond = 100)) + .enabled should be(false) + } + + "limit each attribute value separately" in { + withPerAttributeRateLimiter( + SpliceRateLimitConfig(ratePerSecond = 10), + PerAttributeRateLimitConfig(limit = SpliceRateLimitConfig(ratePerSecond = 1)), + ) { case (_, perAttributeRateLimiter) => + val ip1 = Seq.fill(20)(perAttributeRateLimiter.markRun(Some("1.1.1.1"))) + ip1.count(identity) should be(2) + ip1.count(!_) should be(18) + + // a different attribute value is not affected by the limiter of the first one + perAttributeRateLimiter.markRun(Some("2.2.2.2")) should be(true) + perAttributeRateLimiter.markRun(Some("2.2.2.2")) should be(true) + perAttributeRateLimiter.markRun(Some("2.2.2.2")) should be(false) + } + } + + "not limit requests with an unknown attribute value" in { + withPerAttributeRateLimiter( + SpliceRateLimitConfig(ratePerSecond = 10), + PerAttributeRateLimitConfig(limit = SpliceRateLimitConfig(ratePerSecond = 1)), + ) { case (metrics, perAttributeRateLimiter) => + // requests without an attribute value are not rate limited here; the overall/global + // rate limiter is relied upon to bound them instead + val results = Seq.fill(20)(perAttributeRateLimiter.markRun(None)) + results.count(identity) should be(20) + + metrics.unknownAttributeNotLimited.valueFilteredOnLabels( + LabelFilter("limiter", "test"), + LabelFilter("limiter_attribute", "test_attribute"), + LabelFilter("limiter_type", SpliceRateLimiter.PerAttributeLimiterType), + ) should be(20) + + metrics.meter.valuesWithContext.keys + .flatMap(_.labels.get("limiter_attribute")) + .toSeq should be(empty) + + // requests with a known attribute value are still limited + perAttributeRateLimiter.markRun(Some("1.1.1.1")) should be(true) + } + } + + "distinguish the metrics of the per attribute limiters" in { + withPerAttributeRateLimiter( + SpliceRateLimitConfig(ratePerSecond = 10), + PerAttributeRateLimitConfig(limit = SpliceRateLimitConfig(ratePerSecond = 1)), + ) { case (metrics, perAttributeRateLimiter) => + val results = Seq.fill(20)(perAttributeRateLimiter.markRun(Some("1.1.1.1"))) + + metrics.meter.valueFilteredOnLabels( + LabelFilter("limiter", "test"), + LabelFilter("limiter_attribute", "test_attribute"), + LabelFilter("limiter_type", SpliceRateLimiter.PerAttributeLimiterType), + LabelFilter("result", "accepted"), + ) should be(results.count(identity)) + metrics.meter.valueFilteredOnLabels( + LabelFilter("limiter", "test"), + LabelFilter("limiter_attribute", "test_attribute"), + LabelFilter("limiter_type", SpliceRateLimiter.PerAttributeLimiterType), + LabelFilter("result", "rejected"), + ) should be(results.count(!_)) + // only per attribute limiters report metrics + metrics.meter.valuesWithContext.keys + .flatMap(_.labels.get("limiter_type")) + .toSet should be(Set(SpliceRateLimiter.PerAttributeLimiterType)) + } + } + + "not limit anything if disabled" in { + withPerAttributeRateLimiter( + SpliceRateLimitConfig(ratePerSecond = 10), + PerAttributeRateLimitConfig.Disabled, + ) { case (_, perAttributeRateLimiter) => + Seq.fill(100)(perAttributeRateLimiter.markRun(Some("1.1.1.1"))) should contain only true + Seq.fill(100)(perAttributeRateLimiter.markRun(None)) should contain only true + } + } + + "respect the configured rate over time" in { + withPerAttributeRateLimiter( + SpliceRateLimitConfig(ratePerSecond = 100), + PerAttributeRateLimitConfig(limit = SpliceRateLimitConfig(ratePerSecond = 10)), + ) { case (_, perAttributeRateLimiter) => + // 10 per second per attribute value + val results = runRateLimited(50, 100) { + if (perAttributeRateLimiter.markRun(Some("1.1.1.1"))) Future.successful(true) + else + Future.failed( + io.grpc.Status.RESOURCE_EXHAUSTED + .withDescription("Rate limit exceeded") + .asRuntimeException() + ) + }.futureValue + // roughly 2 seconds of runtime at 10 permits per second, with some slack + results.count(identity) should (be >= 5 and be <= 40) + results.count(!_) should be > 0 + } + } + + } + + "the rate limiter with a sustained limit" should { + + "throttle to the sustained rate once the burst budget is drained" in { + withRateLimiter( + SpliceRateLimitConfig(ratePerSecond = 1000, sustainedRatePerSecond = Some(10)) + ) { case (_, rateLimiter) => + val results = runRateLimited(40, 120) { + rateLimiter.runWithLimit(Future.successful(true)) + }.futureValue + // ~3 seconds of runtime at 10 permits/s, with generous slack + results.count(identity) should (be >= 10 and be <= 60) + results.count(!_) should be > 0 + } + } } private def runThroughRateLimiter( @@ -92,13 +252,36 @@ class SpliceRateLimiterTest } futureValue } - private def withRateLimiter[A](f: (SpliceRateLimitMetrics, SpliceRateLimiter) => A): A = { + private def withRateLimiter[A]( + config: SpliceRateLimitConfig = SpliceRateLimitConfig(enabled = true, ratePerSecond = 10) + )(f: (SpliceRateLimitMetrics, SpliceRateLimiter) => A): A = { val metricsFactory = new InMemoryMetricsFactory() val rateLimitMetrics = SpliceRateLimitMetrics(metricsFactory, logger)(MetricsContext.Empty) val rateLimiter = new SpliceRateLimiter( "test", - SpliceRateLimitConfig(enabled = true, 10), + config, + rateLimitMetrics, + ) + try { + f(rateLimitMetrics, rateLimiter) + } finally { + rateLimitMetrics.close() + } + } + + private def withPerAttributeRateLimiter[A]( + config: SpliceRateLimitConfig, + attributeConfig: PerAttributeRateLimitConfig, + )(f: (SpliceRateLimitMetrics, PerAttributeRateLimiter) => A): A = { + val metricsFactory = new InMemoryMetricsFactory() + val rateLimitMetrics = SpliceRateLimitMetrics(metricsFactory, logger)(MetricsContext.Empty) + val rateLimiter = new PerAttributeRateLimiter( + "test", + "test_attribute", + config, + attributeConfig, rateLimitMetrics, + logger, ) try { f(rateLimitMetrics, rateLimiter) diff --git a/apps/dar-resources-generator/src/main/scala/org/lfdecentralizedtrust/splice/darutils/DarResourcesGenerator.scala b/apps/dar-resources-generator/src/main/scala/org/lfdecentralizedtrust/splice/darutils/DarResourcesGenerator.scala index a24ef7f556..cd749f5548 100644 --- a/apps/dar-resources-generator/src/main/scala/org/lfdecentralizedtrust/splice/darutils/DarResourcesGenerator.scala +++ b/apps/dar-resources-generator/src/main/scala/org/lfdecentralizedtrust/splice/darutils/DarResourcesGenerator.scala @@ -59,6 +59,16 @@ object DarResourcesGenerator { "splice-validator-lifecycle", "splice-api-reward-assignment-v1", ) + private val topLevelPackageOrderWithoutSplitwell: Seq[String] = Seq( + "splice-amulet", + "splice-dso-governance", + "splice-util-batched-markers", + "splice-wallet", + "splice-amulet-name-service", + "splice-wallet-payments", + "splice-validator-lifecycle", + "splice-api-reward-assignment-v1", + ) private val tokenStandardProductionPackageOrder: Seq[String] = Seq( "splice-api-token-metadata-v1", "splice-api-token-holding-v1", @@ -147,14 +157,15 @@ object DarResourcesGenerator { indent(2, renderPackage(name, dars, grouped)) } ++ renderPackageResources() ++ + renderCorePackageResources() ++ Seq( """| lazy val pkgIdToDarResource: Map[String, DarResource] = - | packageResources.view.flatMap(_.all).map(resource => resource.packageId -> resource).toMap + | corePackageResources.view.flatMap(_.all).map(resource => resource.packageId -> resource).toMap | | // We don't index the map by PackageMetadata because that type contains some additional | // fields that don't matter. | lazy val pkgMetadataToDarResource: Map[(PackageName, PackageVersion), DarResource] = - | packageResources.view + | corePackageResources.view | .flatMap(_.all) | .map(resource => (resource.metadata.name, resource.metadata.version) -> resource) | .toMap @@ -177,6 +188,19 @@ object DarResourcesGenerator { "", ) + private def renderCorePackageResources(): Seq[String] = + Seq( + " lazy val corePackageResources: Seq[PackageResource] =", + " TokenStandard.allPackageResources ++ Seq(", + ) ++ + topLevelPackageOrderWithoutSplitwell.sorted.map(name => + s" DarResources.${camel(name)}," + ) ++ + Seq( + " )", + "", + ) + private def renderTokenStandard(grouped: Map[String, Seq[DarEntry]]): Seq[String] = { val production = tokenStandardProductionPackageOrder.flatMap(name => renderPackage(name, grouped.getOrElse(name, Nil), grouped) diff --git a/apps/metrics-docs/src/main/scala/org/lfdecentralizedtrust/splice/metrics/MetricsDocs.scala b/apps/metrics-docs/src/main/scala/org/lfdecentralizedtrust/splice/metrics/MetricsDocs.scala index b3c708f35c..68e96ebc2b 100644 --- a/apps/metrics-docs/src/main/scala/org/lfdecentralizedtrust/splice/metrics/MetricsDocs.scala +++ b/apps/metrics-docs/src/main/scala/org/lfdecentralizedtrust/splice/metrics/MetricsDocs.scala @@ -22,6 +22,7 @@ import org.lfdecentralizedtrust.splice.sv.automation.{ AmuletPriceMetricsTrigger, ReportSvStatusMetricsExportTrigger, RewardMetricsTrigger, + VoteRequestMetricsTrigger, } import org.lfdecentralizedtrust.splice.sv.store.db.DbSvDsoStoreMetrics import org.lfdecentralizedtrust.splice.store.{HistoryMetrics, StoreMetrics} @@ -31,7 +32,7 @@ import org.lfdecentralizedtrust.splice.sv.automation.confirmation.{ } import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.ProcessRewardsTriggerBase import org.lfdecentralizedtrust.splice.validator.metrics.TopologyMetrics -import org.lfdecentralizedtrust.splice.wallet.metrics.AmuletMetrics +import org.lfdecentralizedtrust.splice.wallet.metrics.{AmuletMetrics, TreasuryMetrics} final case class GeneratedMetrics( common: List[MetricDoc.Item], @@ -95,6 +96,7 @@ object MetricsDocs { generator.reset() // validator new AmuletMetrics(walletUserParty, generator) + new TreasuryMetrics(walletUserParty, generator, () => 0L) val topologyMetrics = new TopologyMetrics(generator) // force creation of a gauge for a dummy participant val _ = topologyMetrics.getNumPartiesPerParticipantGauge( @@ -112,6 +114,7 @@ object MetricsDocs { ) new AmuletPriceMetricsTrigger.AmuletPriceMetrics(generator) new RewardMetricsTrigger.RewardMetrics(generator) + new VoteRequestMetricsTrigger.VoteRequestMetrics(generator) new ProcessRewardsTriggerBase.ProcessRewardsMetrics(generator, true) new CalculateRewardsTriggerBase.CalculateRewardsMetrics(generator, true) new SummarizingMiningRoundTrigger.SummarizingMiningRoundMetrics(generator) diff --git a/apps/package-lock.json b/apps/package-lock.json index afa157dbfe..0493fa7e55 100644 --- a/apps/package-lock.json +++ b/apps/package-lock.json @@ -11763,9 +11763,9 @@ "license": "MIT" }, "node_modules/react-router": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", - "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", diff --git a/apps/scan/src/main/openapi/scan.yaml b/apps/scan/src/main/openapi/scan.yaml index ea914649c6..efbc94a9f4 100644 --- a/apps/scan/src/main/openapi/scan.yaml +++ b/apps/scan/src/main/openapi/scan.yaml @@ -638,10 +638,14 @@ paths: /v1/state/acs: post: + deprecated: true tags: [ external, scan ] x-jvm-package: scan operationId: "getAcsSnapshotAtV1" description: | + Deprecated. Please use /v2/state/acs instead. + The only difference with this endpoint and that one is the type of the `after`/`next_page_token` pagination token. + Returns the ACS in creation date ascending order, paged, for a given migration id and record time. Unlike /v0/state/acs, every contract is identified by an (optional) update_id (as opposed to the event ID in /v0/state/acs, which was not BFT-safe). @@ -667,6 +671,37 @@ paths: "500": $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/500" + /v2/state/acs: + post: + tags: [ external, scan ] + x-jvm-package: scan + operationId: "getAcsSnapshotAtV2" + description: | + Returns the ACS in creation date ascending order, paged, for a given migration id and record time. + Unlike /v0/state/acs, every contract is identified by an (optional) update_id + (as opposed to the event ID in /v0/state/acs, which was not BFT-safe). + The update_id is the ID of the update in which the contract was created, and can be used to correlate with updates returned by /v2/updates. + For contracts created in an earlier migration ID, the update_id will be absent. + requestBody: + required: true + content: + application/json: + schema: + "$ref": "#/components/schemas/AcsRequestV2" + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "#/components/schemas/AcsResponseV2" + "400": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/400" + "404": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/404" + "500": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/500" + /v0/state/acs/force: post: tags: [external, scan] @@ -719,10 +754,14 @@ paths: /v1/holdings/state: post: + deprecated: true tags: [external, scan] x-jvm-package: scan operationId: "getHoldingsStateAtV1" description: | + Deprecated. Please use /v2/holdings/state instead. + The only difference with this endpoint and that one is the type of the `after`/`next_page_token` pagination token. + Returns the active amulet contracts for a given migration id and record time, in creation date ascending order, paged. requestBody: required: true @@ -744,6 +783,33 @@ paths: "500": $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/500" + /v2/holdings/state: + post: + tags: [external, scan] + x-jvm-package: scan + operationId: "getHoldingsStateAtV2" + description: | + Returns the active amulet contracts for a given migration id and record time, in creation date ascending order, paged. + requestBody: + required: true + content: + application/json: + schema: + "$ref": "#/components/schemas/HoldingsStateRequestV2" + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "#/components/schemas/AcsResponseV2" + "400": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/400" + "404": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/404" + "500": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/500" + /v0/holdings/summary: post: deprecated: true @@ -1296,6 +1362,26 @@ paths: schema: "$ref": "../../../../common/src/main/openapi/common-internal.yaml#/components/schemas/ListDsoRulesVoteResultsResponse" + /v0/admin/sv/voteresults/count: + post: + tags: [internal, scan] + x-jvm-package: scan + operationId: "countVoteRequestResults" + description: Count all vote results matching the request filters. + requestBody: + required: true + content: + application/json: + schema: + "$ref": "../../../../common/src/main/openapi/common-internal.yaml#/components/schemas/CountVoteResultsRequest" + responses: + "200": + description: ok + content: + application/json: + schema: + "$ref": "../../../../common/src/main/openapi/common-internal.yaml#/components/schemas/CountVoteResultsResponse" + /v0/admin/sv/previous-sv-reward-weight: post: tags: [internal, scan] @@ -1428,36 +1514,6 @@ paths: "404": $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/404" - /v0/transactions: - post: - deprecated: true - tags: [deprecated] - x-jvm-package: scan - operationId: "listTransactionHistory" - description: | - **Deprecated with known bugs that will not be fixed, use /v2/updates instead**. - - Lists transactions, by default in ascending order, paged, from ledger begin or optionally starting after a provided event id. - requestBody: - required: true - content: - application/json: - schema: - "$ref": "#/components/schemas/TransactionHistoryRequest" - responses: - "200": - description: ok - content: - application/json: - schema: - $ref: "#/components/schemas/TransactionHistoryResponse" - "400": - $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/400" - "404": - $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/404" - "500": - $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/500" - /v0/updates: post: deprecated: true @@ -1790,6 +1846,30 @@ paths: "501": $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/501" + /v0/history/bulk/checksums: + post: + tags: [ internal, pre-alpha, scan ] + x-jvm-package: scan + operationId: "getBulkObjectChecksums" + description: | + **Under Development, do not use in production yet** Get checksums for bulk history objects. Searches for object_keys in both staging and committed objects. + Meant for internal use only, as part of the processing pipeline of bulk history objects. + requestBody: + required: true + content: + application/json: + schema: + "$ref": "#/components/schemas/GetBulkObjectChecksumsRequest" + responses: + "200": + description: ok + content: + application/json: + schema: + "$ref": "#/components/schemas/GetBulkObjectChecksumsResponse" + "501": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/501" + components: schemas: @@ -2185,97 +2265,6 @@ components: svName: description: The sequencer's operating SV name. type: string - TransactionHistoryRequest: - type: object - required: - - page_size - properties: - page_end_event_id: - type: string - description: | - Note that all transactions carry some monotonically-increasing event_id. - Omit this page_end_event_id to start reading the first page, from the beginning or the end of the ledger, depending on the sort_order column. - A subsequent request can fill the page_end_event_id with the last event_id of the TransactionHistoryResponse to continue reading in the same sort_order. - The transaction with event_id == page_end_event_id will be skipped in the next response, making it possible to continuously read pages in the same sort_order. - sort_order: - description: | - Sort order for the transactions. For ascending order, from beginning to the end of the ledger, use "asc". - For descending order, from end to beginning of the ledger, use "desc". - "asc" is used if the sort_order is omitted. - type: string - enum: - - "asc" - - "desc" - page_size: - description: | - The maximum number of transactions returned for this request. - type: integer - format: int64 - TransactionHistoryResponse: - type: object - required: - - transactions - properties: - transactions: - type: array - items: - $ref: "#/components/schemas/TransactionHistoryResponseItem" - TransactionHistoryResponseItem: - type: object - required: - - transaction_type - - event_id - - date - - domain_id - properties: - transaction_type: - description: | - Describes the type of activity that occurred. - Determines if the data for the transaction should be read - from the `transfer`, `mint`, or `tap` property. - type: string - enum: - - "transfer" - - "mint" - - "devnet_tap" - - "abort_transfer_instruction" - event_id: - description: | - The event id. - type: string - offset: - description: | - The ledger offset of the event. - Note that this field may not be the same across nodes, and therefore should not be compared between SVs. - type: string - date: - description: | - The effective date of the event. - type: string - format: date-time - domain_id: - description: | - The id of the domain through which this transaction was sequenced. - type: string - round: - description: | - The round for which this transaction was registered. - type: integer - format: int64 - transfer: - description: | - A (batch) transfer from sender to receivers. - $ref: "#/components/schemas/Transfer" - mint: - description: | - The DSO mints amulet for the cases where the DSO rules allow for that. - $ref: "#/components/schemas/AmuletAmount" - tap: - description: | - A tap creates a Amulet, only used for development purposes, and enabled only on DevNet. - $ref: "#/components/schemas/AmuletAmount" - abort_transfer_instruction: - $ref: "#/components/schemas/AbortTransferInstruction" UpdateHistoryRequestAfter: type: object required: @@ -2908,6 +2897,60 @@ components: type: string description: | Filters the ACS by contracts with these template IDs, specified as "PACKAGE_NAME:MODULE_NAME:ENTITY_NAME". + AcsRequestV2: + type: object + required: + - migration_id + - record_time + - page_size + properties: + migration_id: + type: integer + format: int64 + description: | + The migration id for which to return the ACS. + record_time: + type: string + format: date-time + description: | + The timestamp at which the contract set was active. + This needs to be an exact timestamp, i.e., + needs to correspond to a timestamp reported by `/v0/state/acs/snapshot-timestamp` if `record_time_match` is set to `exact` (which is the default). + If `record_time_match` is set to `at_or_before`, this can be any timestamp, and the most recent snapshot at or before the given `record_time` will be returned. + record_time_match: + type: string + description: | + How to match the record_time. "exact" requires the record_time to match exactly. + "at_or_before" finds the most recent snapshot at or before the given record_time. + enum: + - "exact" + - "at_or_before" + default: "exact" + after: + type: string + description: | + Pagination token for the next page of results. For this to be valid, + this must be the `next_page_token` from a prior request with identical + parameters aside from `after` and `page_size`; the response may be + invalid otherwise. + This token is opaque and not meant to be edited by users. + page_size: + description: | + The maximum number of created events returned for this request. + type: integer + format: int32 + party_ids: + type: array + items: + type: string + description: | + Filters the ACS by contracts in which these party IDs are stakeholders. + templates: + type: array + items: + type: string + description: | + Filters the ACS by contracts with these template IDs, specified as "PACKAGE_NAME:MODULE_NAME:ENTITY_NAME". HoldingsStateRequest: # subset of AcsRequest type: object required: @@ -2956,6 +2999,54 @@ components: description: | Filters by contracts in which these party_ids are the owners of the amulets. + HoldingsStateRequestV2: # subset of AcsRequestV2 + type: object + required: + - migration_id + - record_time + - page_size + - owner_party_ids + properties: + migration_id: + type: integer + format: int64 + description: | + The migration id for which to return the ACS. + record_time: + type: string + format: date-time + description: | + The timestamp at which the contract set was active. + This needs to be an exact timestamp, i.e., + needs to correspond to a timestamp reported by `/v0/state/acs/snapshot-timestamp` if `record_time_match` is set to `exact` (which is the default). + If `record_time_match` is set to `at_or_before`, this can be any timestamp, and the most recent snapshot at or before the given `record_time` will be returned. + record_time_match: + type: string + description: | + How to match the record_time. "exact" requires the record_time to match exactly. + "at_or_before" finds the most recent snapshot at or before the given record_time. + enum: + - "exact" + - "at_or_before" + default: "exact" + after: + type: string + description: | + Pagination token for the next page of results. + This token is opaque and not meant to be edited by users. + page_size: + description: | + The maximum number of created events returned for this request. + type: integer + format: int32 + owner_party_ids: + type: array + items: + type: string + minItems: 1 + description: | + Filters by contracts in which these party_ids are the owners of the amulets. + HoldingsSummaryRequest: type: object required: @@ -3110,6 +3201,36 @@ components: to the `AcsRequest` or `HoldingsStateRequest`. Will be absent when there are no more pages. + AcsResponseV2: + type: object + required: + - record_time + - migration_id + - created_events + properties: + record_time: + description: The same `record_time` as in the request. + type: string + format: date-time + migration_id: + description: The same `migration_id` as in the request. + type: integer + format: int64 + created_events: + description: | + Up to `page_size` contracts in the ACS. + `create_arguments` are always encoded as `compact_json`. + type: array + items: + $ref: "#/components/schemas/ActiveContract" + next_page_token: + type: string + description: | + When requesting the next page of results, pass this as `after` + to the `AcsRequestV2` or `HoldingsStateRequestV2`. + Will be absent when there are no more pages. + This token is opaque and not meant to be edited by users. + HoldingsSummaryResponse: type: object required: @@ -4194,6 +4315,33 @@ components: to the next `ListBulkUpdateHistoryObjectsRequest` invocation. Will be absent when there are no more pages. + GetBulkObjectChecksumsRequest: + type: object + required: + - object_keys + properties: + object_keys: + description: | + The list of keys of the bulk storage objects for which checksums are requested. + type: array + items: + type: string + + GetBulkObjectChecksumsResponse: + type: object + required: + - checksums + properties: + checksums: + description: | + The list of checksums for the requested bulk storage objects (in the same order as the object_keys). + type: array + items: + type: object + properties: + value: + type: string + BulkStorageObjectRef: type: object required: diff --git a/apps/scan/src/main/protobuf/scan_tx_log.proto b/apps/scan/src/main/protobuf/scan_tx_log.proto index 167d0b1361..34b3215631 100644 --- a/apps/scan/src/main/protobuf/scan_tx_log.proto +++ b/apps/scan/src/main/protobuf/scan_tx_log.proto @@ -9,13 +9,6 @@ import "google/protobuf/timestamp.proto"; import "google/protobuf/struct.proto"; import "scalapb/scalapb.proto"; -message PartyBalanceChange { - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string change_to_initial_amount_as_of_round_zero = 1 [(scalapb.field).type = "scala.math.BigDecimal"]; - string change_to_holding_fees_rate = 2 [(scalapb.field).type = "scala.math.BigDecimal"]; -} - message SteppedRate { option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; @@ -30,38 +23,6 @@ message SteppedRate { repeated Step steps = 2; } -message SenderAmount { - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string party = 1 [(scalapb.field).type = "com.digitalasset.canton.topology.PartyId"]; - string input_amulet_amount = 2 [(scalapb.field).type = "scala.math.BigDecimal"]; - string input_app_reward_amount = 3 [(scalapb.field).type = "scala.math.BigDecimal"]; - string input_validator_reward_amount = 4 [(scalapb.field).type = "scala.math.BigDecimal"]; - string sender_change_amount = 5 [(scalapb.field).type = "scala.math.BigDecimal"]; - string sender_change_fee = 6 [(scalapb.field).type = "scala.math.BigDecimal"]; - string sender_fee = 7 [(scalapb.field).type = "scala.math.BigDecimal"]; - string holding_fees = 8 [(scalapb.field).type = "scala.math.BigDecimal"]; - // Added after initial release, so needs to be mapped to an Option in scala - string input_sv_reward_amount = 9 [(scalapb.field).type = "Option[scala.math.BigDecimal]"]; - string input_validator_faucet_amount = 10 [(scalapb.field).type = "Option[scala.math.BigDecimal]"]; -} - -message ReceiverAmount { - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string party = 1 [(scalapb.field).type = "com.digitalasset.canton.topology.PartyId"]; - string amount = 2 [(scalapb.field).type = "scala.math.BigDecimal"]; - string receiver_fee = 3 [(scalapb.field).type = "scala.math.BigDecimal"]; -} - -message BalanceChange { - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string party = 1 [(scalapb.field).type = "com.digitalasset.canton.topology.PartyId"]; - string change_to_initial_amount_as_of_round_zero = 2 [(scalapb.field).type = "scala.math.BigDecimal"]; - string change_to_holding_fees_rate = 3 [(scalapb.field).type = "scala.math.BigDecimal"]; -} - message ErrorTxLogEntry { option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry"; option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; @@ -69,30 +30,6 @@ message ErrorTxLogEntry { string event_id = 1; } -message BalanceChangeTxLogEntry { - option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry"; - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string event_id = 1; - string domain_id = 2 [(scalapb.field).type = "com.digitalasset.canton.topology.SynchronizerId"]; - int64 round = 3; - string change_to_initial_amount_as_of_round_zero = 4 [(scalapb.field).type = "scala.math.BigDecimal"]; - string change_to_holding_fees_rate = 5 [(scalapb.field).type = "scala.math.BigDecimal"]; - map party_balance_changes = 6 [(scalapb.field).key_type = "com.digitalasset.canton.topology.PartyId"];; -} - -message ExtraTrafficPurchaseTxLogEntry { - option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry"; - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string event_id = 1; - string domain_id = 2 [(scalapb.field).type = "com.digitalasset.canton.topology.SynchronizerId"]; - int64 round = 3; - string validator = 4 [(scalapb.field).type = "com.digitalasset.canton.topology.PartyId"]; - int64 traffic_purchased = 5; - string cc_spent = 6 [(scalapb.field).type = "scala.math.BigDecimal"]; -} - message OpenMiningRoundTxLogEntry { option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry"; option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; @@ -116,98 +53,6 @@ message ClosedMiningRoundTxLogEntry { google.protobuf.Timestamp effective_at = 4 [(scalapb.field).type = "java.time.Instant"]; } -enum TransferKind { - TRANSFER_KIND_OTHER = 0; - TRANSFER_KIND_CREATE_TRANSFER_INSTRUCTION = 1; - TRANSFER_KIND_TRANSFER_INSTRUCTION_ACCEPT = 2; - TRANSFER_KIND_PREAPPROVAL_SEND = 3; -} - -message TransferTxLogEntry { - option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry.TransactionTxLogEntry"; - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string event_id = 1; - string offset = 2; - string domain_id = 3 [(scalapb.field).type = "com.digitalasset.canton.topology.SynchronizerId"]; - google.protobuf.Timestamp date = 4 [(scalapb.field).type = "java.time.Instant"]; - string provider = 5; // unused: reserved fields don't work well with the json en/decoding so we keep it here. - SenderAmount sender = 6; - repeated ReceiverAmount receivers = 7; - repeated BalanceChange balance_changes = 8; - int64 round = 9; - string amulet_price = 10; // Unused but our decoding infrastructure doesn't like reserved fields. - - string description = 11; - - string transfer_instruction_receiver = 12; - string transfer_instruction_amount = 13 [(scalapb.field).type = "Option[scala.math.BigDecimal]"]; - string transfer_instruction_cid = 14; - - TransferKind transfer_kind = 15; -} - -message TapTxLogEntry { - option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry.TransactionTxLogEntry"; - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string event_id = 1; - string offset = 2; - string domain_id = 3 [(scalapb.field).type = "com.digitalasset.canton.topology.SynchronizerId"]; - google.protobuf.Timestamp date = 4 [(scalapb.field).type = "java.time.Instant"]; - string amulet_owner = 5 [(scalapb.field).type = "com.digitalasset.canton.topology.PartyId"]; - string amulet_amount = 6 [(scalapb.field).type = "scala.math.BigDecimal"]; - int64 round = 7; - string amulet_price = 8; // Unused but our decoding infrastructure doesn't like reserved fields. -} - -message MintTxLogEntry { - option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry.TransactionTxLogEntry"; - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string event_id = 1; - string offset = 2; - string domain_id = 3 [(scalapb.field).type = "com.digitalasset.canton.topology.SynchronizerId"]; - google.protobuf.Timestamp date = 4 [(scalapb.field).type = "java.time.Instant"]; - string amulet_owner = 5 [(scalapb.field).type = "com.digitalasset.canton.topology.PartyId"]; - string amulet_amount = 6 [(scalapb.field).type = "scala.math.BigDecimal"]; - int64 round = 7; - string amulet_price = 8; // Unused but our decoding infrastructure doesn't like reserved fields. -} - -message SvRewardTxLogEntry { - option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry.RewardTxLogEntry"; - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string event_id = 1; - string domain_id = 2 [(scalapb.field).type = "com.digitalasset.canton.topology.SynchronizerId"]; - int64 round = 3; - string party = 4 [(scalapb.field).type = "com.digitalasset.canton.topology.PartyId"]; - string amount = 5 [(scalapb.field).type = "scala.math.BigDecimal"]; -} - -message AppRewardTxLogEntry { - option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry.RewardTxLogEntry"; - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string event_id = 1; - string domain_id = 2 [(scalapb.field).type = "com.digitalasset.canton.topology.SynchronizerId"]; - int64 round = 3; - string party = 4 [(scalapb.field).type = "com.digitalasset.canton.topology.PartyId"]; - string amount = 5 [(scalapb.field).type = "scala.math.BigDecimal"]; -} - -message ValidatorRewardTxLogEntry { - option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry.RewardTxLogEntry"; - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string event_id = 1; - string domain_id = 2 [(scalapb.field).type = "com.digitalasset.canton.topology.SynchronizerId"]; - int64 round = 3; - string party = 4 [(scalapb.field).type = "com.digitalasset.canton.topology.PartyId"]; - string amount = 5 [(scalapb.field).type = "scala.math.BigDecimal"]; -} - message VoteRequestTxLogEntry { option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry"; option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; @@ -253,17 +98,3 @@ enum TransferAbortKind { TRANSFER_ABORT_KIND_WITHDRAW = 1; TRANSFER_ABORT_KIND_REJECT = 2; } - -message AbortTransferInstructionTxLogEntry { - option (scalapb.message).extends = "org.lfdecentralizedtrust.splice.scan.store.TxLogEntry.TransactionTxLogEntry"; - option (scalapb.message).companion_extends = "org.lfdecentralizedtrust.splice.store.TxLogStore.TxLogEntryTypeMappers"; - - string event_id = 1; - string offset = 2; - string domain_id = 3 [(scalapb.field).type = "com.digitalasset.canton.topology.SynchronizerId"]; - google.protobuf.Timestamp date = 4 [(scalapb.field).type = "java.time.Instant"]; - - string transfer_instruction_cid = 14; - - TransferAbortKind transfer_abort_kind = 15; -} diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/ScanApp.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/ScanApp.scala index 5cfe864f60..b07eb2f3b6 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/ScanApp.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/ScanApp.scala @@ -144,16 +144,12 @@ class ScanApp( nodeMetrics.grpcClientMetrics, retryProvider, ), - if (config.enableAppActivityRecordAndTrafficIngestion) { - Some( - new SequencerTrafficClient( - syncConfig.sequencer, - retryProvider, - nodeMetrics.grpcClientMetrics, - loggerFactory, - ) - ) - } else None, + new SequencerTrafficClient( + syncConfig.sequencer, + retryProvider, + nodeMetrics.grpcClientMetrics, + loggerFactory, + ), ) override def initialize( @@ -258,45 +254,43 @@ class ScanApp( ) kvStore <- ScanKeyValueStore(dsoParty, participantId, storage, loggerFactory) kvProvider = new ScanKeyValueProvider(kvStore, loggerFactory) - bulkStorage = (config.bulkStorage.staging, config.bulkStorage.committed).tupled.map(_ => - BulkStorage( - scanStorageConfigV1, - config.bulkStorage, - acsSnapshotStore, - updateHistory, - currentMigrationId = domainMigrationId, - kvProvider, - retryProvider.metricsFactory, - config.automation, - backoffClock = new WallClock(retryProvider.timeouts, loggerFactory), - retryProvider, - loggerFactory, - ) - ) - // Conditionally create traffic summary ingestion dependencies - appActivityRecordStoreO = - if (config.enableAppActivityRecordAndTrafficIngestion) { - Some( - new DbAppActivityRecordStore( - storage, - updateHistory, - DbAppActivityRecordStore.IngestionVersions( - AppActivityComputation.ActivityIngestionCodeVersion, - config.activityIngestionUserVersion.fold(0)(_.toInt), - ), - config.isFirstSv, - loggerFactory, - ) + bulkStorage <- (config.bulkStorage.staging, config.bulkStorage.committed).tupled.traverse(_ => + appInitStep("Initialize bulk storage") { + BulkStorage( + scanStorageConfigV1, + config.bulkStorage, + acsSnapshotStore, + updateHistory, + currentMigrationId = domainMigrationId, + kvProvider, + retryProvider.metricsFactory, + config.automation, + backoffClock = new WallClock(retryProvider.timeouts, loggerFactory), + store, + svName, + ledgerClient, + amuletAppParameters.upgradesConfig, + retryProvider, + loggerFactory, ) - } else None - appRewardsStoreO = appActivityRecordStoreO.map(appActivityRecordStore => - new DbScanAppRewardsStore( - storage, - updateHistory, - appActivityRecordStore, - config.rewardMintingAllowanceTolerance, - loggerFactory, - ) + } + ) + appActivityRecordStore = new DbAppActivityRecordStore( + storage, + updateHistory, + DbAppActivityRecordStore.IngestionVersions( + AppActivityComputation.ActivityIngestionCodeVersion, + config.activityIngestionUserVersion.fold(0)(_.toInt), + ), + config.isFirstSv, + loggerFactory, + ) + appRewardsStore = new DbScanAppRewardsStore( + storage, + updateHistory, + appActivityRecordStore, + config.rewardMintingAllowanceTolerance, + loggerFactory, ) synchronizerId <- retryProvider.getValueWithRetries( @@ -319,8 +313,8 @@ class ScanApp( loggerFactory, store, updateHistory, - appRewardsStoreO, - appActivityRecordStoreO, + appRewardsStore, + appActivityRecordStore, storage, acsSnapshotStore, serviceUserPrimaryParty, @@ -331,7 +325,7 @@ class ScanApp( scanVerdictStore = DbScanVerdictStore( storage, updateHistory, - appActivityRecordStoreO, + appActivityRecordStore, loggerFactory, )(ec) scanEventStore = new ScanEventStore( @@ -360,25 +354,24 @@ class ScanApp( dsoParty, config.spliceInstanceNames.nameServiceNameAcronym.toLowerCase(), ) - rewardsReferenceStoreO = - if (config.enableAppActivityRecordAndTrafficIngestion) { - val rewardsStore = ScanRewardsReferenceStore( - key = ScanRewardsReferenceStore.Key( - dsoParty = dsoParty, - synchronizerId = synchronizerId, - ), - storage, - loggerFactory, - retryProvider, - domainMigrationId, - participantId, - config.automation.ingestion, - config.parameters.defaultLimit, - ) - automation.registerRewardsReferenceStoreIngestion(rewardsStore) - automation.registerRewardComputationTrigger(rewardsStore) - Some(rewardsStore) - } else None + rewardsReferenceStore = { + val rewardsStore = ScanRewardsReferenceStore( + key = ScanRewardsReferenceStore.Key( + dsoParty = dsoParty, + synchronizerId = synchronizerId, + ), + storage, + loggerFactory, + retryProvider, + domainMigrationId, + participantId, + config.automation.ingestion, + config.parameters.defaultLimit, + ) + automation.registerRewardsReferenceStoreIngestion(rewardsStore) + automation.registerRewardComputationTrigger(rewardsStore) + rewardsStore + } verdictAutomation = new ScanVerdictAutomationService( config, syncNodes, @@ -390,7 +383,7 @@ class ScanApp( domainMigrationId, synchronizerId, nodeMetrics.verdictIngestion, - rewardsReferenceStoreO, + rewardsReferenceStore, ) scanHandler = new HttpScanHandler( serviceUserPrimaryParty, @@ -400,15 +393,14 @@ class ScanApp( syncService, automation, updateHistory, - appRewardsStoreO, - appActivityRecordStoreO, + appRewardsStore, + appActivityRecordStore, acsSnapshotStore, scanEventStore, bulkStorage.map(_.reader), dsoAnsResolver, config.miningRoundsCacheTimeToLiveOverride, config.enableForcedAcsSnapshots, - config.serveAppActivityRecordsAndTraffic, clock, loggerFactory, packageVersionSupport, @@ -548,7 +540,7 @@ class ScanApp( bulkStorage, verdictAutomation, scanEventStore, - rewardsReferenceStoreO, + rewardsReferenceStore, loggerFactory.getTracedLogger(ScanApp.State.getClass), timeouts, bftSequencersWithAdminConnections.map(_._1), @@ -622,7 +614,7 @@ object ScanApp { bulkStorage: Option[BulkStorage], verdictAutomation: ScanVerdictAutomationService, eventStore: ScanEventStore, - rewardsReferenceStoreO: Option[ScanRewardsReferenceStore], + rewardsReferenceStore: ScanRewardsReferenceStore, logger: TracedLogger, timeouts: ProcessingTimeout, bftSequencersAdminConnections: Seq[SequencerAdminConnection], @@ -633,20 +625,25 @@ object ScanApp { storage.isActive override def close(): Unit = { - LifeCycle.close(bftSequencersAdminConnections*)(logger) - LifeCycle.close(cleanups*)(logger) - bulkStorage.foreach(LifeCycle.close(_)(logger)) - LifeCycle.close( - automation, - verdictAutomation, - store, - storage, - synchronizerNodes.current, - participantAdminConnection, - )(logger) - synchronizerNodes.successor.foreach( - LifeCycle.close(_)(logger) - ) + // Close everything in one LifeCycle.close call: it closes every instance left to right + // even when some of them fail, whereas separate calls stop at the first failing call. + val instances: Seq[AutoCloseable] = + bftSequencersAdminConnections ++ + cleanups ++ + bulkStorage.toList ++ + Seq( + automation, + verdictAutomation, + store, + rewardsReferenceStore, + storage, + synchronizerNodes.current, + participantAdminConnection, + ) ++ + synchronizerNodes.successor.toList ++ + synchronizerNodes.legacy.toList ++ + synchronizerNodes.additionalLegacy + LifeCycle.close(instances*)(logger) } } } diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/ScanSynchronizerNode.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/ScanSynchronizerNode.scala index a118f25ccd..eaff50ea3c 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/ScanSynchronizerNode.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/ScanSynchronizerNode.scala @@ -8,7 +8,7 @@ import org.lfdecentralizedtrust.splice.scan.sequencer.SequencerTrafficClient final class ScanSynchronizerNode( override val sequencerAdminConnection: SequencerAdminConnection, - val sequencerTrafficClient: Option[SequencerTrafficClient], + val sequencerTrafficClient: SequencerTrafficClient, ) extends SynchronizerNode(sequencerAdminConnection) with AutoCloseable { override def close(): Unit = { diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/BftScanConnection.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/BftScanConnection.scala index c8959052c8..a8af12533b 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/BftScanConnection.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/BftScanConnection.scala @@ -37,6 +37,7 @@ import org.lfdecentralizedtrust.splice.environment.{ import org.lfdecentralizedtrust.splice.http.HttpClient import org.lfdecentralizedtrust.splice.http.v0.definitions.{ AnsEntry, + GetBulkObjectChecksumsResponse, GetDsoInfoResponse, GetRewardAccountingActivityTotalsResponse, GetRewardAccountingBatchResponse, @@ -62,7 +63,7 @@ import org.lfdecentralizedtrust.splice.scan.admin.api.client.commands.HttpScanAp import org.lfdecentralizedtrust.splice.scan.admin.api.client.commands.HttpScanAppClient.DsoScan import org.lfdecentralizedtrust.splice.scan.config.ScanAppClientConfig import org.lfdecentralizedtrust.splice.scan.store.ScanStore -import org.lfdecentralizedtrust.splice.store.DsoRulesStore +import org.lfdecentralizedtrust.splice.store.{DsoRulesStore, VoteResultsFilters} import org.lfdecentralizedtrust.splice.store.HistoryBackfilling.SourceMigrationInfo import org.lfdecentralizedtrust.splice.store.UpdateHistory.UpdateHistoryResponse import org.lfdecentralizedtrust.splice.util.{ @@ -486,11 +487,7 @@ class BftScanConnection( bftCall(_.lookupTransferPreapprovalByParty(receiver), "lookupTransferPreapprovalByParty") override def listVoteRequestResults( - actionName: Option[String], - accepted: Option[Boolean], - requester: Option[String], - effectiveFrom: Option[String], - effectiveTo: Option[String], + filters: VoteResultsFilters, limit: Int, pageToken: Option[BigInt] = None, )(implicit @@ -498,17 +495,23 @@ class BftScanConnection( tc: TraceContext, ): Future[(Seq[DsoRules_CloseVoteRequestResult], Option[BigInt])] = bftCall( _.listVoteRequestResults( - actionName, - accepted, - requester, - effectiveFrom, - effectiveTo, + filters, limit, pageToken, ), "listVoteRequestResults", ) + override def countVoteRequestResults( + filters: VoteResultsFilters + )(implicit + ec: ExecutionContext, + tc: TraceContext, + ): Future[Long] = bftCall( + _.countVoteRequestResults(filters), + "countVoteRequestResults", + ) + override def getPreviousSvRewardWeight(svParty: String, effectiveBefore: Option[String])(implicit ec: ExecutionContext, tc: TraceContext, @@ -899,13 +902,30 @@ class BftScanConnection( endpoint: String, callConfig: BftCallConfig = BftCallConfig.default(scanList.scanConnections), consensusFailureLogLevel: Level = Level.WARN, - consensusLogConfig: BftScanConnection.ConsensusLogConfig = - BftScanConnection.ConsensusLogConfig(), shortenResponsesForLog: T => Any = identity[T], )(implicit ec: ExecutionContext, tc: TraceContext, - ): Future[T] = { + ): Future[T] = bftCallWithScanUris( + call, + endpoint, + callConfig, + consensusFailureLogLevel, + shortenResponsesForLog = shortenResponsesForLog, + ) + .map(_._1) + + private def bftCallWithScanUris[T]( + call: SingleScanConnection => Future[T], + endpoint: String, + callConfig: BftCallConfig, + consensusFailureLogLevel: Level = Level.WARN, + disagreementLogLevel: Level = Level.INFO, + shortenResponsesForLog: T => Any = identity[T], + )(implicit + ec: ExecutionContext, + tc: TraceContext, + ): Future[(T, List[Uri])] = { implicit val mc: MetricsContext = MetricsContext("request" -> endpoint) val connections = scanList.scanConnections @@ -943,7 +963,7 @@ class BftScanConnection( nTargetSuccess = callConfig.targetSuccess, logger, shortenResponsesForLog, - consensusLogConfig, + disagreementLogLevel, connectionMetrics, ), logger, @@ -1008,15 +1028,21 @@ class BftScanConnection( override def getRewardAccountingActivityTotals(roundNumber: Long)(implicit ec: ExecutionContext, tc: TraceContext, - ): Future[GetRewardAccountingActivityTotalsResponse] = { + ): Future[GetRewardAccountingActivityTotalsResponse] = + getRewardAccountingActivityTotalsWithScanUris(roundNumber).map(_._1) + + def getRewardAccountingActivityTotalsWithScanUris(roundNumber: Long)(implicit + ec: ExecutionContext, + tc: TraceContext, + ): Future[(GetRewardAccountingActivityTotalsResponse, List[Uri])] = { val undetermined = GetRewardAccountingActivityTotalsResponse( RewardAccountingActivityTotalsUndetermined(status = "Undetermined") ) val callConfig = BftCallConfig.default(scanList.scanConnections) - if (!callConfig.enoughAvailableScans) Future.successful(undetermined) + if (!callConfig.enoughAvailableScans) Future.successful((undetermined, Nil)) else - bftCall[RewardAccountingActivityTotalsOk]( + bftCallWithScanUris[RewardAccountingActivityTotalsOk]( call = scan => scan.getRewardAccountingActivityTotals(roundNumber).flatMap { case GetRewardAccountingActivityTotalsResponse.members @@ -1028,19 +1054,13 @@ class BftScanConnection( }, endpoint = "getRewardAccountingActivityTotals", callConfig = callConfig, - consensusLogConfig = BftScanConnection.ConsensusLogConfig( - disagreementLogLevel = Level.WARN, - onlyLogDisagreementsInSuccessResponse = true, - agreementLogLevel = Some(Level.INFO), - ), + disagreementLogLevel = Level.WARN, ) - .transform(tryTotals => - Success( - tryTotals.toOption.fold(undetermined)(ok => - GetRewardAccountingActivityTotalsResponse(ok) - ) - ) - ) + .transformWith { + case Success((totals, consensusUris)) => + Future.successful((GetRewardAccountingActivityTotalsResponse(totals), consensusUris)) + case Failure(_) => Future.successful((undetermined, Nil)) + } } /** This is special because in addition to 'Ok' we can receive @@ -1056,15 +1076,21 @@ class BftScanConnection( override def getRewardAccountingRootHash(roundNumber: Long)(implicit ec: ExecutionContext, tc: TraceContext, - ): Future[GetRewardAccountingRootHashResponse] = { + ): Future[GetRewardAccountingRootHashResponse] = + getRewardAccountingRootHashWithScanUris(roundNumber).map(_._1) + + def getRewardAccountingRootHashWithScanUris(roundNumber: Long)(implicit + ec: ExecutionContext, + tc: TraceContext, + ): Future[(GetRewardAccountingRootHashResponse, List[Uri])] = { val undetermined = GetRewardAccountingRootHashResponse( RewardAccountingRootHashUndetermined(status = "Undetermined") ) val callConfig = BftCallConfig.default(scanList.scanConnections) - if (!callConfig.enoughAvailableScans) Future.successful(undetermined) + if (!callConfig.enoughAvailableScans) Future.successful((undetermined, Nil)) else - bftCall[String]( + bftCallWithScanUris[String]( call = scan => scan.getRewardAccountingRootHash(roundNumber).flatMap { case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashOk(ok) => @@ -1075,25 +1101,24 @@ class BftScanConnection( }, endpoint = "getRewardAccountingRootHash", callConfig = callConfig, - consensusLogConfig = BftScanConnection.ConsensusLogConfig( - disagreementLogLevel = Level.WARN, - onlyLogDisagreementsInSuccessResponse = true, - agreementLogLevel = Some(Level.INFO), - ), + disagreementLogLevel = Level.WARN, ) - .transform(tryRootHash => - Success( - tryRootHash.toOption.fold(undetermined)(rootHash => - GetRewardAccountingRootHashResponse( - RewardAccountingRootHashOk( - status = "Ok", - roundNumber = roundNumber, - rootHash = rootHash, - ) + .transformWith { + case Success((rootHash, consensusUris)) => + Future.successful( + ( + GetRewardAccountingRootHashResponse( + RewardAccountingRootHashOk( + status = "Ok", + roundNumber = roundNumber, + rootHash = rootHash, + ) + ), + consensusUris.map(_.toString), ) ) - ) - ) + case Failure(_) => Future.successful((undetermined, Nil)) + } } /** The batch contents are verifiable via the hash, so BFT agreement across scans is not @@ -1118,6 +1143,15 @@ class BftScanConnection( ) .transform(tryBatch => Success(tryBatch.toOption)) } + + override def getBulkObjectChecksums( + objectKeys: Seq[String] + )(implicit ec: ExecutionContext, tc: TraceContext): Future[GetBulkObjectChecksumsResponse] = + bftCall( + _.getBulkObjectChecksums(objectKeys), + "getBulkObjectChecksums", + consensusFailureLogLevel = Level.DEBUG, + ) } trait HasUrl { def url: Uri @@ -1130,19 +1164,19 @@ object BftScanConnection { nTargetSuccess: Int, logger: TracedLogger, shortenResponsesForLog: T => Any = identity[T], - consensusLogConfig: ConsensusLogConfig = ConsensusLogConfig(), + disagreementLogLevel: Level = Level.INFO, connectionMetrics: Option[ScanConnectionMetrics] = None, )(implicit ec: ExecutionContext, tc: TraceContext, mc: MetricsContext = MetricsContext.Empty, - ): Future[T] = { + ): Future[(T, List[Uri])] = { require(requestFrom.nonEmpty, "At least one request must be made.") val responses = new ConcurrentHashMap[BftScanConnection.ScanResponse[T], List[Uri]]() val nResponsesDone = new AtomicInteger(0) - val finalResponse = Promise[T]() + val finalResponse = Promise[(T, List[Uri])]() requestFrom.foreach { scan => call(scan) @@ -1161,7 +1195,7 @@ object BftScanConnection { case _ => true } if (considerResponseForQuorum && agreements.size == nTargetSuccess) { // consensus has been reached - finalResponse.tryComplete(response): Unit + finalResponse.tryComplete(response.map(r => (r, agreements))): Unit } if (nResponsesDone.incrementAndGet() == requestFrom.size) { // all Scans are done @@ -1176,9 +1210,9 @@ object BftScanConnection { case Some(consensusResponse) => logDisagreements( logger, - consensusResponse, + consensusResponse.map(_._1), responses, - consensusLogConfig, + disagreementLogLevel, connectionMetrics, ) } @@ -1224,7 +1258,7 @@ object BftScanConnection { logger: TracedLogger, consensusResponse: Try[T], responses: ConcurrentHashMap[BftScanConnection.ScanResponse[T], List[Uri]], - consensusLogConfig: ConsensusLogConfig, + disagreementLogLevel: Level, connectionMetrics: Option[ScanConnectionMetrics], )(implicit ec: ExecutionContext, tc: TraceContext, mc: MetricsContext): Unit = { implicit val elc: ErrorLoggingContext = ErrorLoggingContext.fromTracedLogger(logger) @@ -1254,28 +1288,16 @@ object BftScanConnection { keyToGroupResponses(consensusResponse).foreach { consensusResponseKey => val agreeingScanUrls = responses.remove(consensusResponseKey) agreeingScanUrls.foreach(recordConsensus(_, "agree", Map.empty)) - consensusLogConfig.agreementLogLevel.foreach { level => - LoggerUtil.logAtLevel( - level, - s"Reached consensus from:\n${agreeingScanUrls.mkString("\n")}", - ) - } responses.forEach { (disagreeingResponse, scanUrls) => val extraLabels = disagreementLabels(disagreeingResponse) scanUrls.foreach(recordConsensus(_, "disagree", extraLabels)) - val shouldLog = disagreeingResponse match { - case _: SuccessfulResponse[?] => true - case _ => !consensusLogConfig.onlyLogDisagreementsInSuccessResponse - } - if (shouldLog) { - LoggerUtil.logAtLevel( - consensusLogConfig.disagreementLogLevel, - s"""The following Scan URLs disagreed with consensus: - |${scanUrls.map(url => s" $url").mkString("\n")} - |consensus response: $consensusResponse - |disagreeing response: $disagreeingResponse""".stripMargin, - ) - } + LoggerUtil.logAtLevel( + disagreementLogLevel, + s"""The following Scan URLs disagreed with consensus: + |${scanUrls.map(url => s" $url").mkString("\n")} + |consensus response: $consensusResponse + |disagreeing response: $disagreeingResponse""".stripMargin, + ) } } } @@ -1861,8 +1883,10 @@ object BftScanConnection { disableBackgroundRefresh = true, ) - // Use the temporary connection to get a consensus on the full list of scans - allScans <- Bft.getScansInDsoRules(tempBftConnection).andThen { case _ => + // Use the temporary connection to get a consensus on the full list of scans. + // Future.delegate turns a synchronous throw into a failed future, so the + // andThen cleanup always runs. + allScans <- Future.delegate(Bft.getScansInDsoRules(tempBftConnection)).andThen { case _ => tempBftConnection.close() } @@ -1919,23 +1943,29 @@ object BftScanConnection { connectionMetrics, ) - _ <- retryProvider.waitUntil( - RetryFor.WaitingOnInitDependency, - "refresh_initial_scan_list", - "Scan list is refreshed.", - scanList - .refresh(bftConnection) - .recoverWith { case NonFatal(ex) => - Future.failed( - Status.UNAVAILABLE - .withDescription("Failed to refresh scan list on init") - .withCause(ex) - .asException() - ) - } - .map(_ => ()), - loggerFactory.getTracedLogger(classOf[BftScanConnection]), - ) + _ <- retryProvider + .waitUntil( + RetryFor.WaitingOnInitDependency, + "refresh_initial_scan_list", + "Scan list is refreshed.", + scanList + .refresh(bftConnection) + .recoverWith { case NonFatal(ex) => + Future.failed( + Status.UNAVAILABLE + .withDescription("Failed to refresh scan list on init") + .withCause(ex) + .asException() + ) + } + .map(_ => ()), + loggerFactory.getTracedLogger(classOf[BftScanConnection]), + ) + .recoverWith { case NonFatal(ex) => + // do not leak the scan connections when initialization ultimately fails + bftConnection.close() + Future.failed(ex) + } } yield bftConnection case bft @ BftScanClientConfig.Bft(_, _, _, _) => @@ -1970,24 +2000,30 @@ object BftScanConnection { else { _ => Future.unit }, connectionMetrics, ) - _ <- retryProvider.waitUntil( - RetryFor.WaitingOnInitDependency, - "refresh_initial_scan_list", - "Scan list is refreshed.", - bftConnection.scanList - .asInstanceOf[AllDsoScansBft] - .refresh(bftConnection) - .recoverWith { case NonFatal(ex) => - Future.failed( - Status.UNAVAILABLE - .withDescription("Failed to refresh scan list on init") - .withCause(ex) - .asException() - ) - } - .map(_ => ()), - loggerFactory.getTracedLogger(classOf[BftScanConnection]), - ) + _ <- retryProvider + .waitUntil( + RetryFor.WaitingOnInitDependency, + "refresh_initial_scan_list", + "Scan list is refreshed.", + bftConnection.scanList + .asInstanceOf[AllDsoScansBft] + .refresh(bftConnection) + .recoverWith { case NonFatal(ex) => + Future.failed( + Status.UNAVAILABLE + .withDescription("Failed to refresh scan list on init") + .withCause(ex) + .asException() + ) + } + .map(_ => ()), + loggerFactory.getTracedLogger(classOf[BftScanConnection]), + ) + .recoverWith { case NonFatal(ex) => + // do not leak the seed scan connections when initialization ultimately fails + bftConnection.close() + Future.failed(ex) + } } yield bftConnection } } @@ -2133,12 +2169,6 @@ object BftScanConnection { extends RuntimeException(s"Scan $url has no answer to contribute to consensus") with NoStackTrace - case class ConsensusLogConfig( - disagreementLogLevel: Level = Level.INFO, - onlyLogDisagreementsInSuccessResponse: Boolean = false, - agreementLogLevel: Option[Level] = None, - ) - private sealed trait ScanResponse[+T] private case class SuccessfulResponse[+T](response: T) extends ScanResponse[T] private case class HttpFailureResponse[+T](status: StatusCode, body: Json) extends ScanResponse[T] diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/ScanConnection.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/ScanConnection.scala index 4bd992a4ee..bbf758cb1c 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/ScanConnection.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/ScanConnection.scala @@ -24,10 +24,12 @@ import org.lfdecentralizedtrust.splice.config.UpgradesConfig import org.lfdecentralizedtrust.splice.environment.* import org.lfdecentralizedtrust.splice.http.HttpClient import org.lfdecentralizedtrust.splice.http.v0.definitions.{ + GetBulkObjectChecksumsResponse, GetDsoInfoResponse, GetRewardAccountingActivityTotalsResponse, GetRewardAccountingBatchResponse, GetRewardAccountingRootHashResponse, + HoldingsSummaryRequestV1, HoldingsSummaryResponse, HoldingsSummaryResponseV1, LookupTransferCommandStatusResponse, @@ -37,6 +39,7 @@ import org.lfdecentralizedtrust.splice.scan.admin.api.client.ScanConnection.* import org.lfdecentralizedtrust.splice.scan.admin.api.client.commands.HttpScanAppClient import org.lfdecentralizedtrust.splice.scan.admin.api.client.commands.HttpScanAppClient.TransferContextWithInstances import org.lfdecentralizedtrust.splice.scan.config.ScanAppClientConfig +import org.lfdecentralizedtrust.splice.store.VoteResultsFilters import org.lfdecentralizedtrust.splice.util.* import org.lfdecentralizedtrust.splice.util.PrettyInstances.* import com.digitalasset.canton.config.RequireTypes.NonNegativeInt @@ -54,7 +57,6 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.{ } import org.lfdecentralizedtrust.splice.http.v0.definitions.HoldingsSummaryRequest.RecordTimeMatch import org.lfdecentralizedtrust.splice.metrics.ScanConnectionMetrics -import org.lfdecentralizedtrust.splice.http.v0.definitions.HoldingsSummaryRequestV1 import scala.concurrent.{ExecutionContext, ExecutionContextExecutor, Future} import scala.jdk.OptionConverters.* @@ -310,11 +312,7 @@ trait ScanConnection ): Future[Option[ContractWithState[TransferPreapproval.ContractId, TransferPreapproval]]] def listVoteRequestResults( - actionName: Option[String], - accepted: Option[Boolean], - requester: Option[String], - effectiveFrom: Option[String], - effectiveTo: Option[String], + filters: VoteResultsFilters, limit: Int, pageToken: Option[BigInt] = None, )(implicit @@ -322,6 +320,13 @@ trait ScanConnection tc: TraceContext, ): Future[(Seq[DsoRules_CloseVoteRequestResult], Option[BigInt])] + def countVoteRequestResults( + filters: VoteResultsFilters + )(implicit + ec: ExecutionContext, + tc: TraceContext, + ): Future[Long] + def getPreviousSvRewardWeight(svParty: String, effectiveBefore: Option[String])(implicit ec: ExecutionContext, tc: TraceContext, @@ -356,6 +361,10 @@ trait ScanConnection tc: TraceContext, ): Future[Option[GetRewardAccountingBatchResponse]] + def getBulkObjectChecksums(objectKeys: Seq[String])(implicit + ec: ExecutionContext, + tc: TraceContext, + ): Future[GetBulkObjectChecksumsResponse] } object ScanConnection { diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/SingleScanConnection.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/SingleScanConnection.scala index 9ccffbd016..a554b15047 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/SingleScanConnection.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/SingleScanConnection.scala @@ -26,12 +26,14 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.round.{ import org.lfdecentralizedtrust.splice.codegen.java.splice.ans.AnsRules import org.lfdecentralizedtrust.splice.config.UpgradesConfig import org.lfdecentralizedtrust.splice.environment.{ + BaseAppConnection, HttpAppConnection, RetryProvider, SpliceLedgerClient, } import org.lfdecentralizedtrust.splice.http.HttpClient import org.lfdecentralizedtrust.splice.http.v0.definitions.{ + GetBulkObjectChecksumsResponse, GetRewardAccountingActivityTotalsResponse, GetRewardAccountingBatchResponse, GetRewardAccountingRootHashResponse, @@ -44,6 +46,7 @@ import org.lfdecentralizedtrust.splice.http.v0.definitions.{ import org.lfdecentralizedtrust.splice.scan.admin.api.client.commands.HttpScanAppClient import org.lfdecentralizedtrust.splice.scan.config.ScanAppClientConfig import org.lfdecentralizedtrust.splice.store.HistoryBackfilling.SourceMigrationInfo +import org.lfdecentralizedtrust.splice.store.VoteResultsFilters import org.lfdecentralizedtrust.splice.store.UpdateHistory.UpdateHistoryResponse import org.lfdecentralizedtrust.splice.util.{ ChoiceContextWithDisclosures, @@ -78,7 +81,7 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.{ } import io.grpc.Status import org.apache.pekko.http.scaladsl.model.{HttpHeader, Uri} -import org.lfdecentralizedtrust.splice.admin.api.client.commands.HttpCommand +import org.lfdecentralizedtrust.splice.admin.api.client.commands.{HttpCommand, HttpCommandException} import org.lfdecentralizedtrust.splice.codegen.java.splice.api.token.transferinstructionv1 import org.lfdecentralizedtrust.splice.codegen.java.splice.api.token.transferinstructionv2 import org.lfdecentralizedtrust.splice.codegen.java.splice.api.token.allocationv1 @@ -95,7 +98,7 @@ import scala.util.{Failure, Success} * to query for the DSO party id. */ class SingleScanConnection private[client] ( - private[client] val config: ScanAppClientConfig, + val config: ScanAppClientConfig, upgradesConfig: UpgradesConfig, protected val clock: Clock, retryProvider: RetryProvider, @@ -140,9 +143,11 @@ class SingleScanConnection private[client] ( .runHttpCmd(url, command, headers) .andThen { case Failure(e) => - MetricsContext.withMetricLabels(("outcome", e.getClass.getSimpleName)) { - implicit ec2 => - metrics.callPerConnection.mark()(m.merge(ec2)) + MetricsContext.withMetricLabels( + ("outcome", e.getClass.getSimpleName), + ("http_status", SingleScanConnection.httpStatusLabel(e)), + ) { implicit ec2 => + metrics.callPerConnection.mark()(m.merge(ec2)) } timer.stop()(m) case Success(_) => @@ -567,11 +572,7 @@ class SingleScanConnection private[client] ( ) override def listVoteRequestResults( - actionName: Option[String], - accepted: Option[Boolean], - requester: Option[String], - effectiveFrom: Option[String], - effectiveTo: Option[String], + filters: VoteResultsFilters, limit: Int, pageToken: Option[BigInt] = None, )(implicit @@ -580,16 +581,22 @@ class SingleScanConnection private[client] ( ): Future[(Seq[DsoRules_CloseVoteRequestResult], Option[BigInt])] = runHttpCmd( config.adminApi.url, HttpScanAppClient.ListVoteRequestResults( - actionName, - accepted, - requester, - effectiveFrom, - effectiveTo, + filters, limit, pageToken, ), ) + override def countVoteRequestResults( + filters: VoteResultsFilters + )(implicit + ec: ExecutionContext, + tc: TraceContext, + ): Future[Long] = runHttpCmd( + config.adminApi.url, + HttpScanAppClient.CountVoteRequestResults(filters), + ) + override def getPreviousSvRewardWeight(svParty: String, effectiveBefore: Option[String])(implicit ec: ExecutionContext, tc: TraceContext, @@ -1024,9 +1031,29 @@ class SingleScanConnection private[client] ( config.adminApi.url, HttpScanAppClient.GetRewardAccountingBatch(roundNumber, batchHash), ) + + override def getBulkObjectChecksums( + objectKeys: Seq[String] + )(implicit ec: ExecutionContext, tc: TraceContext): Future[GetBulkObjectChecksumsResponse] = + runHttpCmd( + config.adminApi.url, + HttpScanAppClient.GetBulkObjectChecksums(objectKeys), + ) } object SingleScanConnection { + + private[client] def httpStatusLabel(error: Throwable): String = + error match { + case e: BaseAppConnection.UnexpectedHttpJsonResponse => e.statusCode.intValue.toString + case e: BaseAppConnection.UnexpectedHttpMalformedJsonResponse => + e.statusCode.intValue.toString + case e: BaseAppConnection.UnexpectedHttpTextResponse => e.statusCode.intValue.toString + case e: BaseAppConnection.UnexpectedHttpNonJsonResponse => e.statusCode.intValue.toString + case e: HttpCommandException => e.status.intValue.toString + case _ => "none" + } + def withSingleScanConnection[T]( scanConfig: ScanAppClientConfig, upgradesConfig: UpgradesConfig, diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/commands/HttpScanAppClient.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/commands/HttpScanAppClient.scala index 2ea945a677..c083776b93 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/commands/HttpScanAppClient.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/commands/HttpScanAppClient.scala @@ -42,6 +42,7 @@ import org.lfdecentralizedtrust.tokenstandard.{ } import org.lfdecentralizedtrust.splice.http.v0.scan.{ ForceAcsSnapshotNowResponse, + GetBulkObjectChecksumsResponse, GetDateOfFirstSnapshotAfterResponse, GetDateOfMostRecentSnapshotBeforeResponse, GetLsuResponse, @@ -53,7 +54,7 @@ import org.lfdecentralizedtrust.splice.scan.admin.http.{ ProtobufJsonScanHttpEncodings, } import org.lfdecentralizedtrust.splice.store.HistoryBackfilling.SourceMigrationInfo -import org.lfdecentralizedtrust.splice.store.MultiDomainAcsStore +import org.lfdecentralizedtrust.splice.store.{MultiDomainAcsStore, VoteResultsFilters} import org.lfdecentralizedtrust.splice.store.UpdateHistory.UpdateHistoryResponse import org.lfdecentralizedtrust.splice.util.{ ChoiceContextWithDisclosures, @@ -843,7 +844,7 @@ object HttpScanAppClient { P2PEndpoint.fromEndpointConfig( P2PEndpointConfig( uri.authority.host.address(), - RequireTypes.Port(uri.effectivePort), + RequireTypes.Port.tryCreate(uri.effectivePort), Option.when(uri.scheme == "https")( TlsClientConfig( None, @@ -894,33 +895,6 @@ object HttpScanAppClient { final case class DsoScan(publicUrl: Uri, svName: String) - case class ListTransactions( - pageEndEventId: Option[String], - sortOrder: definitions.TransactionHistoryRequest.SortOrder, - pageSize: Int, - ) extends InternalBaseCommand[http.ListTransactionHistoryResponse, Seq[ - definitions.TransactionHistoryResponseItem - ]] { - override def submitRequest( - client: http.ScanClient, - headers: List[HttpHeader], - ): EitherT[Future, Either[ - Throwable, - HttpResponse, - ], http.ListTransactionHistoryResponse] = { - client.listTransactionHistory( - definitions - .TransactionHistoryRequest(pageEndEventId, Some(sortOrder), pageSize.toLong), - headers, - ) - } - - override def handleOk()(implicit decoder: TemplateJsonDecoder) = { - case http.ListTransactionHistoryResponse.OK(response) => - Right(response.transactions) - } - } - case class GetAcsSnapshot( party: PartyId, recordTime: Option[Instant], @@ -1097,6 +1071,48 @@ object HttpScanAppClient { } } + case class GetAcsSnapshotAtV2( + at: java.time.OffsetDateTime, + migrationId: Long, + recordTimeMatch: Option[definitions.AcsRequestV2.RecordTimeMatch], + after: Option[String] = None, + pageSize: Int = 100, + partyIds: Option[Vector[PartyId]] = None, + templates: Option[Vector[PackageQualifiedName]] = None, + ) extends InternalBaseCommand[ + http.GetAcsSnapshotAtV2Response, + Option[definitions.AcsResponseV2], + ] { + override def submitRequest( + client: ScanClient, + headers: List[HttpHeader], + ): EitherT[Future, Either[Throwable, HttpResponse], http.GetAcsSnapshotAtV2Response] = + client.getAcsSnapshotAtV2( + definitions.AcsRequestV2( + migrationId, + at, + recordTimeMatch, + after, + pageSize, + partyIds.map(_.map(_.toProtoPrimitive)), + templates.map(_.map(_.toString)), + ), + headers, + ) + + override protected def handleOk()(implicit + decoder: TemplateJsonDecoder + ): PartialFunction[http.GetAcsSnapshotAtV2Response, Either[ + String, + Option[definitions.AcsResponseV2], + ]] = { + case http.GetAcsSnapshotAtV2Response.OK(value) => + Right(Some(value)) + case http.GetAcsSnapshotAtV2Response.NotFound(_) => + Right(None) + } + } + case class GetHoldingsStateAt( at: java.time.OffsetDateTime, migrationId: Long, @@ -3086,11 +3102,7 @@ object HttpScanAppClient { } case class ListVoteRequestResults( - actionName: Option[String], - accepted: Option[Boolean], - requester: Option[String], - effectiveFrom: Option[String], - effectiveTo: Option[String], + filters: VoteResultsFilters, limit: BigInt, pageToken: Option[BigInt] = None, ) extends InternalBaseCommand[ @@ -3107,13 +3119,13 @@ object HttpScanAppClient { ): EitherT[Future, Either[Throwable, HttpResponse], http.ListVoteRequestResultsResponse] = client.listVoteRequestResults( body = definitions.ListVoteResultsRequest( - actionName, - accepted, - requester, - effectiveFrom, - effectiveTo, - limit, - pageToken, + filters.actionName, + filters.accepted, + requester = filters.requester, + effectiveFrom = filters.effectiveFrom, + effectiveTo = filters.effectiveTo, + limit = limit, + pageToken = pageToken, ), headers = headers, ) @@ -3135,6 +3147,35 @@ object HttpScanAppClient { } } + case class CountVoteRequestResults( + filters: VoteResultsFilters + ) extends InternalBaseCommand[ + http.CountVoteRequestResultsResponse, + Long, + ] { + + override def submitRequest( + client: ScanClient, + headers: List[HttpHeader], + ): EitherT[Future, Either[Throwable, HttpResponse], http.CountVoteRequestResultsResponse] = + client.countVoteRequestResults( + body = definitions.CountVoteResultsRequest( + filters.actionName, + filters.accepted, + requester = filters.requester, + effectiveFrom = filters.effectiveFrom, + effectiveTo = filters.effectiveTo, + ), + headers = headers, + ) + + override def handleOk()(implicit + decoder: TemplateJsonDecoder + ) = { case http.CountVoteRequestResultsResponse.OK(response) => + Right(response.count) + } + } + case class GetPreviousSvRewardWeight( svParty: String, effectiveBefore: Option[String], @@ -3346,6 +3387,32 @@ object HttpScanAppClient { } } + case class GetBulkObjectChecksums( + objectKeys: Seq[String] + ) extends InternalBaseCommand[ + http.GetBulkObjectChecksumsResponse, + definitions.GetBulkObjectChecksumsResponse, + ] { + override def submitRequest( + client: Client, + headers: List[HttpHeader], + ): EitherT[Future, Either[Throwable, HttpResponse], GetBulkObjectChecksumsResponse] = + client.getBulkObjectChecksums( + definitions.GetBulkObjectChecksumsRequest(objectKeys.toVector), + headers, + ) + + override protected def handleOk()(implicit + decoder: TemplateJsonDecoder + ): PartialFunction[GetBulkObjectChecksumsResponse, Either[ + String, + definitions.GetBulkObjectChecksumsResponse, + ]] = { + case http.GetBulkObjectChecksumsResponse.OK(response) => Right(response) + case http.GetBulkObjectChecksumsResponse.NotImplemented(err) => Left(err.error) + } + } + case class BulkStorageDownload( objectKey: String ) extends ScanStreamBaseCommand[ diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/http/HttpScanHandler.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/http/HttpScanHandler.scala index 160261e7d0..90d2ab9358 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/http/HttpScanHandler.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/http/HttpScanHandler.scala @@ -67,11 +67,15 @@ import org.lfdecentralizedtrust.splice.http.{ import org.lfdecentralizedtrust.splice.http.v0.{definitions, scan as v0} import org.lfdecentralizedtrust.splice.http.v0.definitions.{ AcsRequest, + AcsRequestV2, BatchListVotesByVoteRequestsRequest, + CountVoteResultsRequest, DamlValueEncoding, ErrorResponse, EventHistoryRequest, + GetBulkObjectChecksumsRequest, HoldingsStateRequest, + HoldingsStateRequestV2, HoldingsSummaryRequest, HoldingsSummaryRequestV1, ListBulkUpdateHistoryObjectsRequest, @@ -96,8 +100,12 @@ import org.lfdecentralizedtrust.splice.scan.store.{ ScanStore, TxLogEntry, } +import org.lfdecentralizedtrust.splice.scan.store.AppActivityStore.RoundIngestionStatus import org.lfdecentralizedtrust.splice.scan.store.bulk.BulkStorageReader -import org.lfdecentralizedtrust.splice.scan.store.AcsSnapshotStore.QueryAcsSnapshotResult +import org.lfdecentralizedtrust.splice.scan.store.AcsSnapshotStore.{ + QueryAcsSnapshotPaginationToken, + QueryAcsSnapshotResult, +} import org.lfdecentralizedtrust.splice.scan.store.bulk.AcsSnapshotBulkStorage.AcsSnapshotObjects import org.lfdecentralizedtrust.splice.scan.store.bulk.UpdateHistoryBulkStorage.UpdateHistoryObjectsResponse import org.lfdecentralizedtrust.splice.store.AppStoreWithIngestion.SpliceLedgerConnectionPriority @@ -106,7 +114,8 @@ import org.lfdecentralizedtrust.splice.store.{ AppStore, AppStoreWithIngestion, PageLimit, - SortOrder, + TimestampWithMigrationId, + VoteResultsFilters, VotesStore, } import org.lfdecentralizedtrust.splice.store.S3BucketConnection.ObjectKeyAndChecksum @@ -146,15 +155,14 @@ class HttpScanHandler( synchronizerNodeService: SynchronizerNodeService[ScanSynchronizerNode], protected val storeWithIngestion: AppStoreWithIngestion[ScanStore], updateHistory: UpdateHistory, - appRewardsStoreO: Option[DbScanAppRewardsStore], - appActivityStoreO: Option[AppActivityStore], + appRewardsStore: DbScanAppRewardsStore, + appActivityStore: AppActivityStore, snapshotStore: AcsSnapshotStore, eventStore: ScanEventStore, bulkStorage: Option[BulkStorageReader], dsoAnsResolver: DsoAnsResolver, miningRoundsCacheTimeToLiveOverride: Option[NonNegativeFiniteDuration], enableForcedAcsSnapshots: Boolean, - serveAppActivityRecordsAndTraffic: Boolean, clock: Clock, protected val loggerFactory: NamedLoggerFactory, protected val packageVersionSupport: PackageVersionSupport, @@ -692,33 +700,6 @@ class HttpScanHandler( } } - override def listTransactionHistory( - respond: v0.ScanResource.ListTransactionHistoryResponse.type - )( - request: definitions.TransactionHistoryRequest - )(extracted: TraceContext): Future[v0.ScanResource.ListTransactionHistoryResponse] = { - implicit val tc = extracted - withSpan(s"$workflowId.listTransactions") { _ => _ => - val pageEndEventId = - if (request.pageEndEventId.exists(_.isEmpty)) None else request.pageEndEventId - val sortOrder = request.sortOrder - .fold[SortOrder](SortOrder.Ascending) { - case definitions.TransactionHistoryRequest.SortOrder.members.Asc => SortOrder.Ascending - case definitions.TransactionHistoryRequest.SortOrder.members.Desc => SortOrder.Descending - } - - for { - txs <- store.listTransactions( - pageEndEventId, - sortOrder, - PageLimit.tryCreate(request.pageSize.intValue()), - ) - } yield definitions.TransactionHistoryResponse( - txs.map(TxLogEntry.Http.toResponseItem).toVector - ) - } - } - def getUpdateHistory( after: Option[definitions.UpdateHistoryRequestAfter] = None, pageSize: Int, @@ -730,9 +711,9 @@ class HttpScanHandler( implicit val tc: TraceContext = extracted val afterO = after.map { after => val afterRecordTime = parseTimestamp(after.afterRecordTime) - ( - after.afterMigrationId, + TimestampWithMigrationId( afterRecordTime, + after.afterMigrationId, ) } confirmBackfillingIsCompleteThen(updateHistory) { @@ -857,14 +838,11 @@ class HttpScanHandler( case Some((verdictWithViewsO, updateO)) => val verdictRowIdO = verdictWithViewsO.map { case (v, _) => v.rowId } for { - appActivityRecordO <- - if (serveAppActivityRecordsAndTraffic) - verdictRowIdO match { - case Some(rowId) => - eventStore.getAppActivityRecords(Seq(rowId)).map(_.get(rowId)) - case None => Future.successful(None) - } - else Future.successful(None) + appActivityRecordO <- verdictRowIdO match { + case Some(rowId) => + eventStore.getAppActivityRecords(Seq(rowId)).map(_.get(rowId)) + case None => Future.successful(None) + } } yield { val encodedUpdateV2 = updateO .map( @@ -879,12 +857,9 @@ class HttpScanHandler( val verdictEncoded = verdictWithViewsO.map { case (v, views) => ScanHttpEncodings.encodeVerdict(v, views) } - val trafficSummaryEncoded = - if (serveAppActivityRecordsAndTraffic) - verdictWithViewsO.flatMap { case (v, _) => - v.trafficSummaryO.map(ScanHttpEncodings.encodeTrafficSummary) - } - else None + val trafficSummaryEncoded = verdictWithViewsO.flatMap { case (v, _) => + v.trafficSummaryO.map(ScanHttpEncodings.encodeTrafficSummary) + } val appActivityRecordEncoded = appActivityRecordO.map( ScanHttpEncodings.encodeAppActivityRecord ) @@ -928,7 +903,7 @@ class HttpScanHandler( implicit val tc: TraceContext = extracted val afterO = after.map { a => val afterRecordTime = parseTimestamp(a.afterRecordTime) - (a.afterMigrationId, afterRecordTime) + TimestampWithMigrationId(afterRecordTime, a.afterMigrationId) } confirmBackfillingIsCompleteThen(updateHistory) { @@ -941,9 +916,7 @@ class HttpScanHandler( verdictRowIds = events.flatMap { case (verdictWithViewsO, _) => verdictWithViewsO.map { case (v, _) => v.rowId } } - appActivityRecordMap <- - if (serveAppActivityRecordsAndTraffic) eventStore.getAppActivityRecords(verdictRowIds) - else Future.successful(Map.empty[Long, eventStore.AppActivityRecordT]) + appActivityRecordMap <- eventStore.getAppActivityRecords(verdictRowIds) } yield events.map { case (verdictWithViewsO, updateO) => val encodedUpdateV2 = updateO .map( @@ -958,12 +931,9 @@ class HttpScanHandler( val verdictEncoded = verdictWithViewsO.map { case (v, views) => ScanHttpEncodings.encodeVerdict(v, views) } - val trafficSummaryEncoded = - if (serveAppActivityRecordsAndTraffic) - verdictWithViewsO.flatMap { case (v, _) => - v.trafficSummaryO.map(ScanHttpEncodings.encodeTrafficSummary) - } - else None + val trafficSummaryEncoded = verdictWithViewsO.flatMap { case (v, _) => + v.trafficSummaryO.map(ScanHttpEncodings.encodeTrafficSummary) + } val appActivityRecordEncoded = verdictWithViewsO.flatMap { case (v, _) => appActivityRecordMap.get(v.rowId).map(ScanHttpEncodings.encodeAppActivityRecord) } @@ -1541,19 +1511,18 @@ class HttpScanHandler( } // Shared between /v0/state/acs and /v1/state/acs. The only difference between them is in `toResponse`. - private def acsSnapshotQuery[T](request: AcsRequest, toResponse: QueryAcsSnapshotResult => T)( - implicit tc: TraceContext + private def acsSnapshotQuery[T]( + migrationId: Long, + recordTime: java.time.OffsetDateTime, + recordTimeIsAtOrBefore: Boolean, + after: Option[AcsSnapshotStore.QueryAcsSnapshotPaginationToken], + pageSize: Int, + partyIds: Option[Vector[String]], + templates: Option[Vector[String]], + toResponse: QueryAcsSnapshotResult => T, + )(implicit + tc: TraceContext ): Future[Either[String, T]] = { - val AcsRequest( - migrationId, - recordTime, - recordTimeMatch, - after, - pageSize, - partyIds, - templates, - ) = request - def exactQuery(recordTimeTs: CantonTimestamp) = snapshotStore .queryAcsSnapshot( migrationId, @@ -1578,7 +1547,7 @@ class HttpScanHandler( queryWithOptionalAtOrBefore( migrationId, recordTime, - recordTimeMatch.contains(AcsRequest.RecordTimeMatch.AtOrBefore), + recordTimeIsAtOrBefore, exactQuery, toResponse, ) @@ -1598,7 +1567,9 @@ class HttpScanHandler( event.event, ) ), - result.afterToken, + result.afterToken.map { + case QueryAcsSnapshotPaginationToken.RowIdQueryAcsSnapshotPaginationToken(after) => after + }, ) } @@ -1616,7 +1587,26 @@ class HttpScanHandler( event.event, ) ), - result.afterToken, + result.afterToken.map { + case QueryAcsSnapshotPaginationToken.RowIdQueryAcsSnapshotPaginationToken(after) => after + }, + ) + + private def toAcsV2Response(migrationId: Long, result: QueryAcsSnapshotResult)(implicit + tc: TraceContext + ) = + definitions.AcsResponseV2( + Codec.encode(result.snapshotRecordTime), + migrationId, + result.createdEventsInPage + .map(event => + CompactJsonScanHttpEncodings().javaToHttpActiveContract( + event.eventId, + event.recordTime, + event.event, + ) + ), + result.afterToken.map(_.encodeToBase64), ) override def getAcsSnapshotAt(respond: ScanResource.GetAcsSnapshotAtResponse.type)( @@ -1630,7 +1620,19 @@ class HttpScanHandler( ) withSpan(s"$workflowId.getAcsSnapshotAt") { _ => _ => - acsSnapshotQuery(body, toResponse).map { + acsSnapshotQuery( + migrationId = body.migrationId, + recordTime = body.recordTime, + recordTimeIsAtOrBefore = + body.recordTimeMatch.contains(AcsRequest.RecordTimeMatch.AtOrBefore), + after = body.after.map( + AcsSnapshotStore.QueryAcsSnapshotPaginationToken.RowIdQueryAcsSnapshotPaginationToken(_) + ), + pageSize = body.pageSize, + partyIds = body.partyIds, + templates = body.templates, + toResponse = toResponse, + ).map { case Right(response) => response case Left(errorMessage) => ScanResource.GetAcsSnapshotAtResponseNotFound( @@ -1652,7 +1654,19 @@ class HttpScanHandler( } withSpan(s"$workflowId.getAcsSnapshotAtV1") { _ => _ => - acsSnapshotQuery(body, toResponse).map { + acsSnapshotQuery( + migrationId = body.migrationId, + recordTime = body.recordTime, + recordTimeIsAtOrBefore = + body.recordTimeMatch.contains(AcsRequest.RecordTimeMatch.AtOrBefore), + after = body.after.map( + AcsSnapshotStore.QueryAcsSnapshotPaginationToken.RowIdQueryAcsSnapshotPaginationToken(_) + ), + pageSize = body.pageSize, + partyIds = body.partyIds, + templates = body.templates, + toResponse = toResponse, + ).map { case Right(response) => response case Left(errorMessage) => ScanResource.GetAcsSnapshotAtV1ResponseNotFound( @@ -1662,21 +1676,50 @@ class HttpScanHandler( } } + override def getAcsSnapshotAtV2(respond: ScanResource.GetAcsSnapshotAtV2Response.type)( + body: AcsRequestV2 + )(extracted: TraceContext): Future[ScanResource.GetAcsSnapshotAtV2Response] = { + implicit val tc: TraceContext = extracted + + def toResponse(result: QueryAcsSnapshotResult) = { + ScanResource.GetAcsSnapshotAtV2ResponseOK( + toAcsV2Response(body.migrationId, result) + ) + } + + withSpan(s"$workflowId.getAcsSnapshotAtV1") { _ => _ => + acsSnapshotQuery( + migrationId = body.migrationId, + recordTime = body.recordTime, + recordTimeIsAtOrBefore = + body.recordTimeMatch.contains(AcsRequestV2.RecordTimeMatch.AtOrBefore), + after = + body.after.map(AcsSnapshotStore.QueryAcsSnapshotPaginationToken.tryDecodeFromBase64), + pageSize = body.pageSize, + partyIds = body.partyIds, + templates = body.templates, + toResponse = toResponse, + ).map { + case Right(response) => response + case Left(errorMessage) => + ScanResource.GetAcsSnapshotAtV2ResponseNotFound( + ErrorResponse(errorMessage) + ) + } + } + } + private def holdingStateQuery[T]( - request: HoldingsStateRequest, + migrationId: Long, + recordTime: java.time.OffsetDateTime, + recordTimeIsAtOrBefore: Boolean, + after: Option[AcsSnapshotStore.QueryAcsSnapshotPaginationToken], + pageSize: Int, + ownerPartyIds: Vector[String], toResponse: QueryAcsSnapshotResult => T, )(implicit tc: TraceContext ): Future[Either[String, T]] = { - val HoldingsStateRequest( - migrationId, - recordTime, - recordTimeMatch, - after, - pageSize, - ownerPartyIds, - ) = request - def exactQuery(recordTimeTs: CantonTimestamp) = snapshotStore .getHoldingsState( migrationId, @@ -1689,7 +1732,7 @@ class HttpScanHandler( queryWithOptionalAtOrBefore( migrationId, recordTime, - recordTimeMatch.contains(HoldingsStateRequest.RecordTimeMatch.AtOrBefore), + recordTimeIsAtOrBefore, exactQuery, toResponse, ) @@ -1703,7 +1746,18 @@ class HttpScanHandler( ScanResource.GetHoldingsStateAtResponseOK(toAcsV0Response(body.migrationId, result)) withSpan(s"$workflowId.getHoldingsStateAt") { _ => _ => - holdingStateQuery(body, toResponse).map { + holdingStateQuery( + migrationId = body.migrationId, + recordTime = body.recordTime, + recordTimeIsAtOrBefore = + body.recordTimeMatch.contains(HoldingsStateRequest.RecordTimeMatch.AtOrBefore), + after = body.after.map( + AcsSnapshotStore.QueryAcsSnapshotPaginationToken.RowIdQueryAcsSnapshotPaginationToken(_) + ), + pageSize = body.pageSize, + ownerPartyIds = body.ownerPartyIds, + toResponse = toResponse, + ).map { case Right(response) => response case Left(errorMessage) => ScanResource.GetHoldingsStateAtResponseNotFound( @@ -1721,7 +1775,18 @@ class HttpScanHandler( ScanResource.GetHoldingsStateAtV1ResponseOK(toAcsV1Response(body.migrationId, result)) withSpan(s"$workflowId.getHoldingsStateAtV1") { _ => _ => - holdingStateQuery(body, toResponse).map { + holdingStateQuery( + migrationId = body.migrationId, + recordTime = body.recordTime, + recordTimeIsAtOrBefore = + body.recordTimeMatch.contains(HoldingsStateRequest.RecordTimeMatch.AtOrBefore), + after = body.after.map( + AcsSnapshotStore.QueryAcsSnapshotPaginationToken.RowIdQueryAcsSnapshotPaginationToken(_) + ), + pageSize = body.pageSize, + ownerPartyIds = body.ownerPartyIds, + toResponse, + ).map { case Right(response) => response case Left(errorMessage) => ScanResource.GetHoldingsStateAtV1ResponseNotFound( @@ -1731,6 +1796,34 @@ class HttpScanHandler( } } + override def getHoldingsStateAtV2(respond: ScanResource.GetHoldingsStateAtV2Response.type)( + body: HoldingsStateRequestV2 + )(extracted: TraceContext): Future[ScanResource.GetHoldingsStateAtV2Response] = { + implicit val tc: TraceContext = extracted + def toResponse(result: QueryAcsSnapshotResult) = + ScanResource.GetHoldingsStateAtV2ResponseOK(toAcsV2Response(body.migrationId, result)) + + withSpan(s"$workflowId.getHoldingsStateAtV1") { _ => _ => + holdingStateQuery( + migrationId = body.migrationId, + recordTime = body.recordTime, + recordTimeIsAtOrBefore = + body.recordTimeMatch.contains(HoldingsStateRequestV2.RecordTimeMatch.AtOrBefore), + after = + body.after.map(AcsSnapshotStore.QueryAcsSnapshotPaginationToken.tryDecodeFromBase64), + pageSize = body.pageSize, + ownerPartyIds = body.ownerPartyIds, + toResponse, + ).map { + case Right(response) => response + case Left(errorMessage) => + ScanResource.GetHoldingsStateAtV2ResponseNotFound( + ErrorResponse(errorMessage) + ) + } + } + } + override def getHoldingsSummaryAt(respond: ScanResource.GetHoldingsSummaryAtResponse.type)( body: HoldingsSummaryRequest )(extracted: TraceContext): Future[ScanResource.GetHoldingsSummaryAtResponse] = { @@ -2118,11 +2211,13 @@ class HttpScanHandler( val after = body.pageToken.map(_.longValue) for { page <- votesStore.listVoteRequestResults( - body.actionName, - body.accepted, - body.requester, - body.effectiveFrom, - body.effectiveTo, + VoteResultsFilters( + body.actionName, + body.accepted, + requester = body.requester, + effectiveFrom = body.effectiveFrom, + effectiveTo = body.effectiveTo, + ), limit, after, ) @@ -2149,6 +2244,29 @@ class HttpScanHandler( } } + override def countVoteRequestResults( + respond: ScanResource.CountVoteRequestResultsResponse.type + )( + body: CountVoteResultsRequest + )(extracted: TraceContext): Future[ScanResource.CountVoteRequestResultsResponse] = { + implicit val tc: TraceContext = extracted + withSpan(s"$workflowId.countVoteRequestResults") { _ => _ => + for { + count <- votesStore.countVoteRequestResults( + VoteResultsFilters( + body.actionName, + body.accepted, + requester = body.requester, + effectiveFrom = body.effectiveFrom, + effectiveTo = body.effectiveTo, + ) + ) + } yield ScanResource.CountVoteRequestResultsResponse.OK( + definitions.CountVoteResultsResponse(count) + ) + } + } + override def getPreviousSvRewardWeight( respond: ScanResource.GetPreviousSvRewardWeightResponse.type )( @@ -2499,7 +2617,7 @@ class HttpScanHandler( val entry = definitions.SynchronizerBftSequencer( psid.serial.unwrap.toLong, id.toProtoPrimitive, - bftSequencer.p2pUrl, + bftSequencer.p2pUrl.toString, ) initializedBftSequencersCache.put(idx, entry).discard Some(entry) @@ -2622,6 +2740,29 @@ class HttpScanHandler( } } + override def getBulkObjectChecksums(respond: ScanResource.GetBulkObjectChecksumsResponse.type)( + body: GetBulkObjectChecksumsRequest + )(extracted: TraceContext): Future[ScanResource.GetBulkObjectChecksumsResponse] = { + implicit val tc = extracted + withSpan(s"$workflowId.getBulkObjectChecksums") { _ => _ => + bulkStorage.fold( + Future.failed[ScanResource.GetBulkObjectChecksumsResponse]( + Status.UNIMPLEMENTED + .withDescription("Bulk storage is not configured") + .asRuntimeException() + ) + ) { bulkStorage => + bulkStorage.getObjectChecksums(body.objectKeys).map { checksums => + ScanResource.GetBulkObjectChecksumsResponse.OK( + definitions.GetBulkObjectChecksumsResponse( + checksums.map(definitions.GetBulkObjectChecksumsResponse.Checksums(_)).toVector + ) + ) + } + } + } + } + def getRollForwardLsu(respond: ScanResource.GetRollForwardLsuResponse.type)()( extracted: TraceContext ): Future[ScanResource.GetRollForwardLsuResponse] = { @@ -2707,23 +2848,14 @@ class HttpScanHandler( ] = { implicit val tc = extracted withSpan(s"$workflowId.getRewardAccountingEarliestAvailableRound") { _ => _ => - appActivityStoreO match { - case Some(appActivityStore) => - appActivityStore.earliestRoundWithCompleteAppActivity().map { - case Some(round) => - ScanResource.GetRewardAccountingEarliestAvailableRoundResponse.OK( - definitions.GetRewardAccountingEarliestAvailableRoundResponse(round) - ) - case None => - ScanResource.GetRewardAccountingEarliestAvailableRoundResponse.NotFound( - ErrorResponse("No reward accounting data available yet") - ) - } + appActivityStore.earliestRoundWithCompleteAppActivity().map { + case Some(round) => + ScanResource.GetRewardAccountingEarliestAvailableRoundResponse.OK( + definitions.GetRewardAccountingEarliestAvailableRoundResponse(round) + ) case None => - Future.successful( - ScanResource.GetRewardAccountingEarliestAvailableRoundResponse.NotFound( - ErrorResponse("Reward accounting is not enabled") - ) + ScanResource.GetRewardAccountingEarliestAvailableRoundResponse.NotFound( + ErrorResponse("No reward accounting data available yet") ) } } @@ -2746,44 +2878,36 @@ class HttpScanHandler( ) ) withSpan(s"$workflowId.getRewardAccountingActivityTotals") { _ => _ => - (appRewardsStoreO, appActivityStoreO) match { - case (Some(appRewardsStore), Some(appActivityStore)) => - appRewardsStore.getAppActivityRoundTotalByRound(roundNumber).flatMap { - case Some(activityTotal) => - appRewardsStore.getAppRewardRoundTotalByRound(roundNumber).map { - case Some(rewardTotal) => - ScanResource.GetRewardAccountingActivityTotalsResponse.OK( - definitions.GetRewardAccountingActivityTotalsResponse( - definitions.RewardAccountingActivityTotalsOk( - status = "Ok", - roundNumber = activityTotal.roundNumber, - totalAppActivityWeight = activityTotal.totalRoundAppActivityWeight, - activePartiesCount = activityTotal.activeAppProviderPartiesCount, - activityRecordsCount = activityTotal.activityRecordsCount, - totalAppRewardMintingAllowance = - rewardTotal.totalAppRewardMintingAllowance.toString, - totalAppRewardThresholded = rewardTotal.totalAppRewardThresholded.toString, - totalAppRewardUnclaimed = rewardTotal.totalAppRewardUnclaimed.toString, - rewardedAppProviderPartiesCount = - rewardTotal.rewardedAppProviderPartiesCount, - ) - ) + appRewardsStore.getAppActivityRoundTotalByRound(roundNumber).flatMap { + case Some(activityTotal) => + appRewardsStore.getAppRewardRoundTotalByRound(roundNumber).map { + case Some(rewardTotal) => + ScanResource.GetRewardAccountingActivityTotalsResponse.OK( + definitions.GetRewardAccountingActivityTotalsResponse( + definitions.RewardAccountingActivityTotalsOk( + status = "Ok", + roundNumber = activityTotal.roundNumber, + totalAppActivityWeight = activityTotal.totalRoundAppActivityWeight, + activePartiesCount = activityTotal.activeAppProviderPartiesCount, + activityRecordsCount = activityTotal.activityRecordsCount, + totalAppRewardMintingAllowance = + rewardTotal.totalAppRewardMintingAllowance.toString, + totalAppRewardThresholded = rewardTotal.totalAppRewardThresholded.toString, + totalAppRewardUnclaimed = rewardTotal.totalAppRewardUnclaimed.toString, + rewardedAppProviderPartiesCount = rewardTotal.rewardedAppProviderPartiesCount, ) - case None => - // We should never hit this, as both activity totals and round - // totals are added in a single DB Tx - undetermined - } + ) + ) case None => - appActivityStore.earliestIngestedRound().map { - case Some(earliestIngested) if roundNumber <= earliestIngested => - cannotProvide - case _ => - undetermined - } + // We should never hit this, as both activity totals and round + // totals are added in a single DB Tx + undetermined + } + case None => + appActivityStore.ingestionStatusForRound(roundNumber).map { + case RoundIngestionStatus.CannotProvide => cannotProvide + case RoundIngestionStatus.Undetermined => undetermined } - case _ => - Future.successful(cannotProvide) } } } @@ -2805,31 +2929,24 @@ class HttpScanHandler( ) ) withSpan(s"$workflowId.getRewardAccountingRootHash") { _ => _ => - (appRewardsStoreO, appActivityStoreO) match { - case (Some(appRewardsStore), Some(appActivityStore)) => - appRewardsStore.getAppRewardRootHashByRound(roundNumber).flatMap { - case Some(rootHash) => - Future.successful( - ScanResource.GetRewardAccountingRootHashResponse.OK( - definitions.GetRewardAccountingRootHashResponse( - definitions.RewardAccountingRootHashOk( - status = "Ok", - roundNumber = rootHash.roundNumber, - rootHash = rootHash.rootHash.toHex, - ) - ) + appRewardsStore.getAppRewardRootHashByRound(roundNumber).flatMap { + case Some(rootHash) => + Future.successful( + ScanResource.GetRewardAccountingRootHashResponse.OK( + definitions.GetRewardAccountingRootHashResponse( + definitions.RewardAccountingRootHashOk( + status = "Ok", + roundNumber = rootHash.roundNumber, + rootHash = rootHash.rootHash.toHex, ) ) - case None => - appActivityStore.earliestIngestedRound().map { - case Some(earliestIngested) if roundNumber <= earliestIngested => - cannotProvide - case _ => - undetermined - } + ) + ) + case None => + appActivityStore.ingestionStatusForRound(roundNumber).map { + case RoundIngestionStatus.CannotProvide => cannotProvide + case RoundIngestionStatus.Undetermined => undetermined } - case _ => - Future.successful(cannotProvide) } } } @@ -2841,50 +2958,41 @@ class HttpScanHandler( ] = { implicit val tc = extracted withSpan(s"$workflowId.getRewardAccountingBatch") { _ => _ => - appRewardsStoreO match { - case None => - Future.successful( + appRewardsStore + .lookupBatchByHash(roundNumber, DbScanAppRewardsStore.RewardHash.fromHex(batchHash)) + .map { + case None => ScanResource.GetRewardAccountingBatchResponse.NotFound( - ErrorResponse("Reward accounting is not enabled on this node") + ErrorResponse( + s"Batch not (yet) found for round $roundNumber with hash $batchHash" + ) ) - ) - case Some(appRewardsStore) => - appRewardsStore - .lookupBatchByHash(roundNumber, DbScanAppRewardsStore.RewardHash.fromHex(batchHash)) - .map { - case None => - ScanResource.GetRewardAccountingBatchResponse.NotFound( - ErrorResponse( - s"Batch not (yet) found for round $roundNumber with hash $batchHash" - ) + case Some(batch: DbScanAppRewardsStore.BatchOfBatches) => + ScanResource.GetRewardAccountingBatchResponse.OK( + definitions.GetRewardAccountingBatchResponse( + definitions.RewardAccountingBatchOfBatches( + batchType = "BatchOfBatches", + childHashes = batch.childHashes.map(_.toHex).toVector, ) - case Some(batch: DbScanAppRewardsStore.BatchOfBatches) => - ScanResource.GetRewardAccountingBatchResponse.OK( - definitions.GetRewardAccountingBatchResponse( - definitions.RewardAccountingBatchOfBatches( - batchType = "BatchOfBatches", - childHashes = batch.childHashes.map(_.toHex).toVector, - ) - ) - ) - case Some(batch: DbScanAppRewardsStore.BatchOfMintingAllowances) => - ScanResource.GetRewardAccountingBatchResponse.OK( - definitions.GetRewardAccountingBatchResponse( - definitions.RewardAccountingBatchOfMintingAllowances( - batchType = "BatchOfMintingAllowances", - mintingAllowances = batch.allowances - .map(a => - definitions.RewardAccountingMintingAllowance( - provider = a.provider, - amount = a.amount.toString, - ) - ) - .toVector, + ) + ) + case Some(batch: DbScanAppRewardsStore.BatchOfMintingAllowances) => + ScanResource.GetRewardAccountingBatchResponse.OK( + definitions.GetRewardAccountingBatchResponse( + definitions.RewardAccountingBatchOfMintingAllowances( + batchType = "BatchOfMintingAllowances", + mintingAllowances = batch.allowances + .map(a => + definitions.RewardAccountingMintingAllowance( + provider = a.provider, + amount = a.amount.toString, + ) ) - ) + .toVector, ) - } - } + ) + ) + } } } } diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/http/ScanHttpEncodings.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/http/ScanHttpEncodings.scala index 3c43d3695a..e8d9b20c5f 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/http/ScanHttpEncodings.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/admin/http/ScanHttpEncodings.scala @@ -636,6 +636,12 @@ object ScanHttpEncodings { ) } + def fromDamlValueEncoding(encoding: definitions.DamlValueEncoding): ScanHttpEncodings = + encoding match { + case definitions.DamlValueEncoding.members.CompactJson => CompactJsonScanHttpEncodings() + case definitions.DamlValueEncoding.members.ProtobufJson => ProtobufJsonScanHttpEncodings + } + def encodeUpdate( update: TreeUpdateWithMigrationId, encoding: definitions.DamlValueEncoding, @@ -656,10 +662,7 @@ object ScanHttpEncodings { externalTransactionHashThresholdTime, ) } - val encodings: ScanHttpEncodings = encoding match { - case definitions.DamlValueEncoding.members.CompactJson => CompactJsonScanHttpEncodings() - case definitions.DamlValueEncoding.members.ProtobufJson => ProtobufJsonScanHttpEncodings - } + val encodings: ScanHttpEncodings = fromDamlValueEncoding(encoding) // v0 always returns the update ids as `#` prefixed,as that's the way they were encoded in canton. v1 returns it without the `#` encodings.lapiToHttpUpdate( update2, diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/AcsSnapshotTriggerBase.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/AcsSnapshotTriggerBase.scala index 063596de69..affced0084 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/AcsSnapshotTriggerBase.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/AcsSnapshotTriggerBase.scala @@ -26,6 +26,7 @@ import org.apache.pekko.stream.Materializer import org.lfdecentralizedtrust.splice.scan.config.ScanStorageConfig import org.lfdecentralizedtrust.splice.store.UpdateHistory import org.lfdecentralizedtrust.splice.store.HistoryMetrics.AcsSnapshotsMetrics +import org.lfdecentralizedtrust.splice.store.db.AdvisoryLocks import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success} @@ -126,7 +127,7 @@ abstract class AcsSnapshotTriggerBase( case Success(result) => snapshotMetrics.waitingForLock.updateValue(0) Success(result) - case Failure(e: AcsSnapshotStore.FailedToAcquireLockException) => + case Failure(e: AdvisoryLocks.FailedToAcquireLockException) => // It is expected that we sometimes fail to acquire the lock on the snapshot table. // The time until the lock is released is typically much larger than our task retry timeouts, // so we can just silently skip the task and try again later. diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanAutomationService.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanAutomationService.scala index e46e2bafab..6bf147e2db 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanAutomationService.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanAutomationService.scala @@ -47,8 +47,8 @@ class ScanAutomationService( protected val loggerFactory: NamedLoggerFactory, store: ScanStore, val updateHistory: UpdateHistory, - appRewardsStoreO: Option[DbScanAppRewardsStore], - appActivityStoreO: Option[AppActivityStore], + appRewardsStore: DbScanAppRewardsStore, + appActivityStore: AppActivityStore, storage: DbStorage, snapshotStore: AcsSnapshotStore, svParty: PartyId, @@ -79,10 +79,7 @@ class ScanAutomationService( def registerRewardComputationTrigger( rewardsReferenceStore: ScanRewardsReferenceStore ): Unit = - for { - appRewardsStore <- appRewardsStoreO - appActivityStore <- appActivityStoreO - } registerTrigger( + registerTrigger( new RewardComputationTrigger( appRewardsStore, appActivityStore, diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanHistoryBackfillingTrigger.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanHistoryBackfillingTrigger.scala index 3876147017..22f9c15175 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanHistoryBackfillingTrigger.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanHistoryBackfillingTrigger.scala @@ -14,11 +14,7 @@ import org.lfdecentralizedtrust.splice.automation.{ import org.lfdecentralizedtrust.splice.config.UpgradesConfig import org.lfdecentralizedtrust.splice.environment.SpliceLedgerClient import org.lfdecentralizedtrust.splice.http.HttpClient -import org.lfdecentralizedtrust.splice.scan.admin.api.client.{ - BackfillingScanConnection, - BftScanConnection, -} -import org.lfdecentralizedtrust.splice.scan.config.ScanAppClientConfig +import org.lfdecentralizedtrust.splice.scan.admin.api.client.BackfillingScanConnection import org.lfdecentralizedtrust.splice.scan.store.ScanHistoryBackfilling.{ FoundingTransactionTreeUpdate, InitialTransactionTreeUpdate, @@ -30,17 +26,19 @@ import org.lfdecentralizedtrust.splice.store.{ HistoryMetrics, ImportUpdatesBackfilling, PageLimit, + TimestampWithMigrationId, TreeUpdateWithMigrationId, UpdateHistory, } import org.lfdecentralizedtrust.splice.util.TemplateJsonDecoder import com.digitalasset.canton.data.CantonTimestamp -import com.digitalasset.canton.lifecycle.{AsyncOrSyncCloseable, SyncCloseable} +import com.digitalasset.canton.lifecycle.{AsyncOrSyncCloseable, LifeCycle} import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} import com.digitalasset.canton.topology.PartyId import com.digitalasset.canton.tracing.TraceContext import io.opentelemetry.api.trace.Tracer import org.apache.pekko.stream.Materializer +import org.lfdecentralizedtrust.splice.scan.util.PeerBftScanConnection import org.lfdecentralizedtrust.splice.store.UpdateHistory.BackfillingState import scala.concurrent.{ExecutionContextExecutor, Future, blocking} @@ -65,6 +63,17 @@ class ScanHistoryBackfillingTrigger( private val currentMigrationId = updateHistory.domainMigrationId + private val scanConnection = new PeerBftScanConnection( + store, + svName, + ledgerClient, + context.config, + upgradesConfig, + context.clock, + context.retryProvider, + loggerFactory, + ) + private val historyMetrics = new HistoryMetrics(context.metricsFactory)( MetricsContext( "current_migration_id" -> currentMigrationId.toString @@ -77,11 +86,7 @@ class ScanHistoryBackfillingTrigger( */ @SuppressWarnings(Array("org.wartremover.warts.Var")) @volatile - private var findHistoryStartAfter: Option[(Long, CantonTimestamp)] = None - - @SuppressWarnings(Array("org.wartremover.warts.Var")) - @volatile - private var connectionVar: Option[BftScanConnection] = None + private var findHistoryStartAfter: Option[TimestampWithMigrationId] = None @SuppressWarnings(Array("org.wartremover.warts.Var")) @volatile @@ -210,7 +215,8 @@ class ScanHistoryBackfillingTrigger( PageLimit.tryCreate(batchSize), ) _ = updates.lastOption.foreach(u => - findHistoryStartAfter = Some(u.migrationId -> u.update.update.recordTime) + findHistoryStartAfter = + Some(TimestampWithMigrationId(u.update.update.recordTime, u.migrationId)) ) result <- if (updates.isEmpty) { @@ -222,34 +228,6 @@ class ScanHistoryBackfillingTrigger( } } - private def getOrCreateScanConnection()(implicit tc: TraceContext): Future[BftScanConnection] = - blocking { - mutex.exclusive { - connectionVar match { - case Some(connection) => - Future.successful(connection) - case None => - for { - connection <- BftScanConnection.peerScanConnection( - () => BftScanConnection.Bft.getPeerScansFromStore(store, svName), - ledgerClient, - // When the network is starting up, the pool of SVs is changing fast - // Using a short refresh interval to quickly pick up new SVs - scansRefreshInterval = context.config.pollingInterval, - amuletRulesCacheTimeToLive = ScanAppClientConfig.DefaultAmuletRulesCacheTimeToLive, - upgradesConfig, - context.clock, - context.retryProvider, - loggerFactory, - ) - } yield { - connectionVar = Some(connection) - connection - } - } - } - } - private def getOrCreateBackfilling( connection: BackfillingScanConnection ): ScanHistoryBackfilling = blocking { @@ -273,7 +251,7 @@ class ScanHistoryBackfillingTrigger( } private def performBackfilling()(implicit traceContext: TraceContext): Future[TaskOutcome] = for { - connection <- getOrCreateScanConnection() + connection <- scanConnection.connection backfilling = getOrCreateBackfilling(connection) outcome <- backfilling.backfill().map { case HistoryBackfilling.Outcome.MoreWorkAvailableNow(workDone) => @@ -305,7 +283,7 @@ class ScanHistoryBackfillingTrigger( private def performImportUpdatesBackfilling()(implicit traceContext: TraceContext ): Future[TaskOutcome] = for { - connection <- getOrCreateScanConnection() + connection <- scanConnection.connection backfilling = getOrCreateBackfilling(connection) outcome <- backfilling.backfillImportUpdates().map { case ImportUpdatesBackfilling.Outcome.MoreWorkAvailableNow(workDone) => @@ -327,21 +305,15 @@ class ScanHistoryBackfillingTrigger( } yield outcome override def closeAsync(): Seq[AsyncOrSyncCloseable] = { - connectionVar - .map(connection => - SyncCloseable( - "closing scan connection", - connection.close(), - ) - ) - .toList + LifeCycle.close(scanConnection)(logger) + super.closeAsync() } } object ScanHistoryBackfillingTrigger { sealed trait Task extends PrettyPrinting final case class InitializeBackfillingTask( - after: Option[(Long, CantonTimestamp)] + after: Option[TimestampWithMigrationId] ) extends Task { override def pretty: Pretty[this.type] = prettyOfClass(param("after", _.after)) diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanVerdictAutomationService.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanVerdictAutomationService.scala index 4341f3e5fe..d40da689ce 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanVerdictAutomationService.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanVerdictAutomationService.scala @@ -34,7 +34,7 @@ class ScanVerdictAutomationService( migrationId: Long, synchronizerId: SynchronizerId, ingestionMetrics: ScanMediatorVerdictIngestionMetrics, - rewardsReferenceStoreO: Option[ScanRewardsReferenceStore], + rewardsReferenceStore: ScanRewardsReferenceStore, )(implicit ec: ExecutionContextExecutor, mat: Materializer, @@ -49,10 +49,8 @@ class ScanVerdictAutomationService( override def companion: AutomationServiceCompanion = ScanVerdictAutomationService - private val appActivityComputationO: Option[AppActivityComputation] = - rewardsReferenceStoreO.map { store => - new AppActivityComputation(store, loggerFactory) - } + private val appActivityComputation: AppActivityComputation = + new AppActivityComputation(rewardsReferenceStore, loggerFactory) registerService( new ScanVerdictIngestionService( @@ -63,7 +61,7 @@ class ScanVerdictAutomationService( migrationId = migrationId, synchronizerId = synchronizerId, ingestionMetrics = ingestionMetrics, - appActivityComputationO = appActivityComputationO, + appActivityComputation = appActivityComputation, backoffClock = triggerContext.pollingClock, retryProvider = triggerContext.retryProvider, loggerFactory = triggerContext.loggerFactory, diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanVerdictIngestionService.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanVerdictIngestionService.scala index 302df6e015..5a8da6401b 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanVerdictIngestionService.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanVerdictIngestionService.scala @@ -74,7 +74,7 @@ class ScanVerdictIngestionService( migrationId: Long, synchronizerId: SynchronizerId, ingestionMetrics: ScanMediatorVerdictIngestionMetrics, - appActivityComputationO: Option[AppActivityComputation], + appActivityComputation: AppActivityComputation, backoffClock: Clock, override protected val retryProvider: RetryProvider, override protected val loggerFactory: NamedLoggerFactory, @@ -107,10 +107,7 @@ class ScanVerdictIngestionService( private def waitForStores(): Future[Unit] = for { _ <- store.waitUntilInitialized - _ <- appActivityComputationO match { - case Some(appActivityComputation) => appActivityComputation.waitUntilInitialized - case None => Future.unit - } + _ <- appActivityComputation.waitUntilInitialized } yield () /** When starting a fresh stream, the record time from which to start streaming */ @@ -130,7 +127,7 @@ class ScanVerdictIngestionService( streamVerdictsAndBatchWithTraffic( ingestionStart, currentMediatorClient, - synchronizerNodes.current.sequencerTrafficClient, + Some(synchronizerNodes.current.sequencerTrafficClient), ) val completedWithCompleteF = Promise[Option[v30.VerdictsResponse.Complete]]() val source = currentSource @@ -157,7 +154,7 @@ class ScanVerdictIngestionService( streamVerdictsAndBatchWithTraffic( successorIngestionStart, successorMediatorClient, - synchronizerNodes.successor.flatMap(_.sequencerTrafficClient), + synchronizerNodes.successor.map(_.sequencerTrafficClient), ) .mapMaterializedValue(_ => NotUsed) case None => @@ -243,30 +240,35 @@ class ScanVerdictIngestionService( // Compute app activity records (before DB transaction). // Records have verdictRowId = DUMMY_VERDICT_ROW_ID // the store resolves actual row_ids during insertion. - (appActivityRecords, lastArchivedRoundO) <- appActivityComputationO match { - case Some(appActivityComputation) => - for { - records <- appActivityComputation.computeActivities(summariesWithVerdicts).map { - _.flatMap { case (summary, _, recordO) => - recordO.map(summary.sequencingTime -> _) - } - } - lastArchivedRoundO <- verdicts - .map(v => CantonTimestamp.tryFromProtoTimestamp(v.getRecordTime)) - .maxOption match { - case Some(maxRecordTime) => - appActivityComputation.lookupLatestArchivedOpenMiningRound(maxRecordTime) - case None => Future.successful(None) + (appActivityRecords, firstActiveRoundO, lastArchivedRoundO) <- { + val recordTimes = + verdicts.map(v => CantonTimestamp.tryFromProtoTimestamp(v.getRecordTime)) + for { + records <- appActivityComputation.computeActivities(summariesWithVerdicts).map { + _.flatMap { case (summary, _, recordO) => + recordO.map(summary.sequencingTime -> _) } - } yield (records, lastArchivedRoundO) - case None => Future.successful((Seq.empty, None)) + } + firstActiveRoundO <- recordTimes.minOption match { + case Some(minRecordTime) => + appActivityComputation.lookupActiveOpenMiningRound(minRecordTime) + case None => Future.successful(None) + } + lastArchivedRoundO <- recordTimes.maxOption match { + case Some(maxRecordTime) => + appActivityComputation.lookupLatestArchivedOpenMiningRound(maxRecordTime) + case None => Future.successful(None) + } + } yield (records, firstActiveRoundO, lastArchivedRoundO) } _ <- ensureVerdictsHaveTrafficSummaries(verdicts, summaryByTime) _ <- store.insertVerdictsWithAppActivityRecords( items, appActivityRecords, - lastArchivedRoundO, + hasTrafficSummaries = summaryByTime.nonEmpty, + firstActiveRoundO = firstActiveRoundO, + lastArchivedRoundO = lastArchivedRoundO, ) } yield { val lastRecordTime = verdicts.lastOption @@ -354,10 +356,7 @@ class ScanVerdictIngestionService( verdicts: Seq[v30.Verdict], summaryByTime: Map[CantonTimestamp, DbScanVerdictStore.TrafficSummaryT], )(implicit tc: TraceContext): Future[Unit] = - (store.appActivityRecordStoreO match { - case None => Future.successful(None) - case Some(s) => s.startedIngestingAt - }).map { startO => + store.appActivityRecordStore.startedIngestingAt.map { startO => val missingTimes = ScanVerdictIngestionService.findMissingTrafficSummaries( verdicts.map(v => CantonTimestamp.tryFromProtoTimestamp(v.getRecordTime)), summaryByTime.keySet, diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/config/CantonBftPeerConfig.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/config/CantonBftPeerConfig.scala index 6b979175a5..373ab29fb4 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/config/CantonBftPeerConfig.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/config/CantonBftPeerConfig.scala @@ -3,6 +3,8 @@ package org.lfdecentralizedtrust.splice.scan.config +import org.apache.pekko.http.scaladsl.model.Uri + case class CantonBftPeerConfig( - p2pUrl: String + p2pUrl: Uri ) diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/config/ScanAppConfig.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/config/ScanAppConfig.scala index d9c87180c5..423efc8d51 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/config/ScanAppConfig.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/config/ScanAppConfig.scala @@ -47,6 +47,22 @@ final case class BulkStorageConfig( maxParallelPartUploads: Int = 4, staging: Option[S3Config] = None, committed: Option[S3Config] = None, + bftCheckEnabled: Boolean = true, + /** When enabled, the app will reset all progress markers thus force recomputing data from genesis. + * Note that this does not delete any existing data, you usually would want to do that before setting + * this flag. Also, after restarting the app once with this flag enabled, you'd want to disable it back + * to avoid having the markers reset on every restart. + * TODO(#6251): this makes sense for initial stages of testing&deploying bulk storage, in case of + * encountered issues, but will not make sense when we start pruning the data from scan. We should remove + * this before starting to prune data. + */ + debugForceStartFromGenesis: Boolean = false, + /** A list of S3 object keys that this instance should not save to the committed bucket, and instead only + * delete from staging. To be used only in extreme cases where we decide to accept a BFT disagreement, + * and have the (minority of) disagreeing instances simply skip the broken objects. + * Should typically be used in test environments only. + */ + debugObjectsToNotCommit: Seq[String] = Seq.empty, ) /** @param miningRoundsCacheTimeToLiveOverride Intended only for testing! @@ -61,8 +77,6 @@ case class ScanAppBackendConfig( synchronizerNodes: ScanSynchronizerNodesConfig, override val automation: AutomationConfig = AutomationConfig(), mediatorVerdictIngestion: MediatorVerdictIngestionConfig = MediatorVerdictIngestionConfig(), - enableAppActivityRecordAndTrafficIngestion: Boolean = true, - serveAppActivityRecordsAndTraffic: Boolean = true, isFirstSv: Boolean = false, // Max rounding error tolerated wrt actual total of minting allowances // and the per-round minting allowance from the CC whitepaper. diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/config/ScanStorageConfig.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/config/ScanStorageConfig.scala index 9eaa7a0e10..1655bc8eee 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/config/ScanStorageConfig.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/config/ScanStorageConfig.scala @@ -3,7 +3,9 @@ package org.lfdecentralizedtrust.splice.scan.config +import cats.data.NonEmptyList import com.digitalasset.canton.data.CantonTimestamp +import org.lfdecentralizedtrust.splice.http.v0.definitions import org.lfdecentralizedtrust.splice.scan.store.AcsSnapshotStore.{ AcsSnapshot, IncrementalAcsSnapshot, @@ -11,6 +13,7 @@ import org.lfdecentralizedtrust.splice.scan.store.AcsSnapshotStore.{ import java.time.{Duration, Instant, ZoneOffset} import java.time.temporal.{ChronoField, ChronoUnit} +import scala.util.matching.Regex /** Note that these configurations must be kept consistent between SVs, * so they are not configured via a local config file in Scan. Instead, they must be voted on. @@ -136,6 +139,26 @@ case class ScanStorageConfig( } +object ScanStorageConfig { + sealed abstract class Encoding( + val key: String, + val damlValueEncoding: definitions.DamlValueEncoding, + ) { + final def storageKey(prefix: String, index: Int): String = s"${prefix}_${key}_$index.zstd" + + final def storageKeyRegex(prefix: String): Regex = + (".*" + Regex.quote(prefix) + "_" + Regex.quote(key) + "_\\d+\\.zstd").r + } + object Encoding { + case object CompactJson + extends Encoding("compact_json", definitions.DamlValueEncoding.CompactJson) + case object ProtobufJson + extends Encoding("protobuf_json", definitions.DamlValueEncoding.ProtobufJson) + + lazy val all: NonEmptyList[Encoding] = NonEmptyList.of[Encoding](CompactJson, ProtobufJson) + } +} + object ScanStorageConfigs { val scanStorageConfigV1 = ScanStorageConfig( dbAcsSnapshotPeriodHours = 3, diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/config/TokenStandardConfig.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/config/TokenStandardConfig.scala index a0630dcdea..ab0b50830f 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/config/TokenStandardConfig.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/config/TokenStandardConfig.scala @@ -14,6 +14,7 @@ object TokenStandardConfig { final case class SettlementConfig( maxLegs: Int = 100, maxParties: Int = 100, + maxAllocations: Int = 100, ) { def validateSettleBatch(settleBatch: allocationv2.SettlementFactory_SettleBatch): Unit = { val numTransferLegs = settleBatch.transferLegs.size() @@ -24,6 +25,9 @@ object TokenStandardConfig { .distinct .size validateNumParties(numParties) + + val numAllocations = settleBatch.allocations.size() + validateNumAllocations(numAllocations) } def validateAllocate(allocate: allocationinstructionv2.AllocationFactory_Allocate): Unit = { @@ -56,5 +60,14 @@ object TokenStandardConfig { .asRuntimeException() } } + private def validateNumAllocations(numAllocations: Int) = { + if (numAllocations > maxAllocations) { + throw io.grpc.Status.INVALID_ARGUMENT + .withDescription( + s"Too many allocations in the settle batch: $numAllocations. Maximum allowed: $maxAllocations" + ) + .asRuntimeException() + } + } } } diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/metrics/ScanAppMetrics.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/metrics/ScanAppMetrics.scala index c4d9af350b..57c32761fa 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/metrics/ScanAppMetrics.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/metrics/ScanAppMetrics.scala @@ -6,8 +6,8 @@ package org.lfdecentralizedtrust.splice.scan.metrics import com.daml.metrics.api.MetricHandle.LabeledMetricsFactory import com.digitalasset.canton.config.ProcessingTimeout import com.digitalasset.canton.logging.NamedLoggerFactory -import com.digitalasset.canton.metrics.DbStorageHistograms import org.lfdecentralizedtrust.splice.BaseSpliceMetrics +import com.digitalasset.canton.metrics.DbStorageHistograms import org.lfdecentralizedtrust.splice.scan.store.db.DbScanStoreMetrics /** Modelled after [[com.digitalasset.canton.synchronizer.metrics.DomainMetrics]]. diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/rewards/AppActivityComputation.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/rewards/AppActivityComputation.scala index 30b4b9ede5..2181d3fcd6 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/rewards/AppActivityComputation.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/rewards/AppActivityComputation.scala @@ -11,6 +11,7 @@ import com.digitalasset.daml.lf.data.Numeric import com.digitalasset.daml.lf.data.{assertRight as damlRight} import org.lfdecentralizedtrust.splice.scan.store.ScanRewardsReferenceStore import org.lfdecentralizedtrust.splice.scan.store.db.{DbAppActivityRecordStore, DbScanVerdictStore} +import org.lfdecentralizedtrust.splice.store.TimestampWithMigrationId import java.math.RoundingMode import scala.collection.immutable.SortedMap @@ -49,6 +50,14 @@ class AppActivityComputation( )(implicit tc: TraceContext): Future[Option[Long]] = rewardsReferenceStore.lookupLatestArchivedOpenMiningRound(asOf) + /** The OpenMiningRound round number active at asOf, if the round data has been ingested. */ + def lookupActiveOpenMiningRound( + asOf: CantonTimestamp + )(implicit tc: TraceContext): Future[Option[Long]] = + rewardsReferenceStore + .lookupActiveOpenMiningRounds(Seq(asOf)) + .map(_.get(asOf).map { case TimestampWithMigrationId(_, roundNumber) => roundNumber }) + /** Compute app activity records for a batch of verdicts. * * Records are returned with verdictRowId = DUMMY_VERDICT_ROW_ID as a placeholder. @@ -96,7 +105,7 @@ class AppActivityComputation( Future.successful((summary, verdict, None)) case (summary, verdict, true) => roundInfoByTime.get(summary.sequencingTime) match { - case Some((roundNumber, roundOpensAt)) => + case Some(TimestampWithMigrationId(roundOpensAt, roundNumber)) => for { featuredAppWeights <- rewardsReferenceStore.lookupFeaturedAppPartiesAsOf( roundOpensAt @@ -125,8 +134,12 @@ class AppActivityComputation( } case None => // Skip activity record computation as we don't have the necessary round data ingested. - // This can happen for freshly onboarded SVs, but is not - // expected to happen once the first activity record has been computed. + // This can happen for freshly onboarded SVs if the reward + // reference store does not have the data for any of the + // sequencingTime(s) in this batch. + // OTOH this cannot happen after ingestion starts because + // lookupActiveOpenMiningRounds blocks until the reference store + // has caught up to all the sequencingTime(s) in this batch. logger.debug( s"No round data found for sequencingTime=${summary.sequencingTime}, skipping activity record computation" ) diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/AcsSnapshotStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/AcsSnapshotStore.scala index b5dfc209a0..81721f9393 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/AcsSnapshotStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/AcsSnapshotStore.scala @@ -8,16 +8,21 @@ import com.daml.ledger.javaapi.data.CreatedEvent import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.{Amulet, LockedAmulet} import org.lfdecentralizedtrust.splice.scan.store.AcsSnapshotStore.{ AcsSnapshot, - FailedToAcquireLockException, IncrementalAcsSnapshot, IncrementalAcsSnapshotTable, + QueryAcsSnapshotPaginationToken, QueryAcsSnapshotResult, amuletQualifiedName, lockedAmuletQualifiedName, } import org.lfdecentralizedtrust.splice.store.UpdateHistory.SelectFromCreateEvents import org.lfdecentralizedtrust.splice.store.{HardLimit, Limit, LimitHelpers, UpdateHistory} -import org.lfdecentralizedtrust.splice.store.db.{AcsJdbcTypes, AcsQueries, AdvisoryLockIds} +import org.lfdecentralizedtrust.splice.store.db.{ + AcsJdbcTypes, + AcsQueries, + AdvisoryLockIds, + AdvisoryLocks, +} import org.lfdecentralizedtrust.splice.util.{Contract, HoldingsSummary, PackageQualifiedName} import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.lifecycle.{CloseContext, FutureUnlessShutdown} @@ -34,6 +39,7 @@ import slick.jdbc.canton.ActionBasedSQLInterpolation.Implicits.actionBasedSQLInt import slick.jdbc.canton.SQLActionBuilder import slick.jdbc.{GetResult, JdbcProfile} +import java.nio.charset.StandardCharsets import java.util.concurrent.Semaphore import scala.concurrent.{ExecutionContext, Future} @@ -220,22 +226,7 @@ class AcsSnapshotStore( private def withExclusiveSnapshotDataLock[T, E <: Effect]( action: DBIOAction[T, NoStream, E] ): DBIOAction[T, NoStream, Effect.Read & Effect.Transactional & E] = - (for { - lockResult <- sql"SELECT pg_try_advisory_xact_lock(${AdvisoryLockIds.acsSnapshotDataInsert})" - .as[Boolean] - .head - result <- lockResult match { - case true => action - // Lock conflicts can happen: - // - In production, if the application crashes while writing a snapshot and then restarts - // and tries to write another snapshot before the database has closed the connection and released the lock. - // - In production, if two triggers (ingesting and backfilling) happen to write a snapshot at the same time. - // - In testing, where multiple scan instances write to the same app database. - // In all cases, we want to fail immediately, and rely on the caller's infrastructure to retry. - case false => - DBIOAction.failed(FailedToAcquireLockException()) - } - } yield result).transactionally + AdvisoryLocks.withTransactionalLock(profile, AdvisoryLockIds.acsSnapshotDataInsert, action) def deleteSnapshot( snapshot: AcsSnapshot @@ -252,7 +243,7 @@ class AcsSnapshotStore( def queryAcsSnapshot( migrationId: Long, snapshot: CantonTimestamp, - after: Option[Long], + after: Option[QueryAcsSnapshotPaginationToken], limit: Limit, partyIds: Seq[PartyId], templates: Seq[PackageQualifiedName], @@ -278,7 +269,11 @@ class AcsSnapshotStore( ) ) begin <- after match { - case Some(value) if value < snapshot.firstRowId || value > snapshot.lastRowId => + case Some( + AcsSnapshotStore.QueryAcsSnapshotPaginationToken.RowIdQueryAcsSnapshotPaginationToken( + value + ) + ) if value < snapshot.firstRowId || value > snapshot.lastRowId => Future.failed( io.grpc.Status.INVALID_ARGUMENT .withDescription( @@ -286,7 +281,12 @@ class AcsSnapshotStore( ) .asRuntimeException() ) - case Some(value) => Future.successful(value + 1) + case Some( + AcsSnapshotStore.QueryAcsSnapshotPaginationToken.RowIdQueryAcsSnapshotPaginationToken( + value + ) + ) => + Future.successful(value + 1) case None => Future.successful(snapshot.firstRowId) } end = snapshot.lastRowId @@ -356,7 +356,9 @@ class AcsSnapshotStore( migrationId = migrationId, snapshotRecordTime = snapshot.snapshotRecordTime, createdEventsInPage = eventsInPage, - afterToken = afterToken, + afterToken = afterToken.map( + AcsSnapshotStore.QueryAcsSnapshotPaginationToken.RowIdQueryAcsSnapshotPaginationToken(_) + ), ) } } @@ -364,7 +366,7 @@ class AcsSnapshotStore( def getHoldingsState( migrationId: Long, snapshot: CantonTimestamp, - after: Option[Long], + after: Option[QueryAcsSnapshotPaginationToken], limit: Limit, partyIds: NonEmptyVector[PartyId], )(implicit tc: TraceContext): Future[QueryAcsSnapshotResult] = { @@ -799,11 +801,6 @@ class AcsSnapshotStore( object AcsSnapshotStore { - case class FailedToAcquireLockException() - extends RuntimeException( - "Failed to acquire advisory lock for writing to the acs snapshot table." - ) - sealed trait IncrementalAcsSnapshotTable { def tableName: String } object IncrementalAcsSnapshotTable { case object Next extends IncrementalAcsSnapshotTable { @@ -922,11 +919,50 @@ object AcsSnapshotStore { ) } + sealed trait QueryAcsSnapshotPaginationToken { + def encodeToBase64: String = { + val jsonString = QueryAcsSnapshotPaginationToken.codec(this).noSpaces + java.util.Base64.getEncoder.encodeToString(jsonString.getBytes(StandardCharsets.UTF_8)) + } + } + object QueryAcsSnapshotPaginationToken { + case class RowIdQueryAcsSnapshotPaginationToken(after: Long) + extends QueryAcsSnapshotPaginationToken + + private val codec: io.circe.Codec[QueryAcsSnapshotPaginationToken] = + io.circe.Codec + .from(io.circe.Decoder[Long], io.circe.Encoder[Long]) + .iemap[QueryAcsSnapshotPaginationToken]((token: Long) => + Right(RowIdQueryAcsSnapshotPaginationToken(token)) + ) { case RowIdQueryAcsSnapshotPaginationToken(after) => after } + + def tryDecodeFromBase64(token: String): QueryAcsSnapshotPaginationToken = { + import cats.implicits.* + + (for { + decodedString <- scala.util + .Try { + val decodedBytes = java.util.Base64.getDecoder.decode(token) + new String(decodedBytes, StandardCharsets.UTF_8) + } + .toEither + .leftMap(_ => "Failed to decode base64 token") + decoded <- io.circe.parser.decode(decodedString)(codec).leftMap(_.getMessage) + } yield decoded).fold( + msg => + throw io.grpc.Status.INVALID_ARGUMENT + .withDescription(msg) + .asRuntimeException(), + identity, + ) + } + } + case class QueryAcsSnapshotResult( migrationId: Long, snapshotRecordTime: CantonTimestamp, createdEventsInPage: Vector[SpliceCreatedEvent], - afterToken: Option[Long], + afterToken: Option[QueryAcsSnapshotPaginationToken], ) private val amuletQualifiedName = diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/AppActivityStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/AppActivityStore.scala index 2b72c75e71..cba770a875 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/AppActivityStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/AppActivityStore.scala @@ -12,21 +12,16 @@ import scala.concurrent.Future */ trait AppActivityStore { - /** Find the earliest round for which all app activity records have been ingested. + /** Ingestion status for a specific round, used by the Scan HTTP + * endpoints when no root hash or activity totals are yet stored. */ - def earliestRoundWithCompleteAppActivity()(implicit + def ingestionStatusForRound(roundNumber: Long)(implicit tc: TraceContext - ): Future[Option[Long]] + ): Future[AppActivityStore.RoundIngestionStatus] - /** The earliest round for which we have ingested app activity records. - * This round may not have all app activity records ingested. - * - * Returns None if no app activity records have been ingested. - * - * Return -1 for the first SV, if the ingestion started from beginning of round 0, - * indicating that this SV has complete data of round 0. + /** Find the earliest round for which all app activity records have been ingested. */ - def earliestIngestedRound()(implicit + def earliestRoundWithCompleteAppActivity()(implicit tc: TraceContext ): Future[Option[Long]] @@ -39,3 +34,24 @@ trait AppActivityStore { /** The record time of the first activity record in the store. */ def startedIngestingAt(implicit tc: TraceContext): Future[Option[Long]] } + +object AppActivityStore { + + /** Whether this Scan can ever be authoritative for a given round, + * or whether the answer will arrive as ingestion catches up. + */ + sealed trait RoundIngestionStatus + + object RoundIngestionStatus { + + /** Cannot compute an answer for this round from local state. + * Callers should delegate to BFT read. + */ + case object CannotProvide extends RoundIngestionStatus + + /** Do not yet have an answer but expect to have one after + * ingesting up to this round. Callers should retry. + */ + case object Undetermined extends RoundIngestionStatus + } +} diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/CachingScanRewardsReferenceStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/CachingScanRewardsReferenceStore.scala index 244ae565e4..9dda395ffd 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/CachingScanRewardsReferenceStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/CachingScanRewardsReferenceStore.scala @@ -10,7 +10,12 @@ import com.digitalasset.canton.tracing.TraceContext import com.github.blemale.scaffeine.Scaffeine import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.rewardaccountingv2.CalculateRewardsV2 import org.lfdecentralizedtrust.splice.codegen.java.splice.round.OpenMiningRound -import org.lfdecentralizedtrust.splice.store.{Limit, MultiDomainAcsStore, SynchronizerStore} +import org.lfdecentralizedtrust.splice.store.{ + Limit, + MultiDomainAcsStore, + SynchronizerStore, + TimestampWithMigrationId, +} import org.lfdecentralizedtrust.splice.util.Contract import scala.concurrent.{ExecutionContext, Future} @@ -52,7 +57,7 @@ class CachingScanRewardsReferenceStore private[splice] ( override def lookupActiveOpenMiningRounds( recordTimes: Seq[CantonTimestamp] - )(implicit tc: TraceContext): Future[Map[CantonTimestamp, (Long, CantonTimestamp)]] = + )(implicit tc: TraceContext): Future[Map[CantonTimestamp, TimestampWithMigrationId]] = store.lookupActiveOpenMiningRounds(recordTimes) override def lookupFeaturedAppPartiesAsOf( diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/CachingScanStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/CachingScanStore.scala index 9206db972d..dec98aef00 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/CachingScanStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/CachingScanStore.scala @@ -35,12 +35,11 @@ import org.lfdecentralizedtrust.splice.store.{ Limit, MiningRoundsStore, MultiDomainAcsStore, - PageLimit, ResultsPage, - SortOrder, SynchronizerStore, TxLogStore, UpdateHistory, + VoteResultsFilters, } import org.lfdecentralizedtrust.splice.util.{Contract, ContractWithState} @@ -191,17 +190,6 @@ class CachingScanStore( store.lookupTransferCommandCounterByParty, ).get(partyId) - override def listTransactions( - pageEndEventId: Option[String], - sortOrder: SortOrder, - limit: PageLimit, - )(implicit tc: TraceContext): Future[Seq[TxLogEntry.TransactionTxLogEntry]] = - store.listTransactions( - pageEndEventId, - sortOrder, - limit, - ) - override def lookupLatestTransferCommandEvents(sender: PartyId, nonce: Long, limit: Int)(implicit tc: TraceContext ): Future[Map[TransferCommand.ContractId, TransferCommandTxLogEntry]] = @@ -221,11 +209,7 @@ class CachingScanStore( ) override def listVoteRequestResults( - actionName: Option[String], - accepted: Option[Boolean], - requester: Option[String], - effectiveFrom: Option[String], - effectiveTo: Option[String], + filters: VoteResultsFilters, limit: Limit, after: Option[Long] = None, )(implicit tc: TraceContext): Future[ResultsPage[DsoRules_CloseVoteRequestResult]] = @@ -235,16 +219,21 @@ class CachingScanStore( store.listVoteRequestResults _ tupled, ).get( ( - actionName, - accepted, - requester, - effectiveFrom, - effectiveTo, + filters, limit, after, ) ) + override def countVoteRequestResults( + filters: VoteResultsFilters + )(implicit tc: TraceContext): Future[Long] = + getCache( + "countVoteRequestResults", + cacheConfig.voteRequests, + (f: VoteResultsFilters) => store.countVoteRequestResults(f), + ).get(filters) + override def listVoteRequestsByTrackingCid( voteRequestCids: Seq[VoteRequest.ContractId], limit: Limit, diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanEventStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanEventStore.scala index b2bc6d8b23..d7d378c32b 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanEventStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanEventStore.scala @@ -10,7 +10,7 @@ import org.lfdecentralizedtrust.splice.scan.store.db.{DbAppActivityRecordStore, import org.lfdecentralizedtrust.splice.store.TreeUpdateWithMigrationId import org.lfdecentralizedtrust.splice.store.UpdateHistory import com.digitalasset.canton.data.CantonTimestamp -import org.lfdecentralizedtrust.splice.store.PageLimit +import org.lfdecentralizedtrust.splice.store.{PageLimit, TimestampWithMigrationId} import scala.collection.immutable.SortedMap import scala.concurrent.{ExecutionContext, Future} @@ -60,7 +60,7 @@ class ScanEventStore( } def getEvents( - afterO: Option[(Long, CantonTimestamp)], + afterO: Option[TimestampWithMigrationId], currentMigrationId: Long, limit: PageLimit, )(implicit tc: TraceContext): Future[Seq[Event]] = { @@ -88,9 +88,9 @@ class ScanEventStore( verdictStore.listTransactionViews(v.rowId).map(views => v -> views) ) } yield { - val verdictEntries: Iterator[((Long, CantonTimestamp), Verdict)] = + val verdictEntries: Iterator[(TimestampWithMigrationId, Verdict)] = verdictsWithViews.iterator.map { case (v, views) => - val k = (v.migrationId, v.recordTime) + val k = TimestampWithMigrationId(v.recordTime, v.migrationId) k -> (v -> views) } @@ -100,11 +100,11 @@ class ScanEventStore( val mergedSorted = { val fromUpdates = filteredUpdates.iterator.foldLeft( SortedMap.empty[ - (Long, CantonTimestamp), + TimestampWithMigrationId, (Option[Verdict], Option[TreeUpdateWithMigrationId]), ] ) { case (acc, u) => - val k = (u.migrationId, u.update.update.recordTime) + val k = TimestampWithMigrationId(u.update.update.recordTime, u.migrationId) acc.updated(k, (None, Some(u))) } verdictEntries.foldLeft(fromUpdates) { case (acc, (k, v)) => @@ -125,10 +125,7 @@ class ScanEventStore( def getAppActivityRecords(verdictRowIds: Seq[Long])(implicit tc: TraceContext ): Future[Map[Long, AppActivityRecordT]] = - verdictStore.appActivityRecordStoreO match { - case Some(store) => store.getRecordsByVerdictRowIds(verdictRowIds) - case None => Future.successful(Map.empty) - } + verdictStore.appActivityRecordStore.getRecordsByVerdictRowIds(verdictRowIds) // Get values from in-memory refs, fallsback to DB read private def resolveCurrentMigrationCap( @@ -154,12 +151,12 @@ class ScanEventStore( // Filtering logic extracted out for unit testing object ScanEventStore { def allowF( - afterO: Option[(Long, CantonTimestamp)], + afterO: Option[TimestampWithMigrationId], currentMigrationId: Long, currentMigrationCap: CantonTimestamp, )(mig: Long, rt: CantonTimestamp): Boolean = { afterO match { - case Some((afterMig, afterRt)) if mig == afterMig => + case Some(TimestampWithMigrationId(afterRt, afterMig)) if mig == afterMig => if (mig < currentMigrationId) rt > afterRt else rt > afterRt && rt <= currentMigrationCap case _ if mig < currentMigrationId => diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanRewardsReferenceStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanRewardsReferenceStore.scala index 505fcae739..56e7e93f8b 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanRewardsReferenceStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanRewardsReferenceStore.scala @@ -16,7 +16,12 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.round.OpenMiningRound import org.lfdecentralizedtrust.splice.config.IngestionConfig import org.lfdecentralizedtrust.splice.environment.RetryProvider import org.lfdecentralizedtrust.splice.scan.store.db.ScanRewardsReferenceTables.ScanRewardsReferenceStoreRowData -import org.lfdecentralizedtrust.splice.store.{AppStore, Limit, MultiDomainAcsStore} +import org.lfdecentralizedtrust.splice.store.{ + AppStore, + Limit, + MultiDomainAcsStore, + TimestampWithMigrationId, +} import org.lfdecentralizedtrust.splice.store.db.AcsInterfaceViewRowData import org.lfdecentralizedtrust.splice.util.{Contract, TemplateJsonDecoder} @@ -57,7 +62,7 @@ trait ScanRewardsReferenceStore extends AppStore { */ def lookupActiveOpenMiningRounds( recordTimes: Seq[CantonTimestamp] - )(implicit tc: TraceContext): Future[Map[CantonTimestamp, (Long, CantonTimestamp)]] + )(implicit tc: TraceContext): Future[Map[CantonTimestamp, TimestampWithMigrationId]] def lookupFeaturedAppPartiesAsOf( asOf: CantonTimestamp @@ -189,6 +194,17 @@ object ScanRewardsReferenceStore { round = Some(contract.payload.round.number), ) }, + mkFilter(splice.amulet.rewardaccountingv2.ProcessRewardsV2.COMPANION)( + co => co.payload.dso == dso, + versionGuard = { case (pkgVersionSupport, now) => + (tc) => pkgVersionSupport.supportsTrafficBasedAppRewards(Seq(key.dsoParty), now)(tc) + }, + ) { contract => + ScanRewardsReferenceStoreRowData( + contract = contract, + round = Some(contract.payload.round.number), + ) + }, ), interfaceFilters = Map.empty, synchronizerFilter = Some(key.synchronizerId), diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanStore.scala index fbe1500723..60c9f7e649 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanStore.scala @@ -30,8 +30,6 @@ import org.lfdecentralizedtrust.splice.store.{ ExternalPartyConfigStateStore, MiningRoundsStore, MultiDomainAcsStore, - PageLimit, - SortOrder, TxLogAppStore, UpdateHistory, VotesStore, @@ -215,14 +213,6 @@ trait ScanStore ]] ] - def listTransactions( - pageEndEventId: Option[String], - sortOrder: SortOrder, - limit: PageLimit, - )(implicit - tc: TraceContext - ): Future[Seq[TxLogEntry.TransactionTxLogEntry]] - def lookupLatestTransferCommandEvents( sender: PartyId, nonce: Long, diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanTxLogParser.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanTxLogParser.scala index a33c651e98..47a6e1f913 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanTxLogParser.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanTxLogParser.scala @@ -9,10 +9,6 @@ import com.daml.ledger.javaapi.data.* import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.topology.{PartyId, SynchronizerId} import com.digitalasset.canton.tracing.TraceContext -import io.grpc.Status -import org.lfdecentralizedtrust.splice.codegen.java.splice -import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.AmuletCreateSummary -import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletrules.TransferResult import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.{ DsoRules_CloseVoteRequest, DsoRules_CloseVoteRequestResult, @@ -21,23 +17,13 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.externalpartyamuletru TransferCommandResultFailure, TransferCommandResultSuccess, } -import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.subscriptions as sws import org.lfdecentralizedtrust.splice.history.* -import org.lfdecentralizedtrust.splice.scan.store.TxLogEntry.* import org.lfdecentralizedtrust.splice.store.TxLogStore import org.lfdecentralizedtrust.splice.store.events.DsoRulesCloseVoteRequest import org.lfdecentralizedtrust.splice.util.SpliceUtil.dollarsToCC -import org.lfdecentralizedtrust.splice.util.TransactionTreeExtensions.* -import org.lfdecentralizedtrust.splice.util.{ - Codec, - EventId, - ExerciseNode, - LegacyOffset, - TokenStandardMetadata, -} +import org.lfdecentralizedtrust.splice.util.{Codec, EventId, ExerciseNode} import scala.collection.immutable -import scala.jdk.OptionConverters.* import scala.jdk.CollectionConverters.* import scala.math.BigDecimal.javaBigDecimal2bigDecimal @@ -81,387 +67,6 @@ class ScanTxLogParser( exercised.getNodeId, ) exercised match { - case Transfer(node) => - State.fromTransfer(tree, exercised, synchronizerId, node) - case TransferPreapproval_Send(node) => - val state = parseTrees( - tree, - synchronizerId, - tree.getChildNodeIds(exercised).asScala.toList, - ignoreUnexpectedAmuletCreateArchive, - ) - state.setTransferPreapprovalSendFields(tree, exercised, node.argument.value.description) - case TransferPreapproval_SendV2(node) => - val receiver = (node.result.value.result.summary.balanceChanges.asScala.keySet - .diff(Set(node.argument.value.sender))) - .headOption - .getOrElse(node.argument.value.sender) - val output = new splice.amuletrules.TransferOutput( - receiver, - BigDecimal(0).bigDecimal, // receiver fee ratio is irrelevant, there are no fees - node.argument.value.amount, - java.util.Optional.empty(), // lock - java.util.Optional.empty(), // meta - ) - val state = State.fromTransferResult( - tree, - exercised, - synchronizerId, - sender = node.argument.value.sender, - outputs = Seq(output), - result = node.result.value.result, - ) - val description = - node.result.value.meta.values.asScala.get(TokenStandardMetadata.reasonMetaKey) - state.setTransferPreapprovalSendFields(tree, exercised, description.toJava) - case CreateTokenStandardTransferInstruction(node) => - // TODO(tech-debt): remove this duplication with CreateTokenStandardTransferInstructionV2 - val cid: String = node.result.value.output match { - case output: splice.api.token.transferinstructionv1.transferinstructionresult_output.TransferInstructionResult_Pending => - output.transferInstructionCid.contractId - case output => - // CreateTokenStandardTransferInstruction only matches on two-step transfers resulting in pending status. - // Single-step transfers are just parsed as the underlying transfer. - logger.warn( - s"Unexpected transfer instruction result output, expected pending but got: $output" - ) - "" - } - val state = parseTrees( - tree, - synchronizerId, - tree.getChildNodeIds(exercised).asScala.toList, - ignoreUnexpectedAmuletCreateArchive = true, - ) - val stateWithTransfer = if (state.hasTransfer) { - // We hit this for transfers before the 24h signing change that call AmuletRules_Transfer internally. - state - } else { - val txLogEntry = new TransferTxLogEntry( - offset = LegacyOffset.Api.fromLong(tree.getOffset), - domainId = synchronizerId, - date = Some(tree.getEffectiveAt), - sender = Some( - senderAmountNoFees( - node.argument.value.transfer.sender, - 0.0, // Note: Because Scan tracks the sum of locked and unlocked input and output is 0. - ) - ), - // receiver is set to the sender as the amulet is locked to them - receivers = Seq( - receiverAmountNoFees( - node.argument.value.transfer.sender, - node.argument.value.transfer.amount, - ) - ), - balanceChanges = Seq.empty, - ) - State( - txLogEntry - ) - } - stateWithTransfer.copy( - entries = stateWithTransfer.entries.map { - case e: TransferTxLogEntry => - e.copy( - description = node.argument.value.transfer.meta.values - .getOrDefault(TokenStandardMetadata.reasonMetaKey, ""), - transferInstructionReceiver = node.argument.value.transfer.receiver, - transferInstructionAmount = Some(node.argument.value.transfer.amount), - transferInstructionCid = cid, - eventId = - EventId.prefixedFromUpdateIdAndNodeId(tree.getUpdateId, exercised.getNodeId), - transferKind = TransferKind.TRANSFER_KIND_CREATE_TRANSFER_INSTRUCTION, - ) - case e => e - } - ) - case DirectTokenStandardTransfer(node) => - // TODO(tech-debt): remove this duplication with DirectTokenStandardTransferV2 - val sender = node.argument.value.transfer.sender - val receiver = node.argument.value.transfer.receiver - val amount = node.argument.value.transfer.amount - - val senderAmount = senderAmountNoFees(sender, amount) - val state = parseTrees( - tree, - synchronizerId, - tree.getChildNodeIds(exercised).asScala.toList, - ignoreUnexpectedAmuletCreateArchive = true, - ) - if (state.hasTransfer) { - // We hit this for transfers before the 24h signing delay change that call AmuletRules_Transfer or TransferPreapproval_Send internally - // or transfers with the 24h signing delay change where sender != receiver which call into TransferPreapproval_SendV2 - state - } else { - // We hit this only when sender = receiver and the 24h signing delay change is active as then there is no TransferPreapproval_SendV2 child. - // We just parse this as a plain transfer matching the behavior before the 24h signing delay change. - val txLogEntry = new TransferTxLogEntry( - offset = LegacyOffset.Api.fromLong(tree.getOffset), - eventId = - EventId.prefixedFromUpdateIdAndNodeId(tree.getUpdateId, exercised.getNodeId), - domainId = synchronizerId, - date = Some(tree.getEffectiveAt), - sender = Some(senderAmount), - receivers = Seq(receiverAmountNoFees(receiver, amount)), - balanceChanges = Seq.empty, - description = node.argument.value.transfer.meta.values - .getOrDefault(TokenStandardMetadata.reasonMetaKey, ""), - ) - State(txLogEntry) - } - case CreateTokenStandardTransferInstructionV2(node) => - // TODO(tech-debt): remove this duplication with CreateTokenStandardTransferInstruction - val admin = node.argument.value.transfer.instrumentId.admin - val cid: String = node.result.value.output match { - case output: splice.api.token.transferinstructionv2.transferinstructionresult_output.TransferInstructionResult_Pending => - output.transferInstructionCid.contractId - case output => - // CreateTokenStandardTransferInstruction only matches on two-step transfers resulting in pending status. - // Single-step transfers are just parsed as the underlying transfer. - logger.warn( - s"Unexpected transfer instruction result output, expected pending but got: $output" - ) - "" - } - val state = parseTrees( - tree, - synchronizerId, - tree.getChildNodeIds(exercised).asScala.toList, - ignoreUnexpectedAmuletCreateArchive = true, - ) - val stateWithTransfer = if (state.hasTransfer) { - // We hit this for transfers before the 24h signing change that call AmuletRules_Transfer internally. - state - } else { - val txLogEntry = new TransferTxLogEntry( - offset = LegacyOffset.Api.fromLong(tree.getOffset), - domainId = synchronizerId, - date = Some(tree.getEffectiveAt), - sender = Some( - senderAmountNoFees( - node.argument.value.transfer.sender.owner.toScala.getOrElse(admin), - 0.0, // Note: Because Scan tracks the sum of locked and unlocked input and output is 0. - ) - ), - // receiver is set to the sender as the amulet is locked to them - receivers = Seq( - receiverAmountNoFees( - node.argument.value.transfer.sender.owner.toScala.getOrElse(admin), - node.argument.value.transfer.amount, - ) - ), - balanceChanges = Seq.empty, - ) - State( - txLogEntry - ) - } - stateWithTransfer.copy( - entries = stateWithTransfer.entries.map { - case e: TransferTxLogEntry => - e.copy( - description = node.argument.value.transfer.meta.values - .getOrDefault(TokenStandardMetadata.reasonMetaKey, ""), - transferInstructionReceiver = - node.argument.value.transfer.receiver.owner.toScala.getOrElse(admin), - transferInstructionAmount = Some(node.argument.value.transfer.amount), - transferInstructionCid = cid, - eventId = - EventId.prefixedFromUpdateIdAndNodeId(tree.getUpdateId, exercised.getNodeId), - transferKind = TransferKind.TRANSFER_KIND_CREATE_TRANSFER_INSTRUCTION, - ) - case e => e - } - ) - case DirectTokenStandardTransferV2(node) => - // TODO(tech-debt): remove this duplication with DirectTokenStandardTransfer - val admin = node.argument.value.transfer.instrumentId.admin - val sender = node.argument.value.transfer.sender.owner.toScala.getOrElse(admin) - val receiver = node.argument.value.transfer.receiver.owner.toScala.getOrElse(admin) - val amount = node.argument.value.transfer.amount - - val senderAmount = senderAmountNoFees(sender, amount) - val state = parseTrees( - tree, - synchronizerId, - tree.getChildNodeIds(exercised).asScala.toList, - ignoreUnexpectedAmuletCreateArchive = true, - ) - if (state.hasTransfer) { - // We hit this for transfers before the 24h signing delay change that call AmuletRules_Transfer or TransferPreapproval_Send internally - // or transfers with the 24h signing delay change where sender != receiver which call into TransferPreapproval_SendV2 - state - } else { - // We hit this only when sender = receiver and the 24h signing delay change is active as then there is no TransferPreapproval_SendV2 child. - // We just parse this as a plain transfer matching the behavior before the 24h signing delay change. - val txLogEntry = new TransferTxLogEntry( - offset = LegacyOffset.Api.fromLong(tree.getOffset), - eventId = - EventId.prefixedFromUpdateIdAndNodeId(tree.getUpdateId, exercised.getNodeId), - domainId = synchronizerId, - date = Some(tree.getEffectiveAt), - sender = Some(senderAmount), - receivers = Seq(receiverAmountNoFees(receiver, amount)), - balanceChanges = Seq.empty, - description = node.argument.value.transfer.meta.values - .getOrDefault(TokenStandardMetadata.reasonMetaKey, ""), - ) - State(txLogEntry) - } - case TransferInstruction_Accept(node) => - val state = parseTrees( - tree, - synchronizerId, - tree.getChildNodeIds(exercised).asScala.toList, - ignoreUnexpectedAmuletCreateArchive = true, - ) - val stateWithTransfer = if (state.hasTransfer) { - // We hit this for transfers before the 24h signing change that call AmuletRules_Transfer internally. - state - } else { - val coinCid = node.result.value.output match { - case output: splice.api.token.transferinstructionv1.transferinstructionresult_output.TransferInstructionResult_Completed => - assert(output.receiverHoldingCids.size == 1) - output.receiverHoldingCids.get(0) - case output => - throw new RuntimeException( - s"Unexpected transfer instruction result output, expected completed but got: $output" - ) - } - val coin = - tree - .findCreation( - splice.amulet.Amulet.COMPANION, - new splice.amulet.Amulet.ContractId(coinCid.contractId), - ) - .getOrElse( - throw new RuntimeException( - s"The amulet contract ${coinCid} was not found in transaction ${tree.getUpdateId}" - ) - ) - val sender = node.result.value.meta.values.get(TokenStandardMetadata.senderMetaKey) - val receiver = coin.payload.owner - val amount = coin.payload.amount.initialAmount - - val txLogEntry = new TransferTxLogEntry( - offset = LegacyOffset.Api.fromLong(tree.getOffset), - eventId = - EventId.prefixedFromUpdateIdAndNodeId(tree.getUpdateId, exercised.getNodeId), - domainId = synchronizerId, - date = Some(tree.getEffectiveAt), - sender = Some(senderAmountNoFees(sender, amount)), - receivers = Seq(receiverAmountNoFees(receiver, amount)), - balanceChanges = Seq.empty, - ) - State(txLogEntry) - } - stateWithTransfer.copy( - entries = stateWithTransfer.entries.map { - case e: TransferTxLogEntry => - e.copy( - transferInstructionCid = exercised.getContractId, - transferKind = TransferKind.TRANSFER_KIND_TRANSFER_INSTRUCTION_ACCEPT, - ) - case e => e - } - ) - case TransferInstruction_Withdraw(_) => - // Contrary to the wallet which tracks only unlocked amulet balance, - // scan tracks the sum of locked and unlocked balance so - // this does not actually create a change in balance. - State( - AbortTransferInstructionTxLogEntry( - offset = LegacyOffset.Api.fromLong(tree.getOffset), - eventId = eventId, - domainId = synchronizerId, - date = Some(tree.getEffectiveAt), - transferInstructionCid = exercised.getContractId, - transferAbortKind = TransferAbortKind.TRANSFER_ABORT_KIND_WITHDRAW, - ) - ) - case TransferInstruction_Reject(_) => - // Contrary to the wallet which tracks only unlocked amulet balance, - // scan tracks the sum of locked and unlocked balance so - // this does not actually create a change in balance. - State( - AbortTransferInstructionTxLogEntry( - offset = LegacyOffset.Api.fromLong(tree.getOffset), - eventId = eventId, - domainId = synchronizerId, - date = Some(tree.getEffectiveAt), - transferInstructionCid = exercised.getContractId, - transferAbortKind = TransferAbortKind.TRANSFER_ABORT_KIND_REJECT, - ) - ) - case Tap(node) => - State.fromAmuletCreateSummary( - tree, - exercised, - synchronizerId, - node.result.value.amuletSum, - TransactionType.Tap, - ) - case Mint(node) => - State.fromAmuletCreateSummary( - tree, - exercised, - synchronizerId, - node.result.value.amuletSum, - TransactionType.Mint, - ) - case AmuletRules_BuyMemberTraffic(node) => - State.fromBuyMemberTraffic(eventId, synchronizerId, node) - case AmuletRules_CreateExternalPartySetupProposal(node) => - State.fromCreateExternalPartySetupProposal(eventId, synchronizerId, node) - case AmuletRules_CreateTransferPreapproval(node) => - State.fromCreateTransferPreapproval(eventId, synchronizerId, node) - case TransferPreapproval_Renew(node) => - State.fromRenewTransferPreapproval(eventId, synchronizerId, node) - case AmuletExpire(node) => - State.empty - case AmuletExpireV2(node) => - State.empty - case LockedAmuletExpireAmulet(node) => - State.empty - case LockedAmuletExpireAmuletV2(node) => - State.empty - // We track the sum of locked/unlocked so this is a noop. - case LockedAmuletUnlock(_) => - State.empty - // We track the sum of locked/unlocked so this is a noop. - case LockedAmuletUnlockV2(_) => - State.empty - // We track the sum of locked/unlocked so this is a noop. - case LockedAmuletOwnerExpireLock(_) => - State.empty - // We track the sum of locked/unlocked so this is a noop. - case LockedAmuletOwnerExpireLockV2(_) => - State.empty - case AnsRules_CollectInitialEntryPayment(_) => - fromAnsEntryPaymentCollection( - tree, - exercised, - synchronizerId, - sws.SubscriptionInitialPayment.COMPANION, - sws.SubscriptionInitialPayment.CHOICE_SubscriptionInitialPayment_Collect, - )(_.amulet) - case AnsRules_CollectEntryRenewalPayment(_) => - fromAnsEntryPaymentCollection( - tree, - exercised, - synchronizerId, - sws.SubscriptionPayment.COMPANION, - sws.SubscriptionPayment.CHOICE_SubscriptionPayment_Collect, - )(_.amulet) - case AmuletArchive(_) => - if (!ignoreUnexpectedAmuletCreateArchive) { - throw new RuntimeException( - s"Unexpected amulet archive event for amulet ${exercised.getContractId} in transaction ${tree.getUpdateId}" - ) - } else { - State.empty - } case DsoRulesCloseVoteRequest(node) => State.fromCloseVoteRequest(eventId, node) case ExternalPartyAmuletRules_CreateTransferCommand(node) => @@ -479,78 +84,6 @@ class ScanTxLogParser( State.fromTransferCommand_Withdraw(eventId, exercised, node) case TransferCommand_Expire(node) => State.fromTransferCommand_Expire(eventId, exercised, node) - case AllocationFactoryAllocate(node) => - val state = parseTrees( - tree, - synchronizerId, - tree.getChildNodeIds(exercised).asScala.toList, - ignoreUnexpectedAmuletCreateArchive = true, - ) - if (state.hasTransfer) { - // We hit this for allocations before the 24h signing change that call AmuletRules_Transfer internally. - state - } else { - - val sender = node.argument.value.allocation.transferLeg.sender - val amount = node.argument.value.allocation.transferLeg.amount - - val txLogEntry = new TransferTxLogEntry( - offset = LegacyOffset.Api.fromLong(tree.getOffset), - eventId = - EventId.prefixedFromUpdateIdAndNodeId(tree.getUpdateId, exercised.getNodeId), - domainId = synchronizerId, - date = Some(tree.getEffectiveAt), - sender = Some(senderAmountNoFees(sender, amount)), - receivers = Seq( - receiverAmountNoFees(sender, amount) - ), // This step locks to the sender which scan displays as a transfer to yourself. - balanceChanges = Seq.empty, - ) - State(txLogEntry) - } - case AllocationExecuteTransfer(node) => - val state = parseTrees( - tree, - synchronizerId, - tree.getChildNodeIds(exercised).asScala.toList, - ignoreUnexpectedAmuletCreateArchive = true, - ) - if (state.hasTransfer) { - // We hit this for allocations before the 24h signing change that call AmuletRules_Transfer internally. - state - } else { - assert(node.result.value.receiverHoldingCids.size == 1) - val coinCid = node.result.value.receiverHoldingCids.get(0) - val coin = - tree - .findCreation( - splice.amulet.Amulet.COMPANION, - new splice.amulet.Amulet.ContractId(coinCid.contractId), - ) - .getOrElse( - throw new RuntimeException( - s"The amulet contract ${coinCid} was not found in transaction ${tree.getUpdateId}" - ) - ) - val sender = node.result.value.meta.values.get(TokenStandardMetadata.senderMetaKey) - val receiver = coin.payload.owner - val amount = coin.payload.amount.initialAmount - - val txLogEntry = new TransferTxLogEntry( - offset = LegacyOffset.Api.fromLong(tree.getOffset), - eventId = - EventId.prefixedFromUpdateIdAndNodeId(tree.getUpdateId, exercised.getNodeId), - domainId = synchronizerId, - date = Some(tree.getEffectiveAt), - sender = Some(senderAmountNoFees(sender, amount)), - receivers = Seq(receiverAmountNoFees(receiver, amount)), - balanceChanges = Seq.empty, - ) - State(txLogEntry) - } - // Token Standard V2: the Scan txlog will go away so we don't bother parsing it - case AllocationFactoryV2Allocate(_) | AllocationV2Settle(_) => - State.empty case _ => parseTrees( tree, @@ -573,22 +106,6 @@ class ScanTxLogParser( ) case ClosedMiningRoundCreate(round) => State.fromClosedMiningRoundCreate(tree, root, synchronizerId, round) - case AmuletCreate(_) => - if (!ignoreUnexpectedAmuletCreateArchive) { - throw new RuntimeException( - s"Unexpected amulet create event for amulet ${created.getContractId} in transaction ${tree.getUpdateId}" - ) - } else { - State.empty - } - case LockedAmuletCreate(_) => - if (!ignoreUnexpectedAmuletCreateArchive) { - throw new RuntimeException( - s"Unexpected locked amulet create event for amulet ${created.getContractId} in transaction ${tree.getUpdateId}" - ) - } else { - State.empty - } case _ => State.empty } @@ -597,35 +114,6 @@ class ScanTxLogParser( } } - private def fromAnsEntryPaymentCollection[Marker, Res]( - tree: Transaction, - exercised: ExercisedEvent, - synchronizerId: SynchronizerId, - paymentCollectionTemplate: codegen.ContractCompanion[?, ?, Marker], - paymentCollectionChoice: codegen.Choice[Marker, ?, Res], - )( - collectionProducedAmulet: Res => AmuletCreate.TCid - )(implicit tc: TraceContext) = { - // first child event is the initial subscription payment collected by DSO - val (paymentCollectionEvent, _) = - tree - .firstDescendantExercise(exercised, paymentCollectionTemplate, paymentCollectionChoice) - .map { case (e, pr) => (e, collectionProducedAmulet(pr)) } - .getOrElse { - sys.error( - s"Unable to find ${paymentCollectionChoice.name} in ${exercised.getChoice}" - ) - } - - val stateFromPaymentCollection = parseTree( - tree, - synchronizerId, - paymentCollectionEvent, - ignoreUnexpectedAmuletCreateArchive = false, - ) - State.empty.appended(stateFromPaymentCollection) - } - private def parseTrees( tree: Transaction, synchronizerId: SynchronizerId, @@ -670,29 +158,6 @@ object ScanTxLogParser { def appended(other: State): State = State( entries = entries.appendedAll(other.entries) ) - def hasTransfer: Boolean = - entries.exists { - case _: TransferTxLogEntry => true - case _ => false - } - - def setTransferPreapprovalSendFields( - tree: Transaction, - exercised: ExercisedEvent, - description: java.util.Optional[String], - ): State = - copy( - entries = entries.map { - case e: TransferTxLogEntry => - e.copy( - description = description.orElse(""), - eventId = - EventId.prefixedFromUpdateIdAndNodeId(tree.getUpdateId, exercised.getNodeId), - transferKind = TransferKind.TRANSFER_KIND_PREAPPROVAL_SEND, - ) - case e => e - } - ) } private object State { @@ -709,334 +174,6 @@ object ScanTxLogParser { a.appended(b) } - private def getAmuletFromSummary( - tx: Transaction, - ccsum: AmuletCreateSummary[? <: codegen.ContractId[AmuletCreate.T]], - ) = { - val amuletCid = ccsum.amulet - tx.findCreation(AmuletCreate.companion, amuletCid) - .map(_.payload) - .getOrElse { - throw new RuntimeException( - s"The amulet contract $amuletCid referenced by AmuletCreateSummary was not found in transaction ${tx.getUpdateId}" - ) - } - } - - def fromAmuletCreateSummary( - tx: Transaction, - event: Event, - synchronizerId: SynchronizerId, - acsum: AmuletCreateSummary[? <: codegen.ContractId[AmuletCreate.T]], - activityType: TransactionType, - ): State = { - val amulet = getAmuletFromSummary(tx, acsum) - val eventId = EventId.prefixedFromUpdateIdAndNodeId(tx.getUpdateId, event.getNodeId) - val activityEntry: TransactionTxLogEntry = activityType match { - case TransactionType.Tap => - TapTxLogEntry( - offset = LegacyOffset.Api.fromLong(tx.getOffset), - eventId = eventId, - domainId = synchronizerId, - date = Some(tx.getEffectiveAt), - amuletOwner = PartyId.tryFromProtoPrimitive(amulet.owner), - amuletAmount = amulet.amount.initialAmount, - round = acsum.round.number, - ) - case TransactionType.Mint => - MintTxLogEntry( - offset = LegacyOffset.Api.fromLong(tx.getOffset), - eventId = eventId, - domainId = synchronizerId, - date = Some(tx.getEffectiveAt), - amuletOwner = PartyId.tryFromProtoPrimitive(amulet.owner), - amuletAmount = amulet.amount.initialAmount, - round = acsum.round.number, - ) - case unexpected => - throw new Exception( - s"unexpected activityType: $unexpected in fromAmuletCreateSummary" - ) - } - - State(activityEntry) - } - - private def rewardsEntriesFromTransferSummary( - sender: PartyId, - summary: splice.amuletrules.TransferSummary, - round: Long, - synchronizerId: SynchronizerId, - rootEventId: String, - ): State = { - val appRewards = summary.inputAppRewardAmount - val validatorRewards = summary.inputValidatorRewardAmount - val svRewards = summary.inputSvRewardAmount - - val appRewardEntry = - if (appRewards.compareTo(BigDecimal(0.0)) > 0) { - val entry = - AppRewardTxLogEntry( - eventId = rootEventId, - domainId = synchronizerId, - round = round, - party = sender, - amount = appRewards, - ) - State(entry) - } else { - State.empty - } - - val validatorRewardEntry = - if (validatorRewards.compareTo(BigDecimal(0.0)) > 0) { - val entry = - ValidatorRewardTxLogEntry( - eventId = rootEventId, - domainId = synchronizerId, - round = round, - party = sender, - amount = validatorRewards, - ) - State(entry) - } else { - State.empty - } - - val svRewardEntry = - if (svRewards.compareTo(BigDecimal(0.0)) > 0) { - val entry = - SvRewardTxLogEntry( - eventId = rootEventId, - domainId = synchronizerId, - round = round, - party = sender, - amount = svRewards, - ) - State(entry) - } else { - State.empty - } - - appRewardEntry.appended(validatorRewardEntry).appended(svRewardEntry) - } - - def fromTransfer( - tx: Transaction, - event: ExercisedEvent, - synchronizerId: SynchronizerId, - node: ExerciseNode[Transfer.Arg, Transfer.Res], - rootEventId: Option[String] = None, - ): State = { - State.fromTransferResult( - tx, - event, - synchronizerId, - sender = node.argument.value.transfer.sender, - outputs = node.argument.value.transfer.outputs.asScala.toSeq, - result = node.result.value, - rootEventId = rootEventId, - ) - } - - def fromTransferResult( - tx: Transaction, - event: ExercisedEvent, - synchronizerId: SynchronizerId, - sender: String, - outputs: Seq[splice.amuletrules.TransferOutput], - result: splice.amuletrules.TransferResult, - rootEventId: Option[String] = None, - ): State = { - val senderParty = Codec - .decode(Codec.Party)(sender) - .getOrElse( - throw Status.INTERNAL - .withDescription(s"Cannot decode party ID ${sender}") - .asRuntimeException() - ) - val eventId = EventId.prefixedFromUpdateIdAndNodeId(tx.getUpdateId, event.getNodeId) - val rewardEntries = - rewardsEntriesFromTransferSummary( - senderParty, - result.summary, - result.round.number, - synchronizerId, - rootEventId.getOrElse(eventId), - ) - - val activityEntry = State( - transferTxLogEntry( - tx, - event, - synchronizerId, - sender = sender, - outputs = outputs, - result = result, - ) - ) - - rewardEntries - .appended(activityEntry) - } - - private def transferTxLogEntry( - tx: Transaction, - event: Event, - synchronizerId: SynchronizerId, - sender: String, - outputs: Seq[splice.amuletrules.TransferOutput], - result: splice.amuletrules.TransferResult, - ): TransferTxLogEntry = { - val senderAmount = parseSenderAmount(sender, outputs, result) - val receiverAmounts = parseReceiverAmounts(outputs, result) - - new TransferTxLogEntry( - offset = LegacyOffset.Api.fromLong(tx.getOffset), - eventId = EventId.prefixedFromUpdateIdAndNodeId(tx.getUpdateId, event.getNodeId), - domainId = synchronizerId, - date = Some(tx.getEffectiveAt), - sender = Some(senderAmount), - receivers = receiverAmounts, - round = result.round.number, - ) - } - - def fromBuyMemberTraffic( - eventId: String, - synchronizerId: SynchronizerId, - node: ExerciseNode[AmuletRules_BuyMemberTraffic.Arg, AmuletRules_BuyMemberTraffic.Res], - ): State = { - val validatorParty = Codec - .decode(Codec.Party)(node.argument.value.provider) - .getOrElse( - throw Status.INTERNAL - .withDescription( - s"Cannot decode party ID ${node.argument.value.provider}" - ) - .asRuntimeException() - ) - val round = node.result.value.round - val trafficPurchased = node.argument.value.trafficAmount - val ccSpent = node.result.value.amuletPaid - val buyExtraTrafficEntry = ExtraTrafficPurchaseTxLogEntry( - eventId = eventId, - domainId = synchronizerId, - round = round.number, - validator = validatorParty, - trafficPurchased = trafficPurchased, - ccSpent = ccSpent, - ) - - val rewardEntries = rewardsEntriesFromTransferSummary( - validatorParty, - node.result.value.summary, - round.number, - synchronizerId, - eventId, - ) - - State(buyExtraTrafficEntry) - .appended(rewardEntries) - } - - def fromCreateExternalPartySetupProposal( - eventId: String, - synchronizerId: SynchronizerId, - node: ExerciseNode[ - AmuletRules_CreateExternalPartySetupProposal.Arg, - AmuletRules_CreateExternalPartySetupProposal.Res, - ], - ): State = { - val validatorParty = Codec - .decode(Codec.Party)(node.result.value.validator) - .getOrElse( - throw Status.INTERNAL - .withDescription( - s"Cannot decode party ID ${node.argument.value.validator}" - ) - .asRuntimeException() - ) - val transferResult = node.result.value.transferResult - fromTransferPreapprovalPurchase( - eventId, - synchronizerId, - validatorParty, - transferResult, - ) - } - - def fromCreateTransferPreapproval( - eventId: String, - synchronizerId: SynchronizerId, - node: ExerciseNode[ - AmuletRules_CreateTransferPreapproval.Arg, - AmuletRules_CreateTransferPreapproval.Res, - ], - ): State = { - val validatorParty = Codec - .decode(Codec.Party)(node.argument.value.provider) - .getOrElse( - throw Status.INTERNAL - .withDescription( - s"Cannot decode party ID ${node.argument.value.provider}" - ) - .asRuntimeException() - ) - val transferResult = node.result.value.transferResult - fromTransferPreapprovalPurchase( - eventId, - synchronizerId, - validatorParty, - transferResult, - ) - } - - def fromRenewTransferPreapproval( - eventId: String, - synchronizerId: SynchronizerId, - node: ExerciseNode[ - TransferPreapproval_Renew.Arg, - TransferPreapproval_Renew.Res, - ], - ): State = { - val validatorParty = Codec - .decode(Codec.Party)(node.result.value.provider) - .getOrElse( - throw Status.INTERNAL - .withDescription( - s"Cannot decode party ID ${node.result.value.provider}" - ) - .asRuntimeException() - ) - val transferResult = node.result.value.transferResult - fromTransferPreapprovalPurchase( - eventId, - synchronizerId, - validatorParty, - transferResult, - ) - } - - private def fromTransferPreapprovalPurchase( - eventId: String, - synchronizerId: SynchronizerId, - validatorParty: PartyId, - transferResult: TransferResult, - ) = { - val round = transferResult.round - - val rewardEntries = rewardsEntriesFromTransferSummary( - validatorParty, - transferResult.summary, - round.number, - synchronizerId, - eventId, - ) - - State.empty.appended(rewardEntries) - } - def fromOpenMiningRoundCreate( eventId: String, synchronizerId: SynchronizerId, @@ -1181,25 +318,4 @@ object ScanTxLogParser { ) } } - - private def senderAmountNoFees(party: String, amount: BigDecimal) = - SenderAmount( - party = PartyId.tryFromProtoPrimitive(party), - inputAmuletAmount = amount, - inputAppRewardAmount = BigDecimal(0.0), - inputValidatorRewardAmount = BigDecimal(0.0), - senderChangeAmount = BigDecimal(0.0), - senderChangeFee = BigDecimal(0.0), - senderFee = BigDecimal(0.0), - holdingFees = BigDecimal(0.0), - inputSvRewardAmount = None, - inputValidatorFaucetAmount = None, - ) - - private def receiverAmountNoFees(party: String, amount: BigDecimal) = - ReceiverAmount( - party = PartyId.tryFromProtoPrimitive(party), - amount = amount, - receiverFee = BigDecimal(0.0), - ) } diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/TxLogEntry.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/TxLogEntry.scala index 2b17fdf90b..d51aa84ee9 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/TxLogEntry.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/TxLogEntry.scala @@ -3,21 +3,11 @@ package org.lfdecentralizedtrust.splice.scan.store -import org.lfdecentralizedtrust.splice.codegen.java.splice import org.lfdecentralizedtrust.splice.store.StoreErrors -import org.lfdecentralizedtrust.splice.util.Codec -import scala.collection.immutable -import scala.jdk.CollectionConverters.* -import scala.jdk.OptionConverters.* import java.time.Instant import org.lfdecentralizedtrust.splice.http.v0.definitions as httpDef -import org.lfdecentralizedtrust.splice.http.v0.definitions.TransactionHistoryResponseItem.TransactionType as HttpTransactionType import com.digitalasset.canton.config.CantonRequireTypes.String3 -import com.digitalasset.canton.topology.PartyId - -import java.time.ZoneOffset -import scala.math.BigDecimal.RoundingMode trait TxLogEntry extends Product with Serializable { // Scan store uses the eventId for pagination @@ -28,42 +18,33 @@ object TxLogEntry extends StoreErrors { object EntryType { val ErrorTxLogEntry = String3.tryCreate("err") - val BalanceChangeTxLogEntry = String3.tryCreate("bac") val ClosedMiningRoundTxLogEntry = String3.tryCreate("cmr") - val ExtraTrafficPurchaseTxLogEntry = String3.tryCreate("etp") val OpenMiningRoundTxLogEntry = String3.tryCreate("omr") - val AppRewardTxLogEntry = String3.tryCreate("are") - val MintTxLogEntry = String3.tryCreate("min") - val TapTxLogEntry = String3.tryCreate("tap") - val TransferTxLogEntry = String3.tryCreate("tra") - val ValidatorRewardTxLogEntry = String3.tryCreate("vre") - val SvRewardTxLogEntry = String3.tryCreate("sre") val VoteRequestTxLogEntry = String3.tryCreate("vot") val TransferCommandTxLogEntry = String3.tryCreate("trc") - val AbortTransferInstructionTxLogEntry = String3.tryCreate("ati") // The following entry types correspond to entries that were removed from `scan_tx_log.proto` // Those entries might still exist in databases, but we don't produce new ones and we don't read them. // The values are only kept for documentation purposes. val Unused_SvRewardCollectedTxLogEntry = String3.tryCreate("src") + val Unused_BalanceChangeTxLogEntry = String3.tryCreate("bac") + val Unused_ExtraTrafficPurchaseTxLogEntry = String3.tryCreate("etp") + val Unused_AppRewardTxLogEntry = String3.tryCreate("are") + val Unused_MintTxLogEntry = String3.tryCreate("min") + val Unused_TapTxLogEntry = String3.tryCreate("tap") + val Unused_TransferTxLogEntry = String3.tryCreate("tra") + val Unused_ValidatorRewardTxLogEntry = String3.tryCreate("vre") + val Unused_SvRewardTxLogEntry = String3.tryCreate("sre") + val Unused_AbortTransferInstructionTxLogEntry = String3.tryCreate("ati") } def encode(entry: TxLogEntry): (String3, String) = { import scalapb.json4s.JsonFormat val entryType = entry match { case _: ErrorTxLogEntry => EntryType.ErrorTxLogEntry - case _: BalanceChangeTxLogEntry => EntryType.BalanceChangeTxLogEntry case _: ClosedMiningRoundTxLogEntry => EntryType.ClosedMiningRoundTxLogEntry - case _: ExtraTrafficPurchaseTxLogEntry => EntryType.ExtraTrafficPurchaseTxLogEntry case _: OpenMiningRoundTxLogEntry => EntryType.OpenMiningRoundTxLogEntry - case _: AppRewardTxLogEntry => EntryType.AppRewardTxLogEntry - case _: MintTxLogEntry => EntryType.MintTxLogEntry - case _: TapTxLogEntry => EntryType.TapTxLogEntry - case _: TransferTxLogEntry => EntryType.TransferTxLogEntry - case _: ValidatorRewardTxLogEntry => EntryType.ValidatorRewardTxLogEntry - case _: SvRewardTxLogEntry => EntryType.SvRewardTxLogEntry case _: VoteRequestTxLogEntry => EntryType.VoteRequestTxLogEntry case _: TransferCommandTxLogEntry => EntryType.TransferCommandTxLogEntry - case _: AbortTransferInstructionTxLogEntry => EntryType.AbortTransferInstructionTxLogEntry case _ => throw txEncodingFailed() } val jsonValue = entry match { @@ -77,20 +58,10 @@ object TxLogEntry extends StoreErrors { try { entryType match { case EntryType.ErrorTxLogEntry => from[ErrorTxLogEntry](json) - case EntryType.BalanceChangeTxLogEntry => from[BalanceChangeTxLogEntry](json) case EntryType.ClosedMiningRoundTxLogEntry => from[ClosedMiningRoundTxLogEntry](json) - case EntryType.ExtraTrafficPurchaseTxLogEntry => from[ExtraTrafficPurchaseTxLogEntry](json) case EntryType.OpenMiningRoundTxLogEntry => from[OpenMiningRoundTxLogEntry](json) - case EntryType.AppRewardTxLogEntry => from[AppRewardTxLogEntry](json) - case EntryType.MintTxLogEntry => from[MintTxLogEntry](json) - case EntryType.TapTxLogEntry => from[TapTxLogEntry](json) - case EntryType.TransferTxLogEntry => from[TransferTxLogEntry](json) - case EntryType.ValidatorRewardTxLogEntry => from[ValidatorRewardTxLogEntry](json) - case EntryType.SvRewardTxLogEntry => from[ValidatorRewardTxLogEntry](json) case EntryType.VoteRequestTxLogEntry => from[VoteRequestTxLogEntry](json) case EntryType.TransferCommandTxLogEntry => from[TransferCommandTxLogEntry](json) - case EntryType.AbortTransferInstructionTxLogEntry => - from[AbortTransferInstructionTxLogEntry](json) case _ => throw txLogIsOfWrongType(entryType.str) } } catch { @@ -98,14 +69,6 @@ object TxLogEntry extends StoreErrors { } } - trait RewardTxLogEntry extends TxLogEntry { - def party: PartyId - - def amount: BigDecimal - - def round: Long - } - trait TransactionTxLogEntry extends TxLogEntry { def date: Option[Instant] } @@ -118,130 +81,6 @@ object TxLogEntry extends StoreErrors { val Failed = "failed" } - private def toResponse(data: SenderAmount) = httpDef.SenderAmount( - party = data.party.toProtoPrimitive, - inputAmuletAmount = Some(Codec.encode(data.inputAmuletAmount)), - inputAppRewardAmount = Some(Codec.encode(data.inputAppRewardAmount)), - inputValidatorRewardAmount = Some(Codec.encode(data.inputValidatorRewardAmount)), - inputSvRewardAmount = Some(Codec.encode(data.inputSvRewardAmount.getOrElse(BigDecimal(0)))), - inputValidatorFaucetAmount = data.inputValidatorFaucetAmount.map(fa => Codec.encode(fa)), - senderChangeAmount = Codec.encode(data.senderChangeAmount), - senderChangeFee = Codec.encode(data.senderChangeFee), - senderFee = Codec.encode(data.senderFee), - holdingFees = Codec.encode(data.holdingFees), - ) - - private def toResponse(data: ReceiverAmount) = httpDef.ReceiverAmount( - party = data.party.toProtoPrimitive, - amount = Codec.encode(data.amount), - receiverFee = Codec.encode(data.receiverFee), - ) - - private def toResponse(data: BalanceChange) = httpDef.BalanceChange( - party = data.party.toProtoPrimitive, - changeToInitialAmountAsOfRoundZero = Codec.encode(data.changeToInitialAmountAsOfRoundZero), - changeToHoldingFeesRate = Codec.encode(data.changeToHoldingFeesRate), - ) - - private def toTransferResponseItem(entry: TransferTxLogEntry) = - httpDef.TransactionHistoryResponseItem( - transactionType = HttpTransactionType.Transfer, - eventId = entry.eventId, - offset = Some(entry.offset), - domainId = entry.domainId.toProtoPrimitive, - date = java.time.OffsetDateTime - .ofInstant(entry.date.getOrElse(throw txMissingField()), ZoneOffset.UTC), - transfer = Some( - httpDef.Transfer( - sender = toResponse(entry.sender.getOrElse(throw txMissingField())), - receivers = entry.receivers.map(toResponse).toVector, - balanceChanges = entry.balanceChanges.map(toResponse).toVector, - description = Some(entry.description).filter(_.nonEmpty), - transferInstructionReceiver = - Some(entry.transferInstructionReceiver).filter(_.nonEmpty), - transferInstructionAmount = entry.transferInstructionAmount.map(Codec.encode(_)), - transferInstructionCid = Some(entry.transferInstructionCid).filter(_.nonEmpty), - transferKind = entry.transferKind match { - case TransferKind.Unrecognized(_) => None - case TransferKind.TRANSFER_KIND_OTHER => None - case TransferKind.TRANSFER_KIND_CREATE_TRANSFER_INSTRUCTION => - Some(httpDef.Transfer.TransferKind.members.CreateTransferInstruction) - case TransferKind.TRANSFER_KIND_TRANSFER_INSTRUCTION_ACCEPT => - Some(httpDef.Transfer.TransferKind.members.TransferInstructionAccept) - case TransferKind.TRANSFER_KIND_PREAPPROVAL_SEND => - Some(httpDef.Transfer.TransferKind.members.PreapprovalSend) - }, - ) - ), - round = Some(entry.round), - ) - - private def toTapResponseItem(entry: TapTxLogEntry) = httpDef.TransactionHistoryResponseItem( - transactionType = HttpTransactionType.DevnetTap, - eventId = entry.eventId, - offset = Some(entry.offset), - domainId = entry.domainId.toProtoPrimitive, - date = java.time.OffsetDateTime - .ofInstant(entry.date.getOrElse(throw txMissingField()), ZoneOffset.UTC), - tap = Some( - httpDef.AmuletAmount( - amuletOwner = entry.amuletOwner.toProtoPrimitive, - amuletAmount = Codec.encode(entry.amuletAmount), - ) - ), - round = Some(entry.round), - ) - - private def toMintResponseItem(entry: MintTxLogEntry) = httpDef.TransactionHistoryResponseItem( - transactionType = HttpTransactionType.Mint, - eventId = entry.eventId, - offset = Some(entry.offset), - domainId = entry.domainId.toProtoPrimitive, - date = java.time.OffsetDateTime - .ofInstant(entry.date.getOrElse(throw txMissingField()), ZoneOffset.UTC), - mint = Some( - httpDef.AmuletAmount( - amuletOwner = entry.amuletOwner.toProtoPrimitive, - amuletAmount = Codec.encode(entry.amuletAmount), - ) - ), - ) - - private def toAbortTransferInstructionResponseItem(entry: AbortTransferInstructionTxLogEntry) = - httpDef.TransactionHistoryResponseItem( - transactionType = HttpTransactionType.AbortTransferInstruction, - eventId = entry.eventId, - offset = Some(entry.offset), - domainId = entry.domainId.toProtoPrimitive, - date = java.time.OffsetDateTime - .ofInstant(entry.date.getOrElse(throw txMissingField()), ZoneOffset.UTC), - abortTransferInstruction = Some( - httpDef.AbortTransferInstruction( - abortKind = entry.transferAbortKind match { - case TransferAbortKind.Unrecognized(_) => - sys.error(s"Unexpected transfer abort kind: ${entry.transferAbortKind}") - case TransferAbortKind.TRANSFER_ABORT_KIND_RESERVED => - sys.error(s"Unexpected transfer abort kind: ${entry.transferAbortKind}") - case TransferAbortKind.TRANSFER_ABORT_KIND_REJECT => - httpDef.AbortTransferInstruction.AbortKind.members.Reject - case TransferAbortKind.TRANSFER_ABORT_KIND_WITHDRAW => - httpDef.AbortTransferInstruction.AbortKind.members.Withdraw - }, - transferInstructionCid = entry.transferInstructionCid, - ) - ), - ) - - def toResponseItem(entry: TransactionTxLogEntry): httpDef.TransactionHistoryResponseItem = - entry match { - case entry: TransferTxLogEntry => toTransferResponseItem(entry) - case entry: TapTxLogEntry => toTapResponseItem(entry) - case entry: MintTxLogEntry => toMintResponseItem(entry) - case entry: AbortTransferInstructionTxLogEntry => - toAbortTransferInstructionResponseItem(entry) - case _ => throw txLogIsOfWrongType(entry.getClass.getSimpleName) - } - def toResponse( status: TransferCommandTxLogEntry.Status ): httpDef.TransferCommandContractStatus = @@ -275,101 +114,4 @@ object TxLogEntry extends StoreErrors { ) } } - - sealed trait TransactionType - object TransactionType { - case object Transfer extends TransactionType - case object Mint extends TransactionType - case object Tap extends TransactionType - } - - def parseSenderAmount( - sender: String, - outputs: Seq[splice.amuletrules.TransferOutput], - res: splice.amuletrules.TransferResult, - ): SenderAmount = { - val senderFee = parseOutputAmounts(outputs, res) - .map(_.senderFee) - .sum - - SenderAmount( - party = PartyId.tryFromProtoPrimitive(sender), - inputAmuletAmount = res.summary.inputAmuletAmount, - inputAppRewardAmount = res.summary.inputAppRewardAmount, - inputValidatorRewardAmount = res.summary.inputValidatorRewardAmount, - inputSvRewardAmount = Some(res.summary.inputSvRewardAmount), - inputValidatorFaucetAmount = - res.summary.inputValidatorFaucetAmount.toScala.map(BigDecimal(_)), - senderChangeAmount = res.summary.senderChangeAmount, - senderChangeFee = res.summary.senderChangeFee, - senderFee = senderFee, - holdingFees = res.summary.holdingFees, - ) - } - - def parseReceiverAmounts( - outputs: Seq[splice.amuletrules.TransferOutput], - res: splice.amuletrules.TransferResult, - ): Seq[ReceiverAmount] = { - - // Note: the same receiver party can appear multiple times in the transfer result - // The code below merges amounts and fees for the same receiver, while preserving - // the order of receivers. - parseOutputAmounts(outputs, res) - .map(o => - new ReceiverAmount( - party = PartyId.tryFromProtoPrimitive(o.output.receiver), - amount = o.output.amount, - receiverFee = o.receiverFee, - ) - ) - .foldLeft(immutable.ListMap.empty[PartyId, ReceiverAmount])((acc, receiverAmount) => - acc.updatedWith(receiverAmount.party)(prev => - Some(prev.fold(receiverAmount) { r => - r.copy( - amount = r.amount + receiverAmount.amount, - receiverFee = r.receiverFee + receiverAmount.receiverFee, - ) - }) - ) - ) - .values - .toList - } - - /** A requested output of a transfer, together with the actual fees paid for the transfer. - * - * @param output Contains the receiver and the gross amount received (before deducting fees). - * @param senderFee Actual amount of fees paid by the sender. - * @param receiverFee Actual amount of fees paid by the receiver. - */ - private final case class OutputWithFees( - output: splice.amuletrules.TransferOutput, - senderFee: BigDecimal, - receiverFee: BigDecimal, - ) - - private def parseOutputAmounts( - outputs: Seq[splice.amuletrules.TransferOutput], - res: splice.amuletrules.TransferResult, - ): Seq[OutputWithFees] = { - assert( - outputs.size == res.summary.outputFees.size(), - "Each output should have a corresponding fee", - ) - val outputsWithFees = outputs.zip(res.summary.outputFees.asScala) - - outputsWithFees - .map { case (out, fee) => - OutputWithFees( - output = out, - senderFee = setDamlDecimalScale(BigDecimal(fee) * (BigDecimal(1) - out.receiverFeeRatio)), - receiverFee = setDamlDecimalScale(BigDecimal(fee) * out.receiverFeeRatio), - ) - } - } - - /** Returns the input number modified such that it has the same number of decimal places as a daml decimal */ - private def setDamlDecimalScale(x: BigDecimal): BigDecimal = - x.setScale(10, RoundingMode.HALF_EVEN) } diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/AcsSnapshotBulkStorage.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/AcsSnapshotBulkStorage.scala index 3adb6e4652..57928ca3f5 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/AcsSnapshotBulkStorage.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/AcsSnapshotBulkStorage.scala @@ -79,6 +79,16 @@ class AcsSnapshotBulkStoragePersistentProgress( kvProvider.store.readValueAndLogOnDecodingFailure(firstSnapshotKvStoreKey).value } + def reset(implicit + tc: TraceContext, + ec: ExecutionContext, + ): Future[Unit] = { + for { + _ <- kvProvider.store.deleteKey(latestSnapshotKvStoreKey) + _ <- kvProvider.store.deleteKey(firstSnapshotKvStoreKey) + } yield {} + } + def persistLatestProcessedSnapshotTimestamp(ts: TimestampWithMigrationId)(implicit tc: TraceContext, ec: ExecutionContext, diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/AcsSnapshotBulkStorageCommitFromStaging.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/AcsSnapshotBulkStorageCommitFromStaging.scala index fba378264d..7f43f33eea 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/AcsSnapshotBulkStorageCommitFromStaging.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/AcsSnapshotBulkStorageCommitFromStaging.scala @@ -10,18 +10,22 @@ import org.apache.pekko.NotUsed import org.apache.pekko.actor.ActorSystem import org.apache.pekko.stream.scaladsl.Flow import org.lfdecentralizedtrust.splice.scan.config.BulkStorageConfig +import org.lfdecentralizedtrust.splice.scan.util.PeerBftScanConnection import org.lfdecentralizedtrust.splice.store.{S3BucketConnection, TimestampWithMigrationId} -import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.{ExecutionContextExecutor, Future} class AcsSnapshotBulkStorageCommitFromStaging( stagingS3Connection: S3BucketConnection, committedS3Connection: S3BucketConnection, bulkStorageReader: BulkStorageReader, appConfig: BulkStorageConfig, + scanConnection: PeerBftScanConnection, + onObjectCommitted: Seq[S3BucketConnection.ObjectKeyAndChecksum] => Unit, val loggerFactory: NamedLoggerFactory, -)(implicit ec: ExecutionContext) - extends AcsSnapshotBulkStorageWriter +)(implicit + ec: ExecutionContextExecutor +) extends AcsSnapshotBulkStorageWriter with NamedLogging { override def getNextSnapshotTimestampAfter( @@ -55,7 +59,9 @@ class AcsSnapshotBulkStorageCommitFromStaging( Seq.empty }, appConfig, + scanConnection, loggerFactory, + onObjectCommitted, ) } } diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/BulkStorage.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/BulkStorage.scala index 1ed4ff4b55..5f7586df12 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/BulkStorage.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/BulkStorage.scala @@ -4,23 +4,29 @@ package org.lfdecentralizedtrust.splice.scan.store.bulk import com.daml.metrics.api.MetricHandle.LabeledMetricsFactory -import com.digitalasset.canton.lifecycle.{AsyncOrSyncCloseable, FlagCloseableAsync} +import com.digitalasset.canton.lifecycle.{AsyncOrSyncCloseable, FlagCloseableAsync, LifeCycle} import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.time.Clock import com.digitalasset.canton.tracing.TraceContext import io.grpc.Status import io.opentelemetry.api.trace.Tracer import org.apache.pekko.actor.{ActorSystem, Cancellable} -import org.lfdecentralizedtrust.splice.config.{AutomationConfig, S3Config} -import org.lfdecentralizedtrust.splice.environment.RetryProvider +import org.lfdecentralizedtrust.splice.config.{AutomationConfig, S3Config, UpgradesConfig} +import org.lfdecentralizedtrust.splice.environment.{RetryProvider, SpliceLedgerClient} import org.lfdecentralizedtrust.splice.scan.config.{BulkStorageConfig, ScanStorageConfig} -import org.lfdecentralizedtrust.splice.scan.store.{AcsSnapshotStore, ScanKeyValueProvider} +import org.lfdecentralizedtrust.splice.scan.store.{ + AcsSnapshotStore, + ScanKeyValueProvider, + ScanStore, +} import org.lfdecentralizedtrust.splice.store.{HistoryMetrics, S3BucketConnection, UpdateHistory} -import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.{ExecutionContextExecutor, Future} +import com.digitalasset.canton.discard.Implicits.DiscardOps import cats.implicits.* import org.apache.pekko.stream.scaladsl.Source import org.lfdecentralizedtrust.splice.PekkoRetryableService +import org.lfdecentralizedtrust.splice.http.HttpClient import org.lfdecentralizedtrust.splice.scan.store.bulk.BulkStorage.{ acsCommittedKvStoreKey, acsStagingKvStoreKey, @@ -28,6 +34,8 @@ import org.lfdecentralizedtrust.splice.scan.store.bulk.BulkStorage.{ updatesCommittedKvStoreKey, updatesStagingKvStoreKey, } +import org.lfdecentralizedtrust.splice.scan.util.PeerBftScanConnection +import org.lfdecentralizedtrust.splice.util.TemplateJsonDecoder import scala.concurrent.duration.* @@ -43,13 +51,19 @@ class BulkStorage( metricsFactory: LabeledMetricsFactory, automationConfig: AutomationConfig, backoffClock: Clock, + store: ScanStore, + svName: String, + ledgerClient: SpliceLedgerClient, + upgradesConfig: UpgradesConfig, override val retryProvider: RetryProvider, override val loggerFactory: NamedLoggerFactory, )(implicit actorSystem: ActorSystem, tc: TraceContext, - ec: ExecutionContext, + ec: ExecutionContextExecutor, tracer: Tracer, + httpClient: HttpClient, + templateJsonDecoder: TemplateJsonDecoder, ) extends NamedLogging with FlagCloseableAsync with RetryProvider.Has { @@ -57,6 +71,16 @@ class BulkStorage( val stagingConnection = S3BucketConnection(stagingS3Config, loggerFactory) val committedConnection = S3BucketConnection(committedS3Config, loggerFactory) val historyMetrics = HistoryMetrics(metricsFactory, currentMigrationId) + val scanConnection = new PeerBftScanConnection( + store, + svName, + ledgerClient, + automationConfig, + upgradesConfig, + backoffClock, + retryProvider, + loggerFactory, + ) val backfillingCompleteGate: Source[Boolean, Cancellable] = Source @@ -128,6 +152,17 @@ class BulkStorage( committedConnection, reader, appConfig, + scanConnection, + objs => + objs.foreach { obj => + val encoding = ScanStorageConfig.Encoding.all.toList + .collectFirst { + case enc if enc.storageKeyRegex("ACS").matches(obj.key) => + enc.key + } + .getOrElse("unknown") + historyMetrics.BulkStorage.incAcsSnapshotObjects(encoding, "committed") + }, loggerFactory, ) val acsCommitted = new AcsSnapshotBulkStorage( @@ -160,6 +195,17 @@ class BulkStorage( committedConnection, reader, appConfig, + scanConnection, + objs => + objs.foreach { obj => + val encoding = ScanStorageConfig.Encoding.all.toList + .collectFirst { + case enc if enc.storageKeyRegex("updates").matches(obj.key) => + enc.key + } + .getOrElse("unknown") + historyMetrics.BulkStorage.incUpdateObjects(encoding, "committed") + }, loggerFactory, ) val updatesCommitted = new UpdateHistoryBulkStorage( @@ -171,12 +217,34 @@ class BulkStorage( loggerFactory, ) - private val services = + // Services are only started once initialization has completed. + private lazy val services = Seq[PekkoRetryableService[?]](acsStaging, acsCommitted, updatesStaging, updatesCommitted) .map(_.asPekkoRetryingService(automationConfig, backoffClock, retryProvider)) - final override def closeAsync(): Seq[AsyncOrSyncCloseable] = + private def initialize(): Future[BulkStorage] = { + val resetAll = + if (appConfig.debugForceStartFromGenesis) { + logger.warn( + "debugForceStartFromGenesis is set to true, resetting all bulk storage progress and starting from genesis" + ) + for { + _ <- acsStagingProgress.reset + _ <- acsCommittedProgress.reset + _ <- updatesStagingProgress.reset + _ <- updatesCommittedProgress.reset + } yield () + } else Future.unit + resetAll.map { _ => + services.discard + this + } + } + + final override def closeAsync(): Seq[AsyncOrSyncCloseable] = { + LifeCycle.close(scanConnection)(logger) services.flatMap(_.closeAsync()) + } } object BulkStorage { @@ -197,14 +265,20 @@ object BulkStorage { metricsFactory: LabeledMetricsFactory, automationConfig: AutomationConfig, backoffClock: Clock, + store: ScanStore, + svName: String, + ledgerClient: SpliceLedgerClient, + upgradesConfig: UpgradesConfig, retryProvider: RetryProvider, loggerFactory: NamedLoggerFactory, )(implicit actorSystem: ActorSystem, tc: TraceContext, - ec: ExecutionContext, + ec: ExecutionContextExecutor, tracer: Tracer, - ): BulkStorage = { + httpClient: HttpClient, + templateJsonDecoder: TemplateJsonDecoder, + ): Future[BulkStorage] = { val logger = loggerFactory.getTracedLogger(classOf[BulkStorage]) (appConfig.staging, appConfig.committed).tupled.fold { @@ -225,9 +299,13 @@ object BulkStorage { metricsFactory, automationConfig, backoffClock, + store, + svName, + ledgerClient, + upgradesConfig, retryProvider, loggerFactory, - ) + ).initialize() } } } diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/BulkStorageCommitFromStaging.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/BulkStorageCommitFromStaging.scala index 75f18af458..5d1671aeca 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/BulkStorageCommitFromStaging.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/BulkStorageCommitFromStaging.scala @@ -6,13 +6,15 @@ package org.lfdecentralizedtrust.splice.scan.store.bulk import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.tracing.TraceContext import org.apache.pekko.NotUsed -import org.apache.pekko.actor.ActorSystem -import org.apache.pekko.stream.scaladsl.{Flow, Sink, Source} +import org.apache.pekko.http.scaladsl.model.StatusCodes +import org.apache.pekko.stream.scaladsl.{Flow, Source} +import org.lfdecentralizedtrust.splice.admin.http.HttpErrorWithHttpCode import org.lfdecentralizedtrust.splice.scan.config.BulkStorageConfig +import org.lfdecentralizedtrust.splice.scan.util.PeerBftScanConnection import org.lfdecentralizedtrust.splice.store.S3BucketConnection import org.lfdecentralizedtrust.splice.store.S3BucketConnection.ObjectKeyAndChecksum -import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.{ExecutionContextExecutor, Future} // TODO(#5884): review parallelism here. We use parallelism = 1 all over, but unsure whether that's actually necessary. @@ -21,28 +23,127 @@ class BulkStorageCommitFromStaging[T]( committedS3Connection: S3BucketConnection, getObjects: T => Future[Seq[ObjectKeyAndChecksum]], appConfig: BulkStorageConfig, + scanConnection: PeerBftScanConnection, override val loggerFactory: NamedLoggerFactory, + onObjectCommitted: Seq[ObjectKeyAndChecksum] => Unit = _ => (), )(implicit tc: TraceContext, - ec: ExecutionContext, - actorSystem: ActorSystem, + ec: ExecutionContextExecutor, ) extends NamedLogging { + private def checkBftForObjects( objects: Seq[ObjectKeyAndChecksum] ): Future[Boolean] = { logger.debug( s"Checking BFT agreement for objects: ${objects.map(_.key).mkString(", ")}" ) - Future.successful(true) + if (appConfig.bftCheckEnabled) { + for { + connection <- scanConnection.connection + bft <- connection.getBulkObjectChecksums(objects.map(_.key)).map(Some(_)).recoverWith { + case ex @ HttpErrorWithHttpCode(code, _) => + if (code == StatusCodes.BadGateway) { + logger.debug( + s"Consensus on checksums for objects ${objects.map(_.key).mkString(", ")} not reached. Assuming that this is because not all peers have processed the objects yet." + ) + Future.successful(None) + } else { + throw ex + } + } + } yield { + bft match { + case Some(bftChecksums) => + val consensusChecksums = bftChecksums.checksums.filter(_.value.isDefined) + logger.debug( + s"Consensus achieved on ${consensusChecksums.length} out of ${objects.length} objects" + ) + + if (consensusChecksums.length < objects.length) { + logger.debug( + s"Not all objects are known to the BFT peers yet. Will retry after delay." + ) + false + } else { + logger.debug( + s"All objects are known to the BFT peers. Checking if checksums match." + ) + val consensus = + bftChecksums.checksums.filter(_.value.isDefined).map(_.value) == objects.map(oc => + Some(oc.checksum) + ) + if (!consensus) { + logger.error( + s"Checksums do not match for objects ${objects.map(_.key).mkString(", ")}. My checksums are: ${objects + .map(_.checksum) + .mkString(", ")}, consensus checksums are: ${consensusChecksums.mkString(", ")}" + ) + + if (appConfig.debugObjectsToNotCommit.intersect(objects.map(_.key)).nonEmpty) { + logger.debug( + s"Some relevant objects are listed in debugObjectsToNotCommit, will ignore them for the consensus check. Ignored objects: ${appConfig.debugObjectsToNotCommit + .intersect(objects.map(_.key)) + .mkString(", ")}" + ) + val objectsWithConsensusChecksums = objects.zip(consensusChecksums) + // Filter out objects for which the key is listed in appConfig.debugObjectsToNotCommit + val unignoredObjectsWithTheirConsensusChecksums = + objectsWithConsensusChecksums.filter { case (obj, _) => + !appConfig.debugObjectsToNotCommit.contains(obj.key) + } + val unignoredObjectsWithMyChecksums = + objects.filter(obj => !appConfig.debugObjectsToNotCommit.contains(obj.key)) + // recheck consensus, but now only on the unignored objects. The comparison should be similar to val consensus above + val unignoredConsensus = + unignoredObjectsWithTheirConsensusChecksums + .filter(_._2.value.isDefined) + .map(_._2.value) == unignoredObjectsWithMyChecksums.map(oc => + Some(oc.checksum) + ) + + if (!unignoredConsensus) { + logger.error( + s"Checksums still do not match for unignored objects ${unignoredObjectsWithMyChecksums + .map(_.key) + .mkString(", ")}. Expected: ${unignoredObjectsWithMyChecksums + .map(_.checksum) + .mkString(", ")}, got: ${unignoredObjectsWithTheirConsensusChecksums.map(_._2.value).mkString(", ")}" + ) + } else { + logger.debug( + s"After ignoring objects from the config, Checksums match ${unignoredObjectsWithMyChecksums.map(_.key).mkString(", ")}. Proceeding with commit." + ) + } + unignoredConsensus + } else { + logger.trace( + s"No relevant objects are listed in debugObjectsToNotCommit, will not ignore any objects for the consensus check." + ) + consensus + } + } else { + logger.trace( + s"Checksums match for all objects ${objects.map(_.key).mkString(", ")}. Proceeding with commit." + ) + true + } + } + case None => + false + } + } + } else { + logger.trace("BFT check is disabled, skipping BFT agreement check") + Future.successful(true) + } } - // TODO(#5884): implement the BFT check private def waitForBftAgreement: Flow[ (T, Seq[ObjectKeyAndChecksum]), (T, Seq[ObjectKeyAndChecksum]), NotUsed, ] = { - Flow[(T, Seq[ObjectKeyAndChecksum])].mapAsync(parallelism = 1) { case (t, obj) => + Flow[(T, Seq[ObjectKeyAndChecksum])].flatMapConcat { case (t, obj) => Source .repeat(obj) .mapAsync(parallelism = 1)(obj => checkBftForObjects(obj).map(result => (obj, result))) @@ -60,8 +161,7 @@ class BulkStorageCommitFromStaging[T]( Source.single((obj, false)).delay(appConfig.bftRetryInterval.underlying) } .takeWhile({ case (_, bftReached) => !bftReached }, inclusive = true) - .runWith(Sink.last) - .map { case (obj, _) => (t, obj) } + .collect { case (o, true) => (t, o) } } } @@ -78,8 +178,15 @@ class BulkStorageCommitFromStaging[T]( ) Future.unit case false => - logger.debug(s"Copying object ${obj.key} from staging to committed storage") - committedS3Connection.copyObject(stagingS3Connection.bucketName, obj.key) + if (appConfig.debugObjectsToNotCommit.contains(obj.key)) { + logger.debug( + s"Object ${obj.key} is listed in debugObjectsToNotCommit, skipping copy to committed storage" + ) + Future.unit + } else { + logger.debug(s"Copying object ${obj.key} from staging to committed storage") + committedS3Connection.copyObject(stagingS3Connection.bucketName, obj.key) + } } } @@ -95,7 +202,10 @@ class BulkStorageCommitFromStaging[T]( ) Future .sequence(objs.map(copyObjectToCommitted(stagingS3Connection, committedS3Connection))) - .map(_ => (ts, objs)) + .map { _ => + onObjectCommitted(objs) + (ts, objs) + } } private def deleteFromStaging: Flow[ @@ -136,18 +246,21 @@ object BulkStorageCommitFromStaging { committedS3Connection: S3BucketConnection, getStagingObjects: T => Future[Seq[ObjectKeyAndChecksum]], appConfig: BulkStorageConfig, + scanConnection: PeerBftScanConnection, loggerFactory: NamedLoggerFactory, + onObjectCommitted: Seq[ObjectKeyAndChecksum] => Unit = _ => (), )(implicit tc: TraceContext, - ec: ExecutionContext, - actorSystem: ActorSystem, + ec: ExecutionContextExecutor, ): Flow[T, T, NotUsed] = { new BulkStorageCommitFromStaging[T]( stagingS3Connection, committedS3Connection, getStagingObjects, appConfig, + scanConnection, loggerFactory, + onObjectCommitted, ).getFlow } } diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/BulkStorageReader.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/BulkStorageReader.scala index de1653fb35..792824f059 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/BulkStorageReader.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/BulkStorageReader.scala @@ -3,6 +3,7 @@ package org.lfdecentralizedtrust.splice.scan.store.bulk +import cats.data.NonEmptyList import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.tracing.TraceContext @@ -28,7 +29,9 @@ class BulkStorageReader( extends NamedLogging { def getCommittedObjectsForAcsSnapshotAtOrBefore( - atOrBeforeTimestamp: CantonTimestamp + atOrBeforeTimestamp: CantonTimestamp, + storageEncodings: NonEmptyList[ScanStorageConfig.Encoding] = + NonEmptyList.one(ScanStorageConfig.Encoding.CompactJson), )(implicit tc: TraceContext, ec: ExecutionContext): Future[AcsSnapshotObjects] = { for { snapshotTs <- @@ -45,7 +48,12 @@ class BulkStorageReader( ts.timestamp case Some(ts) => storageConfig.computeBulkSnapshotTimeAtOrBefore(atOrBeforeTimestamp) } - objects <- getAcsSnapshotObjects(snapshotTs, committedS3Connection, storageConfig) + objects <- getAcsSnapshotObjects( + snapshotTs, + committedS3Connection, + storageConfig, + storageEncodings, + ) } yield { objects } @@ -99,18 +107,26 @@ class BulkStorageReader( def getStagingObjectsForAcsSnapshotAt( timestamp: CantonTimestamp ): Future[AcsSnapshotObjects] = { - getAcsSnapshotObjects(timestamp, stagingS3Connection, storageConfig) + getAcsSnapshotObjects( + timestamp, + stagingS3Connection, + storageConfig, + ScanStorageConfig.Encoding.all, + ) } def getStagingObjectsForUpdateHistorySegment( segment: UpdatesSegment - ): Future[UpdateHistoryObjectsResponse] = getUpdateObjectsInSegment(segment, stagingS3Connection) + ): Future[UpdateHistoryObjectsResponse] = + getUpdateObjectsInSegment(segment, stagingS3Connection, ScanStorageConfig.Encoding.all) def getCommittedUpdatesBetweenDates( afterRecordTime: CantonTimestamp, atOrBeforeRecordTime: CantonTimestamp, limit: PageLimit, nextPageTokenO: Option[String], + storageEncodings: NonEmptyList[ScanStorageConfig.Encoding] = + NonEmptyList.one(ScanStorageConfig.Encoding.CompactJson), )(implicit tc: TraceContext, ec: ExecutionContext): Future[UpdateHistoryObjectsResponse] = getUpdatesBetweenDatesFromBucket( afterRecordTime, @@ -119,6 +135,7 @@ class BulkStorageReader( nextPageTokenO, committedS3Connection, updateHistoryCommittedProgress.readLatestProcessedSegment, + storageEncodings, ) def getStagingSegmentStartingAt( @@ -143,6 +160,19 @@ class BulkStorageReader( } } + def getObjectChecksums( + objectKeys: Seq[String] + ): Future[Seq[Option[String]]] = { + for { + committed <- committedS3Connection.getChecksums(objectKeys) + staging <- stagingS3Connection.getChecksums(objectKeys) + } yield { + objectKeys.map { key => + committed.find(_.key == key).orElse(staging.find(_.key == key)).map(_.checksum) + } + } + } + private def getSegmentStartingAt( startTimestamp: Option[CantonTimestamp] ): Future[Option[(CantonTimestamp, CantonTimestamp)]] = @@ -158,10 +188,18 @@ class BulkStorageReader( ) } + /** Checks if a key in bulk storage matches one of the given [[storageEncodings]]. */ + private def keyMatchesStorageEncodings( + prefix: String, + storageEncodings: NonEmptyList[ScanStorageConfig.Encoding], + )(key: String): Boolean = + storageEncodings.exists(_.storageKeyRegex(prefix).matches(key)) + private def getAcsSnapshotObjects( timestamp: CantonTimestamp, s3Connection: S3BucketConnection, storageConfig: ScanStorageConfig, + storageEncodings: NonEmptyList[ScanStorageConfig.Encoding], ): Future[AcsSnapshotObjects] = { for { objects <- s3Connection @@ -170,7 +208,7 @@ class BulkStorageReader( // (hence the HardLimit, just as a safety precaution). .listObjects( storageConfig.findSegmentFolderPrefixByStartTimestamp(timestamp), - _.matches(".*ACS_\\d+\\.zstd"), + keyMatchesStorageEncodings("ACS", storageEncodings), HardLimit.tryCreate(Limit.DefaultMaxPageSize), ) objectsWithChecksums <- s3Connection.getChecksums(objects) @@ -201,6 +239,7 @@ class BulkStorageReader( nextPageTokenO: Option[String], s3Connection: S3BucketConnection, readLatestProcessedSegment: => Future[Option[UpdatesSegment]], + storageEncodings: NonEmptyList[ScanStorageConfig.Encoding], )(implicit tc: TraceContext, ec: ExecutionContext): Future[UpdateHistoryObjectsResponse] = { def isFolderInRange(folder: String): Boolean = { @@ -299,7 +338,7 @@ class BulkStorageReader( if (folderLimit <= 0) { Future.successful((folderAcc, folderLimit)) } else { - getUpdateObjectsInFolder(s3Connection, folder).map { folderObjs => + getUpdateObjectsInFolder(s3Connection, folder, storageEncodings).map { folderObjs => if (folderObjs.size > folderLimit) { // Folder would exceed the limit; omit it entirely (and stop adding more by making the limit 0) if (folderAcc.isEmpty) { @@ -338,12 +377,13 @@ class BulkStorageReader( private def getUpdateObjectsInSegment( segment: UpdatesSegment, s3Connection: S3BucketConnection, + storageEncodings: NonEmptyList[ScanStorageConfig.Encoding], ): Future[UpdateHistoryObjectsResponse] = { val folder = storageConfig.getSegmentFolder( segment.fromTimestamp.timestamp, Some(segment.toTimestamp.timestamp), ) - getUpdateObjectsInFolder(s3Connection, folder) + getUpdateObjectsInFolder(s3Connection, folder, storageEncodings) .flatMap(s3Connection.getChecksums) .map { objectsWithChecksums => if (objectsWithChecksums.isEmpty) { @@ -366,9 +406,10 @@ class BulkStorageReader( private def getUpdateObjectsInFolder( s3Connection: S3BucketConnection, folder: String, + storageEncodings: NonEmptyList[ScanStorageConfig.Encoding], ): Future[Seq[String]] = s3Connection.listObjects( prefix = folder, - _.matches(".*updates_\\d+\\.zstd"), + keyMatchesStorageEncodings("updates", storageEncodings), HardLimit.tryCreate(Limit.DefaultMaxPageSize), ) diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/GroupedWeightS3ObjectFlow.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/GroupedWeightS3ObjectFlow.scala index 60df59359d..9825e0e6b4 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/GroupedWeightS3ObjectFlow.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/GroupedWeightS3ObjectFlow.scala @@ -83,6 +83,14 @@ case class GroupedWeightS3ObjectFlow( @SuppressWarnings(Array("org.wartremover.warts.Var")) private var state = State.initial() + // Guards against finishing the same object twice, which can otherwise happen because finish() + // is triggered both from uploadCallback and from onUpstreamFinish. + // At the time of writing, a duplicate finish is actually harmless, as it calls + // AppendWriteObject.finish() which is idempotent. We guard against it anyway, to protect against + // future changes that would make finishing an object non-idempotent. + @SuppressWarnings(Array("org.wartremover.warts.Var")) + private var finishingObject = false + private def objectDone = state.currentObjectSize >= maxObjectSize || isClosed(in) private val uploadCallback = getAsyncCallback[Unit] { _ => @@ -111,6 +119,7 @@ case class GroupedWeightS3ObjectFlow( private val finishCallback = getAsyncCallback[Unit] { _ => logger.debug(s"Finished uploading and finalizing object ${state.currentObject.key}") + finishingObject = false push(out, state.currentObject.key) if (isClosed(in)) { logger.trace("Upstream completed, completing too.") @@ -156,13 +165,24 @@ case class GroupedWeightS3ObjectFlow( } private def finishCurrentObject(): Unit = - state.currentObject.finish().onComplete { - case Success(_) => finishCallback.invoke(()) - case Failure(ex) => failCallback.invoke(ex) + if (finishingObject) { + logger.debug( + s"Object ${state.currentObject.key} is already being finished, not finishing it again" + ) + } else { + finishingObject = true + state.currentObject.finish().onComplete { + case Success(_) => finishCallback.invoke(()) + case Failure(ex) => failCallback.invoke(ex) + } } override def onUpstreamFinish(): Unit = { - if (state.numPendingPartUploads == 0) { + if (finishingObject) { + logger.debug( + s"Upstream finished while object ${state.currentObject.key} is being finished, waiting for it to complete" + ) + } else if (state.numPendingPartUploads == 0) { if (state.currentObjectSize > 0) { logger.debug( s"Upstream finished, finishing current object ${state.currentObject.key} with size ${state.currentObjectSize}" diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/MultiEncodingBulkStorageFlow.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/MultiEncodingBulkStorageFlow.scala new file mode 100644 index 0000000000..9e1342e4c1 --- /dev/null +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/MultiEncodingBulkStorageFlow.scala @@ -0,0 +1,49 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.scan.store.bulk + +import org.apache.pekko.NotUsed +import org.apache.pekko.stream.FlowShape +import org.apache.pekko.stream.scaladsl.{Broadcast, Flow, GraphDSL, Merge} +import org.apache.pekko.util.ByteString +import org.lfdecentralizedtrust.splice.scan.config.ScanStorageConfig + +/** Uploads every chunk of a bulk storage dump once per [[ScanStorageConfig.Encoding]]. + * + * Each upstream chunk is broadcast to one branch per encoding, where it's encoded, uploaded via + * the given [[uploadFlow]] (an [[S3ZstdObjects]] flow), and counted in the metrics. The emitted + * object keys of all branches are merged into a single downstream output. + * + * Note that the `Merge` is not eager, so the resulting flow only completes once every branch has + * uploaded all of its objects. Callers can therefore rely on total completion before advancing a + * progress marker. + */ +object MultiEncodingBulkStorageFlow { + private lazy val encodings = ScanStorageConfig.Encoding.all.toList + private lazy val numEncodings = encodings.length + + def apply[A]( + encode: (A, ScanStorageConfig.Encoding) => ByteString, + uploadFlow: ScanStorageConfig.Encoding => Flow[ByteString, String, ?], + incObjects: ScanStorageConfig.Encoding => Unit, + ): Flow[A, String, NotUsed] = { + Flow.fromGraph(GraphDSL.create() { implicit b => + import GraphDSL.Implicits.* + + val broadcast = b.add(Broadcast[A](numEncodings)) + val merge = b.add(Merge[String](numEncodings)) + + encodings.zipWithIndex.foreach { case (encoding, i) => + val branch = Flow[A] + .map(encode(_, encoding)) + .via(uploadFlow(encoding)) + .wireTap(_ => incObjects(encoding)) + + broadcast.out(i) ~> branch ~> merge.in(i) + } + + FlowShape(broadcast.in, merge.out) + }) + } +} diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/SingleAcsSnapshotBulkStorage.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/SingleAcsSnapshotBulkStorage.scala index fb514a6dce..5c54c8ee1a 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/SingleAcsSnapshotBulkStorage.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/SingleAcsSnapshotBulkStorage.scala @@ -8,7 +8,7 @@ import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.tracing.TraceContext import org.apache.pekko.stream.scaladsl.{Flow, Source} import org.apache.pekko.util.ByteString -import org.lfdecentralizedtrust.splice.scan.admin.http.CompactJsonScanHttpEncodings +import org.lfdecentralizedtrust.splice.scan.admin.http.ScanHttpEncodings import org.lfdecentralizedtrust.splice.scan.store.AcsSnapshotStore import org.lfdecentralizedtrust.splice.store.{ HistoryMetrics, @@ -16,6 +16,7 @@ import org.lfdecentralizedtrust.splice.store.{ S3BucketConnection, TimestampWithMigrationId, } +import org.lfdecentralizedtrust.splice.store.events.SpliceCreatedEvent import scala.concurrent.Future import io.circe.syntax.* @@ -32,7 +33,7 @@ object Position { case object End extends Position - final case class Index(value: Long) extends Position + final case class Index(value: AcsSnapshotStore.QueryAcsSnapshotPaginationToken) extends Position } class SingleAcsSnapshotBulkStorage( @@ -46,15 +47,10 @@ class SingleAcsSnapshotBulkStorage( )(implicit tc: TraceContext, ec: ExecutionContext) extends NamedLogging { - case class AcsSnapshotChunk( - chunkBytes: ByteString, - numContracts: Int, - ) - private def getAcsSnapshotChunk( timestamp: TimestampWithMigrationId, - after: Option[Long], - ): Future[(Position, AcsSnapshotChunk)] = { + after: Option[AcsSnapshotStore.QueryAcsSnapshotPaginationToken], + ): Future[(Position, Vector[SpliceCreatedEvent])] = { for { snapshot <- acsSnapshotStore.queryAcsSnapshot( timestamp.migrationId, @@ -64,22 +60,27 @@ class SingleAcsSnapshotBulkStorage( Seq.empty, Seq.empty, ) - } yield { - val encoded = snapshot.createdEventsInPage.map(event => - CompactJsonScanHttpEncodings() - .javaToHttpActiveContract(event.eventId, event.recordTime, event.event) - ) - val contractsStr = encoded.map(_.asJson.noSpacesSortKeys).mkString("\n") + "\n" - val contractsBytes = ByteString(contractsStr.getBytes(StandardCharsets.UTF_8)) - logger.debug( - s"Read ${encoded.length} contracts from ACS, to a bytestring of size ${contractsBytes.length} bytes" - ) - ( - snapshot.afterToken.fold(End: Position)(Index(_)), - AcsSnapshotChunk(contractsBytes, encoded.length), - ) - } + } yield ( + snapshot.afterToken.fold(End: Position)(Index(_)), + snapshot.createdEventsInPage, + ) + + } + private def encodeEvents( + events: Vector[SpliceCreatedEvent], + encoding: ScanStorageConfig.Encoding, + ): ByteString = { + val encodings = ScanHttpEncodings.fromDamlValueEncoding(encoding.damlValueEncoding) + val encoded = events.map(event => + encodings.javaToHttpActiveContract(event.eventId, event.recordTime, event.event) + ) + val contractsStr = encoded.map(_.asJson.noSpacesSortKeys).mkString("\n") + "\n" + val contractsBytes = ByteString(contractsStr.getBytes(StandardCharsets.UTF_8)) + logger.debug( + s"Read ${encoded.length} contracts from ACS, to a bytestring of size ${contractsBytes.length} bytes, with encoding ${encoding.key}" + ) + contractsBytes } private def getSource: Source[Seq[String], NotUsed] = { @@ -89,22 +90,26 @@ class SingleAcsSnapshotBulkStorage( case Index(i) => getAcsSnapshotChunk(timestamp, Some(i)).map(Some(_)) case End => Future.successful(None) } - .map(chunk => { - historyMetrics.BulkStorage.incContractsCount(chunk.numContracts) - chunk.chunkBytes + .map(events => { + historyMetrics.BulkStorage.incContractsCount(events.length) + events }) .via( - S3ZstdObjects( - storageConfig, - appConfig, - s3Connection, - { objIdx => - s"${storageConfig.getSegmentFolder(timestamp.timestamp, None)}/ACS_$objIdx.zstd" - }, - loggerFactory, + MultiEncodingBulkStorageFlow( + encodeEvents, + encoding => + S3ZstdObjects( + storageConfig, + appConfig, + s3Connection, + objIdx => + s"${storageConfig.getSegmentFolder(timestamp.timestamp, None)}/${encoding + .storageKey("ACS", objIdx)}", + loggerFactory, + ), + encoding => historyMetrics.BulkStorage.incAcsSnapshotObjects(encoding.key, "staging"), ) ) - .wireTap(_ => historyMetrics.BulkStorage.incAcsSnapshotObjects()) .fold(Seq.empty[String])(_ :+ _) } } diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/UpdateHistoryBulkStorage.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/UpdateHistoryBulkStorage.scala index a27d418a20..d0e858829e 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/UpdateHistoryBulkStorage.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/UpdateHistoryBulkStorage.scala @@ -67,6 +67,10 @@ class UpdateHistoryBulkStoragePersistentProgress( ) }) } + + def reset(implicit tc: TraceContext): Future[Unit] = { + kvProvider.store.deleteKey(kvStoreKey) + } } /** An abstract class for pipelines that process update history for bulk storage. diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/UpdateHistoryBulkStorageCommitFromStaging.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/UpdateHistoryBulkStorageCommitFromStaging.scala index 1911f9f893..7a07c2f326 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/UpdateHistoryBulkStorageCommitFromStaging.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/UpdateHistoryBulkStorageCommitFromStaging.scala @@ -7,21 +7,24 @@ import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.tracing.TraceContext import io.grpc.{Status, StatusRuntimeException} import org.apache.pekko.NotUsed -import org.apache.pekko.actor.ActorSystem import org.apache.pekko.stream.scaladsl.Flow import org.lfdecentralizedtrust.splice.scan.config.BulkStorageConfig +import org.lfdecentralizedtrust.splice.scan.util.PeerBftScanConnection import org.lfdecentralizedtrust.splice.store.{S3BucketConnection, TimestampWithMigrationId} -import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.{ExecutionContextExecutor, Future} class UpdateHistoryBulkStorageCommitFromStaging( stagingS3Connection: S3BucketConnection, committedS3Connection: S3BucketConnection, bulkStorageReader: BulkStorageReader, appConfig: BulkStorageConfig, + scanConnection: PeerBftScanConnection, + onObjectCommitted: Seq[S3BucketConnection.ObjectKeyAndChecksum] => Unit, val loggerFactory: NamedLoggerFactory, -)(implicit ec: ExecutionContext, actorSystem: ActorSystem) - extends UpdateHistoryBulkStorageWriter +)(implicit + ec: ExecutionContextExecutor +) extends UpdateHistoryBulkStorageWriter with NamedLogging { override def processSegmentsFlow(implicit tc: TraceContext @@ -39,7 +42,9 @@ class UpdateHistoryBulkStorageCommitFromStaging( Seq.empty }, appConfig, + scanConnection, loggerFactory, + onObjectCommitted, ) override def getNextSegmentAfter( diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/UpdateHistorySegmentBulkStorage.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/UpdateHistorySegmentBulkStorage.scala index bd0782c85a..fb8b915b96 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/UpdateHistorySegmentBulkStorage.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/UpdateHistorySegmentBulkStorage.scala @@ -3,6 +3,7 @@ package org.lfdecentralizedtrust.splice.scan.store.bulk +import cats.data.NonEmptyList import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.tracing.TraceContext import org.apache.pekko.NotUsed @@ -10,7 +11,6 @@ import org.apache.pekko.stream.scaladsl.{Flow, Source} import org.lfdecentralizedtrust.splice.scan.config.{BulkStorageConfig, ScanStorageConfig} import org.apache.pekko.util.ByteString import org.apache.pekko.pattern.after -import org.lfdecentralizedtrust.splice.http.v0.definitions import org.lfdecentralizedtrust.splice.scan.admin.http.{ScanHttpEncodings, ScanJsonSupport} import org.lfdecentralizedtrust.splice.store.{ HistoryMetrics, @@ -56,17 +56,14 @@ class UpdateHistorySegmentBulkStorage( )(implicit tc: TraceContext, ec: ExecutionContext) extends NamedLogging { - case class UpdatesChunk( - updateBytes: ByteString, - numUpdates: Int, - ) - private def getUpdatesChunk( afterTs: TimestampWithMigrationId - )(implicit actorSystem: ActorSystem): Future[Option[(TimestampWithMigrationId, UpdatesChunk)]] = { + )(implicit + actorSystem: ActorSystem + ): Future[Option[(TimestampWithMigrationId, Seq[TreeUpdateWithMigrationId])]] = { for { updates <- updateHistory.getUpdatesWithoutImportUpdates( - Some((afterTs.migrationId, afterTs.timestamp)), + Some(TimestampWithMigrationId(afterTs.timestamp, afterTs.migrationId)), PageLimit.tryCreate(storageConfig.bulkDbReadChunkSize), ) updatesInSegment = updates.filter(update => @@ -85,7 +82,6 @@ class UpdateHistorySegmentBulkStorage( s"Adding ${updatesInSegment.length} updates, between record time ${updatesInSegment.headOption .map(_.update.update.recordTime)} and ${updatesInSegment.lastOption.map(_.update.update.recordTime)}" ) - val updatesBytes: ByteString = encodeUpdates(updatesInSegment) val last = updatesInSegment.lastOption.getOrElse( throw new RuntimeException("Unexpected failure") ) @@ -93,7 +89,7 @@ class UpdateHistorySegmentBulkStorage( Some( ( TimestampWithMigrationId(last.update.update.recordTime, last.migrationId), - UpdatesChunk(updatesBytes, updatesInSegment.length), + updatesInSegment, ) ) ) @@ -113,7 +109,7 @@ class UpdateHistorySegmentBulkStorage( appConfig.updatesPollingInterval.underlying, actorSystem.scheduler, ) { - Future.successful(Some((afterTs, UpdatesChunk(ByteString.empty, 0)))) + Future.successful(Some((afterTs, Nil))) } } } yield { @@ -121,11 +117,14 @@ class UpdateHistorySegmentBulkStorage( } } - private def encodeUpdates(updates: Seq[TreeUpdateWithMigrationId]) = { - val encoded = updates.map(update => + private def encodeUpdates( + updates: NonEmptyList[TreeUpdateWithMigrationId], + encoding: ScanStorageConfig.Encoding, + ): ByteString = { + val encoded = updates.toList.map(update => ScanHttpEncodings.encodeUpdateV2( update, - definitions.DamlValueEncoding.CompactJson, + encoding.damlValueEncoding, ScanHttpEncodings.V1, ) ) @@ -139,8 +138,7 @@ class UpdateHistorySegmentBulkStorage( .mkString("\n") + "\n" val updatesBytes = ByteString(updatesStr.getBytes(StandardCharsets.UTF_8)) logger.debug( - s"Read and encoded ${encoded.length} updates from DB, to a bytestring of size ${updatesBytes.length} bytes. Timestamps are ${updates.headOption - .map(_.update.update.recordTime)} to ${updates.lastOption.map(_.update.update.recordTime)}" + s"Read and encoded ${encoded.length} updates from DB, to a bytestring of size ${updatesBytes.length} bytes, with encoding ${encoding.key}. Timestamps are ${updates.head.update.update.recordTime} to ${updates.last.update.update.recordTime}" ) updatesBytes } @@ -150,30 +148,35 @@ class UpdateHistorySegmentBulkStorage( ): Source[Seq[String], NotUsed] = { Source .unfoldAsync(segment.fromTimestamp)(ts => getUpdatesChunk(ts)) - .map(chunk => { - historyMetrics.BulkStorage.incUpdatesCount(chunk.numUpdates) - chunk.updateBytes + .map(updates => { + historyMetrics.BulkStorage.incUpdatesCount(updates.length) + updates }) .via( - // We use lazyFlow, so that in the case where no updates are emitted, we don't instantiate the S3ZstdObjects at all, - // since it assumes that it gets at least one chunk to write. - Flow.lazyFlow(() => - S3ZstdObjects( - storageConfig, - appConfig, - s3Connection, - { objIdx => - s"${storageConfig.getSegmentFolder(segment.fromTimestamp.timestamp, Some(segment.toTimestamp.timestamp))}/updates_$objIdx.zstd" - }, - loggerFactory, - ) + MultiEncodingBulkStorageFlow( + (updates, encoding) => + NonEmptyList.fromFoldable(updates).fold(ByteString.empty)(encodeUpdates(_, encoding)), + encoding => + // We use lazyFlow, so that in the case where no updates are emitted, we don't instantiate the S3ZstdObjects at all, + // since it assumes that it gets at least one chunk to write. + Flow.lazyFlow(() => + S3ZstdObjects( + storageConfig, + appConfig, + s3Connection, + objIdx => + s"${storageConfig.getSegmentFolder(segment.fromTimestamp.timestamp, Some(segment.toTimestamp.timestamp))}/${encoding + .storageKey("updates", objIdx)}", + loggerFactory, + ) + ), + encoding => historyMetrics.BulkStorage.incUpdateObjects(encoding.key, "staging"), ) ) .orElse(Source.lazySource { () => logger.warn(s"No updates found in segment ${segment.fromTimestamp}-${segment.toTimestamp}") Source.empty }) - .wireTap(_ => historyMetrics.BulkStorage.incUpdateObjects()) .fold(Seq.empty[String])(_ :+ _) } } diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbAppActivityRecordStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbAppActivityRecordStore.scala index a60594dc25..60a3664e00 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbAppActivityRecordStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbAppActivityRecordStore.scala @@ -4,6 +4,7 @@ package org.lfdecentralizedtrust.splice.scan.store.db import org.lfdecentralizedtrust.splice.scan.store.AppActivityStore +import org.lfdecentralizedtrust.splice.scan.store.AppActivityStore.RoundIngestionStatus import org.lfdecentralizedtrust.splice.store.UpdateHistory import org.lfdecentralizedtrust.splice.util.FutureUnlessShutdownUtil.futureUnlessShutdownToFuture import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} @@ -171,7 +172,7 @@ class DbAppActivityRecordStore( * This round may not have all app activity records ingested. * Returns None if no app activity records have been ingested, ie meta row does not exist. */ - def earliestIngestedRound()(implicit + private[store] def earliestIngestedRound()(implicit tc: TraceContext ): Future[Option[Long]] = { val codeVersion = ingestionVersions.code @@ -187,6 +188,32 @@ class DbAppActivityRecordStore( ) } + override def ingestionStatusForRound(roundNumber: Long)(implicit + tc: TraceContext + ): Future[RoundIngestionStatus] = + earliestIngestedRound().map { + case Some(earliestIngested) if roundNumber <= earliestIngested => + // We should have data for this round but no root hash exists: + // a peer likely does, so delegate. + RoundIngestionStatus.CannotProvide + + case Some(_) => + // Meta row present but round is beyond our ingested boundary — + // ingestion is still catching up; retry. + RoundIngestionStatus.Undetermined + + case None if !isFirstSv => + // Late-joining Scan with no ingestion boundary of its own — + // it might seem Undetermined is right, but peers do have one, + // so we delegate. + RoundIngestionStatus.CannotProvide + + case None => + // firstSV during initial ingestion (brief startup window before + // the meta row is inserted) — retry. + RoundIngestionStatus.Undetermined + } + /** Find the latest round with complete app activity. * A round is complete once the verdict ingestion has moved passed its archival. * Returns None if no meta row exists or archival of a round has not happened yet. @@ -293,20 +320,23 @@ class DbAppActivityRecordStore( (sql""" insert into #${Tables.appActivityRecords}( history_id, verdict_row_id, round_number, app_provider_parties, app_activity_weights - ) values """ ++ values).asUpdate + ) values """ ++ values ++ sql" ON CONFLICT DO NOTHING").asUpdate } } /** Insert activity records and ensure the meta row exists. * Creates the meta row when enough information is available to * determine which rounds have complete activity, even when no - * activity records exist (e.g., no featured app providers). + * activity records exist (e.g., no featured app providers), + * but only if traffic-summaries could be obtained for this batch. * On a fresh firstSV with no archived rounds, bootstraps round 0 * as complete. */ def insertAppActivityRecordsDBIO( items: Seq[AppActivityRecordT], firstRecordTimeMicros: Long, + hasTrafficSummaries: Boolean, + firstActiveRoundO: Option[Long] = None, lastArchivedRoundO: Option[Long] = None, )(implicit tc: TraceContext): DBIO[Unit] = { val insertRecords = @@ -316,15 +346,9 @@ class DbAppActivityRecordStore( logger.info(s"Inserted ${items.size} app activity records.") } - // earliestRound: the lowest round covered by this ingestion batch. - // - From activity records when present - // - From lastArchivedRound when no featured apps produced records - // - From bootstrap (-1) on a fresh firstSV with no archived rounds - val earliestRound = items - .map(_.roundNumber) - .minOption - .orElse(lastArchivedRoundO) - .orElse(if (isFirstSv) Some(-1L) else None) + // earliestRound: the oldest round open at the earliest record_time of this batch. + // or (-1) on firstSV, as it is expected to have complete data for the first round. + val earliestRound = if (isFirstSv) Some(-1L) else firstActiveRoundO // lastArchived: the highest round archived as of this verdict batch. // - From the caller when available @@ -337,10 +361,12 @@ class DbAppActivityRecordStore( for { _ <- insertRecords ensureResult <- earliestRound match { - case Some(earliest) => + case Some(earliest) if hasTrafficSummaries => ensureMetaDBIO((firstRecordTimeMicros, earliest), lastArchived) - case None => - // No archived rounds and not firstSV — skip meta creation. + case _ => + // Either we have no rounds info and this is not firstSV, + // or we have not started obtaining the traffic summaries yet + // — skip meta creation. // A later verdict batch will create it. DBIO.successful(Resume: MetaCheckResult) } @@ -416,6 +442,7 @@ class DbAppActivityRecordStore( earliest_ingested_round, last_archived_round) values ($historyId, $codeVersion, $userVersion, $startedIngestingAt, $earliestIngestedRound, $lastArchivedRound) + ON CONFLICT DO NOTHING """.asUpdate private def updateLastArchivedRoundDBIO(round: Long) = diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanRewardsReferenceStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanRewardsReferenceStore.scala index 09313f5e42..1dd5c85fdd 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanRewardsReferenceStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanRewardsReferenceStore.scala @@ -17,7 +17,12 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.round.OpenMiningRound import org.lfdecentralizedtrust.splice.config.IngestionConfig import org.lfdecentralizedtrust.splice.environment.RetryProvider import org.lfdecentralizedtrust.splice.scan.store.ScanRewardsReferenceStore -import org.lfdecentralizedtrust.splice.store.{Limit, LimitHelpers, TcsStore} +import org.lfdecentralizedtrust.splice.store.{ + Limit, + LimitHelpers, + TcsStore, + TimestampWithMigrationId, +} import org.lfdecentralizedtrust.splice.store.db.{ AcsArchiveConfig, AcsQueries, @@ -103,7 +108,7 @@ class DbScanRewardsReferenceStore( override def lookupActiveOpenMiningRounds( recordTimes: Seq[CantonTimestamp] - )(implicit tc: TraceContext): Future[Map[CantonTimestamp, (Long, CantonTimestamp)]] = { + )(implicit tc: TraceContext): Future[Map[CantonTimestamp, TimestampWithMigrationId]] = { tcsStore.getEarliestArchivedAt().flatMap { case None => Future.successful(Map.empty) @@ -125,7 +130,10 @@ class DbScanRewardsReferenceStore( .flatMap { r => val opensAt = CantonTimestamp.assertFromInstant(r.contract.payload.opensAt) Option.when(opensAt >= ingestionStart) { - recordTime -> (r.contract.payload.round.number.toLong, opensAt) + recordTime -> TimestampWithMigrationId( + opensAt, + r.contract.payload.round.number.toLong, + ) } } }.toMap diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanStore.scala index 2f69177fe4..871c2633b6 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanStore.scala @@ -59,9 +59,8 @@ import org.lfdecentralizedtrust.splice.store.{ DbVotesAcsStoreQueryBuilder, DbVotesTxLogStoreQueryBuilder, Limit, - PageLimit, + VoteResultsFilters, ResultsPage, - SortOrder, TxLogStore, UpdateHistory, } @@ -78,7 +77,6 @@ import org.lfdecentralizedtrust.splice.config.IngestionConfig import org.lfdecentralizedtrust.splice.store.UpdateHistoryQueries.UpdateHistoryQueries import org.lfdecentralizedtrust.splice.store.db.AcsQueries.AcsStoreId import org.lfdecentralizedtrust.splice.store.db.TxLogQueries.TxLogStoreId -import slick.jdbc.canton.SQLActionBuilder import scala.concurrent.{ExecutionContext, Future} import scala.jdk.CollectionConverters.* @@ -390,63 +388,6 @@ class DbScanStore( } yield contractWithStateFromRow(TransferCommandCounter.COMPANION)(row)).value } - override def listTransactions( - pageEndEventId: Option[String], - sortOrder: SortOrder, - limit: PageLimit, - )(implicit - tc: TraceContext - ): Future[Seq[TxLogEntry.TransactionTxLogEntry]] = - waitUntilAcsIngested { - val entryTypeCondition: SQLActionBuilder = inClause( - "entry_type", - List( - EntryType.TransferTxLogEntry, - EntryType.TapTxLogEntry, - EntryType.MintTxLogEntry, - EntryType.AbortTransferInstructionTxLogEntry, - ), - ) - // Literal sort order since Postgres complains when trying to bind it to a parameter - val (compareEntryNumber, orderLimit) = sortOrder match { - case SortOrder.Ascending => - (sql" > ", sql""" order by entry_number asc limit ${sqlLimit(limit)};""") - case SortOrder.Descending => - (sql" < ", sql""" order by entry_number desc limit ${sqlLimit(limit)};""") - } - - // TODO (#960): don't use the event id for pagination, use the entry number - for { - rows <- storage.query( - pageEndEventId.fold( - selectFromTxLogTable( - txLogTableName, - txLogStoreId, - where = entryTypeCondition, - orderLimit = orderLimit, - ) - )(pageEndEventId => - selectFromTxLogTable( - txLogTableName, - txLogStoreId, - where = (entryTypeCondition ++ sql" and entry_number " ++ compareEntryNumber ++ - sql"""( - select entry_number - from scan_txlog_store - where store_id = $txLogStoreId - and event_id = ${lengthLimited(pageEndEventId)} - and """ ++ entryTypeCondition ++ sql""" - )""").toActionBuilder, - orderLimit = orderLimit, - ) - ), - "listTransactions", - ) - entries = rows.map(txLogEntryFromRow[TxLogEntry.TransactionTxLogEntry](txLogConfig)) - } yield entries - - } - override def lookupFeaturedAppRight( providerPartyId: PartyId )(implicit @@ -633,11 +574,7 @@ class DbScanStore( } override def listVoteRequestResults( - actionName: Option[String], - accepted: Option[Boolean], - requester: Option[String], - effectiveFrom: Option[String], - effectiveTo: Option[String], + filters: VoteResultsFilters, limit: Limit, after: Option[Long] = None, )(implicit tc: TraceContext): Future[ResultsPage[DsoRules_CloseVoteRequestResult]] = { @@ -648,11 +585,7 @@ class DbScanStore( actionNameColumnName = "vote_action_name", acceptedColumnName = "vote_accepted", requesterNameColumnName = "vote_requester_name", - actionName = actionName, - accepted = accepted, - requester = requester, - effectiveFrom = effectiveFrom, - effectiveTo = effectiveTo, + filters = filters, limit = limit, after = after, ) @@ -668,6 +601,23 @@ class DbScanStore( } yield ResultsPage(recentVoteResults, afterToken) } + override def countVoteRequestResults( + filters: VoteResultsFilters + )(implicit tc: TraceContext): Future[Long] = { + val query = countVoteRequestResultsQuery( + txLogTableName = ScanTables.txLogTableName, + txLogStoreId = txLogStoreId, + dbType = EntryType.VoteRequestTxLogEntry, + actionNameColumnName = "vote_action_name", + acceptedColumnName = "vote_accepted", + requesterNameColumnName = "vote_requester_name", + filters = filters, + ) + storage + .query(query, "countVoteRequestResults") + .map(_.headOption.getOrElse(0L)) + } + override def lookupLatestSvRewardWeightChange( svParty: PartyId, effectiveBefore: Option[String], diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanVerdictStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanVerdictStore.scala index 3e5cdf0646..bd565c986e 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanVerdictStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanVerdictStore.scala @@ -25,7 +25,7 @@ import slick.dbio.DBIO import java.util.concurrent.atomic.AtomicReference import scala.concurrent.{ExecutionContext, Future} import cats.data.NonEmptyList -import org.lfdecentralizedtrust.splice.store.UpdateHistory +import org.lfdecentralizedtrust.splice.store.{TimestampWithMigrationId, UpdateHistory} import org.lfdecentralizedtrust.splice.scan.store.db.DbAppActivityRecordStore.AppActivityRecordT object DbScanVerdictStore { @@ -237,13 +237,13 @@ object DbScanVerdictStore { def apply( storage: com.digitalasset.canton.resource.DbStorage, updateHistory: UpdateHistory, - appActivityRecordStoreO: Option[DbAppActivityRecordStore], + appActivityRecordStore: DbAppActivityRecordStore, loggerFactory: NamedLoggerFactory, )(implicit ec: ExecutionContext): DbScanVerdictStore = new DbScanVerdictStore( storage, updateHistory, - appActivityRecordStoreO, + appActivityRecordStore, loggerFactory, ) } @@ -251,7 +251,7 @@ object DbScanVerdictStore { class DbScanVerdictStore( storage: DbStorage, updateHistory: UpdateHistory, - val appActivityRecordStoreO: Option[DbAppActivityRecordStore], + val appActivityRecordStore: DbAppActivityRecordStore, override protected val loggerFactory: NamedLoggerFactory, )(implicit ec: ExecutionContext @@ -479,12 +479,17 @@ class DbScanVerdictStore( * * @param items verdicts with transaction view constructors * @param appActivityRecords activity records with placeholder verdictRowIds + * @param hasTrafficSummaries whether traffic summaries were fetched for this batch + * @param firstActiveRoundO the OpenMiningRound round active at the earliest + * record time of the batch * @param lastArchivedRoundO the highest archived OpenMiningRound round as of the * max record time of the batch */ def insertVerdictsWithAppActivityRecords( items: NonEmptyList[(VerdictT, Long => Seq[TransactionViewT])], appActivityRecords: Seq[(CantonTimestamp, AppActivityRecordT)], + hasTrafficSummaries: Boolean, + firstActiveRoundO: Option[Long] = None, lastArchivedRoundO: Option[Long] = None, )(implicit tc: TraceContext): Future[Unit] = { import profile.api.jdbcActionExtensionMethods @@ -498,6 +503,8 @@ class DbScanVerdictStore( _ <- insertAppActivityRecordsDBIO( resolvedAppActivityRecords, items.head._1.recordTime.toMicros, + hasTrafficSummaries, + firstActiveRoundO, lastArchivedRoundO, ) } yield () @@ -545,23 +552,27 @@ class DbScanVerdictStore( private def insertAppActivityRecordsDBIO( items: Seq[AppActivityRecordT], firstRecordTimeMicros: Long, + hasTrafficSummaries: Boolean, + firstActiveRoundO: Option[Long], lastArchivedRoundO: Option[Long], )(implicit tc: TraceContext): DBIO[Unit] = - appActivityRecordStoreO match { - case None => DBIO.successful(()) - case Some(s) => - s.insertAppActivityRecordsDBIO(items, firstRecordTimeMicros, lastArchivedRoundO) - } + appActivityRecordStore.insertAppActivityRecordsDBIO( + items, + firstRecordTimeMicros, + hasTrafficSummaries, + firstActiveRoundO, + lastArchivedRoundO, + ) private def afterFilters( - afterO: Option[(Long, CantonTimestamp)], + afterO: Option[TimestampWithMigrationId], includeImportUpdates: Boolean, ): NonEmptyList[SQLActionBuilder] = { val gt = if (includeImportUpdates) ">=" else ">" afterO match { case None => NonEmptyList.of(sql"migration_id >= 0 and record_time #$gt ${CantonTimestamp.MinValue}") - case Some((afterMigrationId, afterRecordTime)) => + case Some(TimestampWithMigrationId(afterRecordTime, afterMigrationId)) => NonEmptyList.of( sql"migration_id = ${afterMigrationId} and record_time > ${afterRecordTime} ", sql"migration_id > ${afterMigrationId} and record_time #$gt ${CantonTimestamp.MinValue}", @@ -604,7 +615,7 @@ class DbScanVerdictStore( } def listVerdicts( - afterO: Option[(Long, CantonTimestamp)], + afterO: Option[TimestampWithMigrationId], includeImportUpdates: Boolean, limit: Int, )(implicit tc: TraceContext): Future[Seq[VerdictT]] = { diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/ScanTables.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/ScanTables.scala index bba1683131..de109b94bf 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/ScanTables.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/ScanTables.scala @@ -15,19 +15,10 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.actionrequir } import org.lfdecentralizedtrust.splice.codegen.java.splice.externalpartyamuletrules.TransferCommand import org.lfdecentralizedtrust.splice.scan.store.{ - AbortTransferInstructionTxLogEntry, - AppRewardTxLogEntry, - BalanceChangeTxLogEntry, ClosedMiningRoundTxLogEntry, ErrorTxLogEntry, - ExtraTrafficPurchaseTxLogEntry, - MintTxLogEntry, OpenMiningRoundTxLogEntry, - SvRewardTxLogEntry, - TapTxLogEntry, - TransferTxLogEntry, TxLogEntry, - ValidatorRewardTxLogEntry, VoteRequestTxLogEntry, TransferCommandTxLogEntry, } @@ -193,50 +184,6 @@ object ScanTables extends AcsTables { round = Some(cmr.round), closedRoundEffectiveAt = cmr.effectiveAt.map(CantonTimestamp.assertFromInstant), ) - case are: AppRewardTxLogEntry => - ScanTxLogRowData( - entry = are, - round = Some(are.round), - rewardAmount = Some(are.amount), - rewardedParty = Some(are.party), - ) - case vre: ValidatorRewardTxLogEntry => - ScanTxLogRowData( - entry = vre, - round = Some(vre.round), - rewardAmount = Some(vre.amount), - rewardedParty = Some(vre.party), - ) - case sre: SvRewardTxLogEntry => - ScanTxLogRowData( - entry = sre, - round = Some(sre.round), - rewardAmount = Some(sre.amount), - rewardedParty = Some(sre.party), - ) - case etp: ExtraTrafficPurchaseTxLogEntry => - ScanTxLogRowData( - entry = etp, - round = Some(etp.round), - extraTrafficValidator = Some(etp.validator), - extraTrafficPurchaseTrafficPurchase = Some(etp.trafficPurchased), - extraTrafficPurchaseCcSpent = Some(etp.ccSpent), - ) - case rar: TransferTxLogEntry => - ScanTxLogRowData( - entry = rar, - round = Some(rar.round), - ) - case entry: TapTxLogEntry => - ScanTxLogRowData( - entry = entry, - round = Some(entry.round), - ) - case entry: MintTxLogEntry => - ScanTxLogRowData( - entry = entry, - round = Some(entry.round), - ) case vr: VoteRequestTxLogEntry => val result = vr.result.getOrElse(throw txMissingField()) val parsedOutcome = VoteRequestOutcome.parse(result.outcome) @@ -268,20 +215,12 @@ object ScanTables extends AcsTables { entry.nonce ), ) - case entry: AbortTransferInstructionTxLogEntry => - ScanTxLogRowData( - entry = entry - ) case _ => throw txEncodingFailed() } } record match { - case _: BalanceChangeTxLogEntry => - // the balance changes are no longer indexed, or written, to the tx log table, - // See https://github.com/canton-network/splice/pull/3734 - None case entry => Some(fromEntry(entry)) } } diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/util/PeerBftScanConnection.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/util/PeerBftScanConnection.scala new file mode 100644 index 0000000000..22208a9b08 --- /dev/null +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/util/PeerBftScanConnection.scala @@ -0,0 +1,67 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.scan.util + +import com.digitalasset.canton.logging.NamedLoggerFactory +import com.digitalasset.canton.time.Clock +import com.digitalasset.canton.tracing.TraceContext +import com.digitalasset.canton.util.Mutex +import org.apache.pekko.stream.Materializer +import org.lfdecentralizedtrust.splice.config.{AutomationConfig, UpgradesConfig} +import org.lfdecentralizedtrust.splice.environment.{RetryProvider, SpliceLedgerClient} +import org.lfdecentralizedtrust.splice.http.HttpClient +import org.lfdecentralizedtrust.splice.scan.admin.api.client.BftScanConnection +import org.lfdecentralizedtrust.splice.scan.config.ScanAppClientConfig +import org.lfdecentralizedtrust.splice.scan.store.ScanStore +import org.lfdecentralizedtrust.splice.util.TemplateJsonDecoder + +import scala.concurrent.{ExecutionContextExecutor, Future} + +class PeerBftScanConnection( + store: ScanStore, + svName: String, + ledgerClient: SpliceLedgerClient, + automationConfig: AutomationConfig, + upgradesConfig: UpgradesConfig, + clock: Clock, + retryProvider: RetryProvider, + loggerFactory: NamedLoggerFactory, +)(implicit + ec: ExecutionContextExecutor, + mat: Materializer, + httpClient: HttpClient, + templateDecoder: TemplateJsonDecoder, +) extends AutoCloseable { + + private val mutex = Mutex() + + @SuppressWarnings(Array("org.wartremover.warts.Var")) + @volatile private var connectionVar: Option[Future[BftScanConnection]] = None + + def connection(implicit tc: TraceContext): Future[BftScanConnection] = mutex.exclusive { + connectionVar match { + case Some(conn) => conn + case None => + val conn = BftScanConnection.peerScanConnection( + () => BftScanConnection.Bft.getPeerScansFromStore(store, svName), + ledgerClient, + // When the network is starting up, the pool of SVs is changing fast + // Using a short refresh interval to quickly pick up new SVs + scansRefreshInterval = automationConfig.pollingInterval, + amuletRulesCacheTimeToLive = ScanAppClientConfig.DefaultAmuletRulesCacheTimeToLive, + upgradesConfig, + clock, + retryProvider, + loggerFactory, + ) + connectionVar = Some(conn) + conn + } + } + + override def close(): Unit = mutex.exclusive { + connectionVar.foreach(_.foreach(_.close())) + } + +} diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/BftScanConnectionTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/BftScanConnectionTest.scala index 889fee4c45..1879ed49bb 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/BftScanConnectionTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/BftScanConnectionTest.scala @@ -97,6 +97,7 @@ class BftScanConnectionTest when(m.config).thenReturn( ScanAppClientConfig(NetworkAppClientConfig(scanUrl(n))) ) + when(m.url).thenReturn(Uri(scanUrl(n))) m } connections.foreach { connection => @@ -1043,7 +1044,7 @@ class BftScanConnectionTest connections.tail.foreach(c => when(c.getDsoPartyId()).thenReturn(delayedSuccess)) for { - result <- BftScanConnection.executeCall(call, connections, nTargetSuccess = 1, logger) + (result, _) <- BftScanConnection.executeCall(call, connections, nTargetSuccess = 1, logger) } yield result should be(partyIdA) } @@ -1111,7 +1112,7 @@ class BftScanConnectionTest } for { - result <- BftScanConnection.executeCall( + (result, _) <- BftScanConnection.executeCall( call, connections, nTargetSuccess = 2, @@ -1158,7 +1159,7 @@ class BftScanConnectionTest makeMockFail(connections(2), notFoundFailure) for { - result <- BftScanConnection.executeCall( + (result, _) <- BftScanConnection.executeCall( call, connections, nTargetSuccess = 2, @@ -1194,7 +1195,7 @@ class BftScanConnectionTest } for { - result <- BftScanConnection.executeCall( + (result, _) <- BftScanConnection.executeCall( call, connections, nTargetSuccess = 2, @@ -1245,26 +1246,36 @@ class BftScanConnectionTest // With n=4, we query only two connections randomly, and even with // retries a single call can fail to reach consensus. - def attempt(remaining: Int): Future[GetRewardAccountingRootHashResponse] = - bft.getRewardAccountingRootHash(round).flatMap { - case ok: GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashOk => - Future.successful(ok) + def attempt(remaining: Int): Future[(GetRewardAccountingRootHashResponse, List[Uri])] = + bft.getRewardAccountingRootHashWithScanUris(round).flatMap { + case (ok: GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashOk, uris) => + Future.successful((ok, uris)) case _ if remaining > 1 => attempt(remaining - 1) case other => Future.successful(other) } + // A call that reaches consensus here always queries a third scan that + // disagrees (returns IgnoreResponse or fails), which BftScanConnection + // logs at WARN for the reward-read paths. Assert that WARN is produced + // and suppress it so it doesn't fail the `sbt checkErrors` log-scan gate. loggerFactory - .assertEventuallyLogsSeq(SuppressionRule.LevelAndAbove(Level.INFO))( + .assertEventuallyLogsSeq(SuppressionRule.Level(Level.WARN))( attempt(100).map { resp => inside(resp) { - case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashOk(ok) => + case ( + GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashOk(ok), + uris, + ) => ok.rootHash should be("aabb") ok.roundNumber should be(round) + uris.size should be(2) } }, logs => - logs.exists(l => - l.level == Level.INFO && l.message.contains("Reached consensus from") + logs.exists(log => + log.level == Level.WARN && log.message.contains( + "disagreed with consensus" + ) ) should be(true), ) .map(_ => succeed) @@ -1366,29 +1377,42 @@ class BftScanConnectionTest // With n=4, we query only two connections randomly, and even with // retries a single call can fail to reach consensus. - def attempt(remaining: Int): Future[GetRewardAccountingActivityTotalsResponse] = - bft.getRewardAccountingActivityTotals(round).flatMap { - case ok: GetRewardAccountingActivityTotalsResponse.members.RewardAccountingActivityTotalsOk => - Future.successful(ok) + def attempt(remaining: Int): Future[(GetRewardAccountingActivityTotalsResponse, List[Uri])] = + bft.getRewardAccountingActivityTotalsWithScanUris(round).flatMap { + case ( + ok: GetRewardAccountingActivityTotalsResponse.members.RewardAccountingActivityTotalsOk, + uris, + ) => + Future.successful((ok, uris)) case _ if remaining > 1 => attempt(remaining - 1) case other => Future.successful(other) } + // A call that reaches consensus here always queries a third scan that + // disagrees (returns IgnoreResponse or fails), which BftScanConnection + // logs at WARN for the reward-read paths. Assert that WARN is produced + // and suppress it so it doesn't fail the `sbt checkErrors` log-scan gate. loggerFactory - .assertEventuallyLogsSeq(SuppressionRule.LevelAndAbove(Level.INFO))( + .assertEventuallyLogsSeq(SuppressionRule.Level(Level.WARN))( attempt(100).map { resp => inside(resp) { - case GetRewardAccountingActivityTotalsResponse.members - .RewardAccountingActivityTotalsOk(ok) => + case ( + GetRewardAccountingActivityTotalsResponse.members + .RewardAccountingActivityTotalsOk(ok), + uris, + ) => ok.roundNumber should be(round) ok.totalAppActivityWeight should be(100L) ok.activePartiesCount should be(10L) ok.activityRecordsCount should be(5L) + uris.size should be(2) } }, logs => - logs.exists(l => - l.level == Level.INFO && l.message.contains("Reached consensus from") + logs.exists(log => + log.level == Level.WARN && log.message.contains( + "disagreed with consensus" + ) ) should be(true), ) .map(_ => succeed) diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/SingleScanConnectionTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/SingleScanConnectionTest.scala new file mode 100644 index 0000000000..9852669a1c --- /dev/null +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/admin/api/client/SingleScanConnectionTest.scala @@ -0,0 +1,62 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.scan.admin.api.client + +import com.digitalasset.canton.BaseTest +import io.circe.Json +import org.apache.pekko.http.scaladsl.model.{HttpRequest, StatusCodes} +import org.apache.pekko.stream.StreamTcpException +import org.lfdecentralizedtrust.splice.admin.api.client.commands.HttpCommandException +import org.lfdecentralizedtrust.splice.environment.BaseAppConnection +import org.scalatest.wordspec.AnyWordSpec + +class SingleScanConnectionTest extends AnyWordSpec with BaseTest { + + "SingleScanConnection.httpStatusLabel" should { + + "extract the status code of an unexpected JSON response" in { + SingleScanConnection.httpStatusLabel( + new BaseAppConnection.UnexpectedHttpJsonResponse(StatusCodes.NotFound, Json.obj()) + ) should be("404") + } + + "extract the status code of an unexpected malformed JSON response" in { + SingleScanConnection.httpStatusLabel( + new BaseAppConnection.UnexpectedHttpMalformedJsonResponse( + StatusCodes.BadGateway, + "not json", + ) + ) should be("502") + } + + "extract the status code of an unexpected text response" in { + SingleScanConnection.httpStatusLabel( + new BaseAppConnection.UnexpectedHttpTextResponse(StatusCodes.ServiceUnavailable, "nope") + ) should be("503") + } + + "extract the status code of an unexpected non-JSON response" in { + SingleScanConnection.httpStatusLabel( + new BaseAppConnection.UnexpectedHttpNonJsonResponse(StatusCodes.InternalServerError) + ) should be("500") + } + + "extract the status code of an HttpCommandException" in { + SingleScanConnection.httpStatusLabel( + HttpCommandException( + HttpRequest(), + StatusCodes.TooManyRequests, + HttpCommandException.RawResponse("slow down"), + ) + ) should be("429") + } + + "report 'none' for failures without an HTTP status code" in { + SingleScanConnection.httpStatusLabel( + new StreamTcpException("connection refused") + ) should be("none") + SingleScanConnection.httpStatusLabel(new RuntimeException("boom")) should be("none") + } + } +} diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/config/TokenStandardConfigTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/config/TokenStandardConfigTest.scala index 4c51e7328a..8d2da10a11 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/config/TokenStandardConfigTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/config/TokenStandardConfigTest.scala @@ -37,6 +37,27 @@ class TokenStandardConfigTest extends AnyWordSpec with BaseTest { config.validateSettleBatch(settleBatch) } + "reject settle batches with too many allocations" in { + val config = + TokenStandardConfig.SettlementConfig(maxLegs = 10, maxParties = 10, maxAllocations = 1) + + val ex = the[StatusRuntimeException] thrownBy { + config.validateSettleBatch( + mkSettleBatch( + transferLegs = Seq.empty, + allocations = Seq( + mkFinalizedAllocation("alloc-1"), + mkFinalizedAllocation("alloc-2"), + ), + ) + ) + } + + ex.getStatus.getCode shouldBe Status.Code.INVALID_ARGUMENT + ex.getStatus.getDescription shouldBe + "Too many allocations in the settle batch: 2. Maximum allowed: 1" + } + "reject settle batches with too many transfer legs" in { val config = TokenStandardConfig.SettlementConfig(maxLegs = 1, maxParties = 10) @@ -112,12 +133,13 @@ class TokenStandardConfigTest extends AnyWordSpec with BaseTest { } private def mkSettleBatch( - transferLegs: Seq[allocationv2.TransferLeg] + transferLegs: Seq[allocationv2.TransferLeg], + allocations: Seq[allocationv2.FinalizedAllocation] = Seq.empty, ): allocationv2.SettlementFactory_SettleBatch = new allocationv2.SettlementFactory_SettleBatch( mkSettlementInfo(), transferLegs.asJava, - java.util.List.of(), + allocations.asJava, java.util.List.of("venue"), emptyExtraArgs, ) @@ -154,6 +176,13 @@ class TokenStandardConfigTest extends AnyWordSpec with BaseTest { ) } + private def mkFinalizedAllocation(cid: String): allocationv2.FinalizedAllocation = + new allocationv2.FinalizedAllocation( + new allocationv2.Allocation.ContractId(cid), + java.util.List.of(), + java.util.Optional.empty(), + ) + private def mkTransferLeg( transferLegId: String, sender: String, diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/rewards/AppActivityComputationTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/rewards/AppActivityComputationTest.scala index ef1bba310c..9f8c350492 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/rewards/AppActivityComputationTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/rewards/AppActivityComputationTest.scala @@ -11,6 +11,7 @@ import com.google.protobuf.timestamp.Timestamp as ProtoTimestamp import com.google.protobuf.ByteString import org.lfdecentralizedtrust.splice.scan.store.ScanRewardsReferenceStore import org.lfdecentralizedtrust.splice.scan.store.db.{DbAppActivityRecordStore, DbScanVerdictStore} +import org.lfdecentralizedtrust.splice.store.TimestampWithMigrationId import org.scalatest.wordspec.AnyWordSpec import scala.concurrent.Future @@ -219,7 +220,7 @@ class AppActivityComputationTest extends AnyWordSpec with BaseTest { val store = mock[ScanRewardsReferenceStore] when(store.lookupActiveOpenMiningRounds(any[Seq[CantonTimestamp]])(any[TraceContext])) .thenAnswer { (times: Seq[CantonTimestamp]) => - Future.successful(times.map(_ -> (0L, roundOpensAt)).toMap) + Future.successful(times.map(_ -> TimestampWithMigrationId(roundOpensAt, 0L)).toMap) } when(store.lookupFeaturedAppPartiesAsOf(any[CantonTimestamp])(any[TraceContext])) .thenReturn(Future.successful(featuredWeights)) diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbAppActivityRecordStoreTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbAppActivityRecordStoreTest.scala index b4b57c17c0..30a9867878 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbAppActivityRecordStoreTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbAppActivityRecordStoreTest.scala @@ -7,6 +7,7 @@ import com.digitalasset.canton.topology.SynchronizerId import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.resource.DbStorage import com.digitalasset.canton.lifecycle.FutureUnlessShutdown +import org.lfdecentralizedtrust.splice.scan.store.AppActivityStore.RoundIngestionStatus import org.lfdecentralizedtrust.splice.scan.store.db.DbAppActivityRecordStore import org.lfdecentralizedtrust.splice.scan.store.db.DbAppActivityRecordStore.* import org.lfdecentralizedtrust.splice.scan.store.db.DbScanVerdictStore @@ -169,6 +170,8 @@ class DbAppActivityRecordStoreTest _ <- verdictStore.insertVerdictsWithAppActivityRecords( NonEmptyList.of(verdict1 -> noViews, verdict2 -> noViews), appActivityRecords, + hasTrafficSummaries = true, + firstActiveRoundO = Some(10L), lastArchivedRoundO = Some(9L), ) @@ -209,6 +212,8 @@ class DbAppActivityRecordStoreTest _ <- verdictStore.insertVerdictsWithAppActivityRecords( NonEmptyList.of(mkVerdict(verdictStore, "update-mono-1", baseTs) -> noViews), Seq(baseTs -> mkRecord(0L, 10L, Seq("app1::provider"), Seq(100L))), + hasTrafficSummaries = true, + firstActiveRoundO = Some(10L), lastArchivedRoundO = Some(9L), ) // A later batch without activity records still advances the round @@ -217,10 +222,13 @@ class DbAppActivityRecordStoreTest mkVerdict(verdictStore, "update-mono-2", baseTs.plusSeconds(1L)) -> noViews ), Seq.empty, + hasTrafficSummaries = true, + firstActiveRoundO = Some(11L), lastArchivedRoundO = Some(10L), ) meta <- appStore.lookupActivityRecordMeta(1, 0) } yield { + meta.value.earliestIngestedRound shouldBe 10L meta.value.lastArchivedRound shouldBe Some(10L) } } @@ -233,40 +241,96 @@ class DbAppActivityRecordStoreTest _ <- verdictStore.insertVerdictsWithAppActivityRecords( NonEmptyList.of(mkVerdict(verdictStore, "update-no-meta", baseTs) -> noViews), Seq.empty, - lastArchivedRoundO = Some(7L), + hasTrafficSummaries = true, + firstActiveRoundO = Some(7L), + lastArchivedRoundO = None, ) meta <- appStore.lookupActivityRecordMeta(1, 0) } yield { - // Meta row is created with earliestRound = lastArchivedRound - // because verdict ingestion is active even without activity records + // Meta row is created using the firstActiveRoundO + // even though there are no activity records meta shouldBe defined meta.value.earliestIngestedRound shouldBe 7L - meta.value.lastArchivedRound shouldBe Some(7L) + meta.value.lastArchivedRound shouldBe None } } - "insert verdicts without activity records when appActivityRecords is empty" in { + "Does not create meta row when traffic summaries are absent" in { + for { + (appStore, verdictStore) <- newStores() + baseTs = CantonTimestamp.now() + + _ <- verdictStore.insertVerdictsWithAppActivityRecords( + NonEmptyList.of(mkVerdict(verdictStore, "update-no-meta", baseTs) -> noViews), + Seq.empty, + hasTrafficSummaries = false, + lastArchivedRoundO = Some(7L), + ) + v <- verdictStore.getVerdictByUpdateId("update-no-meta") + countAfter <- countRecords() + meta <- appStore.lookupActivityRecordMeta(1, 0) + } yield { + v shouldBe defined + countAfter shouldBe 0L + meta shouldBe None + } + } + + "on a fresh firstSV, does not create meta row when traffic summaries are absent" in { + for { + (appStore, verdictStore) <- newStores(isFirstSv = true) + baseTs = CantonTimestamp.now() + + _ <- verdictStore.insertVerdictsWithAppActivityRecords( + NonEmptyList.of(mkVerdict(verdictStore, "update-firstsv-2", baseTs) -> noViews), + Seq.empty, + hasTrafficSummaries = false, + ) + // Even on firstSV, missing traffic summaries defer meta creation + // to a later batch. + metaBefore <- appStore.lookupActivityRecordMeta(1, 0) + + // A later batch with traffic summaries creates the meta row. + _ <- verdictStore.insertVerdictsWithAppActivityRecords( + NonEmptyList.of( + mkVerdict(verdictStore, "update-firstsv-3", baseTs.plusSeconds(1L)) -> noViews + ), + Seq.empty, + hasTrafficSummaries = true, + ) + metaAfter <- appStore.lookupActivityRecordMeta(1, 0) + } yield { + metaBefore shouldBe None + + metaAfter shouldBe defined + metaAfter.value.earliestIngestedRound shouldBe -1L + metaAfter.value.lastArchivedRound shouldBe Some(0L) + } + } + + "insert verdicts without activity records, when reward reference store does not have data asOf" in { for { (appStore, verdictStore) <- newStores() baseTs = CantonTimestamp.now() verdict = mkVerdict(verdictStore, "update-no-activity", baseTs) + // firstActiveRoundO is None, as reward reference store began ingestion after baseTx _ <- verdictStore.insertVerdictsWithAppActivityRecords( NonEmptyList.of(verdict -> noViews), Seq.empty, + hasTrafficSummaries = true, + firstActiveRoundO = None, + lastArchivedRoundO = None, ) v <- verdictStore.getVerdictByUpdateId("update-no-activity") countAfter <- countRecords() - // No meta row should be created when there are no activity records meta <- appStore.lookupActivityRecordMeta(1, 0) } yield { v shouldBe defined countAfter shouldBe 0L - // Non-firstSV with no lastArchivedRound: meta row is not created - // because it would have last_archived_round = NULL, making no - // rounds complete. + // Non-firstSV with no firstActiveRoundO: meta row is not created meta shouldBe None } } @@ -289,6 +353,7 @@ class DbAppActivityRecordStoreTest _ <- verdictStore.insertVerdictsWithAppActivityRecords( NonEmptyList.of(verdict1 -> noViews, verdict2 -> noViews, verdict3 -> noViews), appActivityRecords, + hasTrafficSummaries = true, ) v1 <- verdictStore.getVerdictByUpdateId("update-with-1") @@ -335,6 +400,7 @@ class DbAppActivityRecordStoreTest _ <- verdictStore.insertVerdictsWithAppActivityRecords( NonEmptyList.of(verdict -> noViews), appActivityRecords, + hasTrafficSummaries = true, ) v <- verdictStore.getVerdictByUpdateId("update-mismatch") @@ -592,6 +658,51 @@ class DbAppActivityRecordStoreTest } } + "ingestionStatusForRound" should { + + "return CannotProvide when meta row absent and isFirstSv=false" in { + for { + (store, _) <- newStore(isFirstSv = false) + result <- store.ingestionStatusForRound(5L) + } yield { + result shouldBe RoundIngestionStatus.CannotProvide + } + } + + "return Undetermined when meta row absent and isFirstSv=true" in { + for { + (store, _) <- newStore(isFirstSv = true) + result <- store.ingestionStatusForRound(5L) + } yield { + result shouldBe RoundIngestionStatus.Undetermined + } + } + + "return CannotProvide when meta row present and roundNumber <= earliestIngested" in { + for { + (store, _) <- newStore() + baseTs = CantonTimestamp.now() + _ <- store.insertActivityRecordMetaForTesting(1, 0, baseTs.toMicros, 10L, Some(11L)) + atBoundary <- store.ingestionStatusForRound(10L) + below <- store.ingestionStatusForRound(5L) + } yield { + atBoundary shouldBe RoundIngestionStatus.CannotProvide + below shouldBe RoundIngestionStatus.CannotProvide + } + } + + "return Undetermined when meta row present and roundNumber > earliestIngested" in { + for { + (store, _) <- newStore() + baseTs = CantonTimestamp.now() + _ <- store.insertActivityRecordMetaForTesting(1, 0, baseTs.toMicros, 10L, Some(11L)) + result <- store.ingestionStatusForRound(15L) + } yield { + result shouldBe RoundIngestionStatus.Undetermined + } + } + } + "lookupActivityRecordMeta" should { "return None when no meta row exists" in { @@ -1025,7 +1136,7 @@ class DbAppActivityRecordStoreTest val n = storeCounter.getAndIncrement() val participantId = mkParticipantId(s"activity-test-$n") val updateHistory = new UpdateHistory( - storage.underlying, + storage, migrationId, s"app_activity_test_$n", participantId, @@ -1038,7 +1149,7 @@ class DbAppActivityRecordStoreTest ) updateHistory.ingestionSink.initialize().map { _ => val store = new DbAppActivityRecordStore( - storage.underlying, + storage, updateHistory, versions, isFirstSv, @@ -1051,10 +1162,12 @@ class DbAppActivityRecordStoreTest /** Creates both an app activity record store and a verdict store backed by * the same UpdateHistory, for testing insertVerdictsWithAppActivityRecords. */ - private def newStores(): Future[(DbAppActivityRecordStore, DbScanVerdictStore)] = { + private def newStores( + isFirstSv: Boolean = false + ): Future[(DbAppActivityRecordStore, DbScanVerdictStore)] = { val participantId = mkParticipantId("activity-test") val updateHistory = new UpdateHistory( - storage.underlying, + storage, migrationId, "app_activity_combined_test", participantId, @@ -1070,13 +1183,13 @@ class DbAppActivityRecordStoreTest storage.underlying, updateHistory, DbAppActivityRecordStore.IngestionVersions(1, 0), - isFirstSv = false, + isFirstSv, loggerFactory, ) val verdictStore = new DbScanVerdictStore( storage.underlying, updateHistory, - Some(appStore), + appStore, loggerFactory, ) (appStore, verdictStore) diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbScanAppRewardsStoreTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbScanAppRewardsStoreTest.scala index bdfd83565e..eabc35301b 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbScanAppRewardsStoreTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbScanAppRewardsStoreTest.scala @@ -36,11 +36,8 @@ class DbScanAppRewardsStoreTest private val migrationId = 0L - "DbScanAppRewardsStore" should { - - // -- Test 1: Insert and read back a single row per table ---------------- - - "insert and read back app_activity_party_totals" in { + "insert and read back" should { + "app_activity_party_totals" in { for { (store, historyId) <- newStore() row = AppActivityPartyTotalT( @@ -58,7 +55,7 @@ class DbScanAppRewardsStoreTest } } - "insert and read back app_activity_round_totals" in { + "app_activity_round_totals" in { for { (store, historyId) <- newStore() row = AppActivityRoundTotalT( @@ -75,7 +72,7 @@ class DbScanAppRewardsStoreTest } } - "insert and read back app_reward_party_totals" in { + "app_reward_party_totals" in { for { (store, historyId) <- newStore() activityRow = AppActivityPartyTotalT( @@ -147,7 +144,7 @@ class DbScanAppRewardsStoreTest } } - "insert and read back app_reward_round_totals" in { + "app_reward_round_totals" in { for { (store, historyId) <- newStore() row = AppRewardRoundTotalT( @@ -165,7 +162,7 @@ class DbScanAppRewardsStoreTest } } - "insert and read back app_reward_batch_hashes" in { + "app_reward_batch_hashes" in { for { (store, historyId) <- newStore() hash = RewardHash( @@ -187,7 +184,7 @@ class DbScanAppRewardsStoreTest } } - "insert and read back app_reward_root_hashes" in { + "app_reward_root_hashes" in { for { (store, historyId) <- newStore() hash = RewardHash(Array[Byte](0xca.toByte, 0xfe.toByte, 0xba.toByte, 0xbe.toByte)) @@ -202,10 +199,10 @@ class DbScanAppRewardsStoreTest loaded.value shouldBe row } } + } - // -- Test 2: Batch inserts ---------------------------------------------- - - "batch insert multiple app_activity_party_totals and spot-check" in { + "batch insert" should { + "batch multiple app_activity_party_totals and spot-check" in { for { (store, historyId) <- newStore() rows = (0 until 10).map { i => @@ -227,7 +224,7 @@ class DbScanAppRewardsStoreTest } } - "batch insert multiple app_reward_batch_hashes and spot-check" in { + "batch multiple app_reward_batch_hashes and spot-check" in { for { (store, historyId) <- newStore() rows = (0 until 5).map { i => @@ -249,10 +246,10 @@ class DbScanAppRewardsStoreTest loaded.last shouldBe rows(4) } } + } - // -- Test 5: Duplicate key handling (reject) ---------------------------- - - "reject duplicate app_activity_party_totals on PK conflict" in { + "duplicate key handling" should { + "duplicate app_activity_party_totals on PK conflict" in { for { (store, historyId) <- newStore() row = AppActivityPartyTotalT( @@ -270,7 +267,7 @@ class DbScanAppRewardsStoreTest } } - "reject duplicate app_activity_round_totals on PK conflict" in { + "duplicate app_activity_round_totals on PK conflict" in { for { (store, historyId) <- newStore() row = AppActivityRoundTotalT( @@ -288,7 +285,7 @@ class DbScanAppRewardsStoreTest } } - "reject duplicate app_reward_root_hashes on PK conflict" in { + "duplicate app_reward_root_hashes on PK conflict" in { for { (store, historyId) <- newStore() row = AppRewardRootHashT( @@ -307,10 +304,11 @@ class DbScanAppRewardsStoreTest result.getMessage should (include("unique constraint") or include("duplicate key")) } } + } - // -- Aggregation tests --------------------------------------------------- + "aggregateActivityTotals" should { - "aggregateActivityTotals — single round, single party" in { + "single round, single party" in { for { (store, historyId) <- newStore() _ <- markRoundComplete(historyId, roundNumber) @@ -330,7 +328,7 @@ class DbScanAppRewardsStoreTest } } - "aggregateActivityTotals — multiple parties with correct GROUP BY and seq_nums" in { + "multiple parties with correct GROUP BY and seq_nums" in { for { (store, historyId) <- newStore() _ <- markRoundComplete(historyId, roundNumber) @@ -371,7 +369,7 @@ class DbScanAppRewardsStoreTest } } - "aggregateActivityTotals — empty round produces zero totals" in { + "empty round produces zero totals" in { for { (store, historyId) <- newStore() _ <- markRoundComplete(historyId, roundNumber) @@ -387,7 +385,7 @@ class DbScanAppRewardsStoreTest } } - "aggregateActivityTotals — only aggregates records from own history_id" in { + "only aggregates records from own history_id" in { for { (store1, historyId1) <- newStore() (_, historyId2) <- newStore() @@ -409,7 +407,7 @@ class DbScanAppRewardsStoreTest } } - "aggregateActivityTotals — re-run for same round raises error" in { + "re-run for same round raises error" in { for { (store, historyId) <- newStore() _ <- markRoundComplete(historyId, roundNumber) @@ -421,7 +419,7 @@ class DbScanAppRewardsStoreTest } } - "aggregateActivityTotals — rejects the first ingested round (possibly partial)" in { + "rejects the first ingested round (possibly partial)" in { for { (store, historyId) <- newStore() // roundNumber is the first ingested round, which may be partial @@ -438,7 +436,7 @@ class DbScanAppRewardsStoreTest } } - "aggregateActivityTotals — rejects round whose OpenMiningRound is not yet archived" in { + "rejects round whose OpenMiningRound is not yet archived" in { for { (store, historyId) <- newStore() _ <- insertActivityMeta( @@ -454,7 +452,7 @@ class DbScanAppRewardsStoreTest } } - "aggregateActivityTotals — succeeds for round 0 when marked complete" in { + "succeeds for round 0 when marked complete" in { for { (store, historyId) <- newStore() _ <- markRoundComplete(historyId, 0L) @@ -468,7 +466,7 @@ class DbScanAppRewardsStoreTest } } - "aggregateActivityTotals — rejects round 0 when not marked complete" in { + "rejects round 0 when not marked complete" in { for { (store, historyId) <- newStore() _ <- insertActivityRecord(historyId, 0L, Seq("alice::provider"), Seq(500L)) @@ -478,212 +476,267 @@ class DbScanAppRewardsStoreTest result.getMessage should include("Incomplete app activity for round 0") } } + } - "roundsWithComputedRewards" should { + "roundsWithComputedRewards" should { - "returns empty set for empty input" in { - for { - (store, _) <- newStore() - result <- store.roundsWithComputedRewards(Seq.empty) - } yield { - result shouldBe Set.empty - } + "returns empty set for empty input" in { + for { + (store, _) <- newStore() + result <- store.roundsWithComputedRewards(Seq.empty) + } yield { + result shouldBe Set.empty } + } - "returns correct subset" in { - for { - (store, historyId) <- newStore() - _ <- store.insertAppRewardRootHashes( - Seq( - AppRewardRootHashT(historyId, 10L, RewardHash(Array[Byte](1, 2, 3, 4))), - AppRewardRootHashT(historyId, 20L, RewardHash(Array[Byte](5, 6, 7, 8))), - AppRewardRootHashT(historyId, 30L, RewardHash(Array[Byte](9, 10, 11, 12))), - ) + "returns correct subset" in { + for { + (store, historyId) <- newStore() + _ <- store.insertAppRewardRootHashes( + Seq( + AppRewardRootHashT(historyId, 10L, RewardHash(Array[Byte](1, 2, 3, 4))), + AppRewardRootHashT(historyId, 20L, RewardHash(Array[Byte](5, 6, 7, 8))), + AppRewardRootHashT(historyId, 30L, RewardHash(Array[Byte](9, 10, 11, 12))), ) - result <- store.roundsWithComputedRewards(Seq(10L, 15L, 20L, 25L)) - } yield { - result shouldBe Set(10L, 20L) - } + ) + result <- store.roundsWithComputedRewards(Seq(10L, 15L, 20L, 25L)) + } yield { + result shouldBe Set(10L, 20L) } + } - "returns empty set when no matches" in { - for { - (store, historyId) <- newStore() - _ <- store.insertAppRewardRootHashes( - Seq( - AppRewardRootHashT(historyId, 10L, RewardHash(Array[Byte](1, 2, 3, 4))) - ) + "returns empty set when no matches" in { + for { + (store, historyId) <- newStore() + _ <- store.insertAppRewardRootHashes( + Seq( + AppRewardRootHashT(historyId, 10L, RewardHash(Array[Byte](1, 2, 3, 4))) ) - result <- store.roundsWithComputedRewards(Seq(20L, 30L)) - } yield { - result shouldBe Set.empty - } + ) + result <- store.roundsWithComputedRewards(Seq(20L, 30L)) + } yield { + result shouldBe Set.empty } + } + } + + "computeAndStoreRewards" should { + "returns correct summary counts" in { + for { + (store, historyId) <- newStore() + _ <- markRoundComplete(historyId, roundNumber) + // 3 activity records, 2 parties (alice in 2 records, bob in 2) + _ <- insertActivityRecord( + historyId, + roundNumber, + Seq("alice::provider", "bob::provider"), + Seq(3000000L, 2000000L), + ) + _ <- insertActivityRecord( + historyId, + roundNumber, + Seq("alice::provider"), + Seq(1000000L), + ) + _ <- insertActivityRecord( + historyId, + roundNumber, + Seq("bob::provider"), + Seq(500000L), + ) + summary <- store.computeAndStoreRewards( + roundNumber, + batchSize = 100, + testInputs, + ) + } yield { + summary.activePartiesCount shouldBe 2L + summary.activityRecordsCount shouldBe 4L // sum of per-party counts: alice=2 + bob=2 + summary.rewardedPartiesCount shouldBe 2L + summary.batchesCreatedCount should be >= 1L + } } - "computeAndStoreRewards" should { + "non-zero threshold excludes low-activity parties from rewards" in { + for { + (store, historyId) <- newStore() + _ <- markRoundComplete(historyId, roundNumber) + // alice has high activity, bob has low activity + _ <- insertActivityRecord( + historyId, + roundNumber, + Seq("alice::provider", "bob::provider"), + Seq(5000000L, 50000L), + ) + // totalIssuanceForFeaturedAppRewards is 0.45, and alice gets almost all of it. Only alice will therefore be above the threshold of 0.4 + nonZeroThresholdInputs = testInputs.copy( + appRewardCouponThreshold = RewardComputationInputs.fromBigDecimal(BigDecimal("0.4")) + ) + summary <- store.computeAndStoreRewards( + roundNumber, + batchSize = 100, + nonZeroThresholdInputs, + ) + rewardPartyTotals <- store.getAppRewardPartyTotalsByRound(roundNumber) + } yield { + summary.activePartiesCount shouldBe 2L + summary.rewardedPartiesCount shouldBe 1L // only alice above threshold + rewardPartyTotals should have size 1 + rewardPartyTotals.head.appProviderParty shouldBe "alice::provider" + } + } - "returns correct summary counts" in { - for { - (store, historyId) <- newStore() - _ <- markRoundComplete(historyId, roundNumber) - // 3 activity records, 2 parties (alice in 2 records, bob in 2) - _ <- insertActivityRecord( - historyId, - roundNumber, - Seq("alice::provider", "bob::provider"), - Seq(3000000L, 2000000L), - ) - _ <- insertActivityRecord( - historyId, - roundNumber, - Seq("alice::provider"), - Seq(1000000L), - ) - _ <- insertActivityRecord( - historyId, - roundNumber, - Seq("bob::provider"), - Seq(500000L), - ) - summary <- store.computeAndStoreRewards( - roundNumber, - batchSize = 100, - testInputs, - ) - } yield { - summary.activePartiesCount shouldBe 2L - summary.activityRecordsCount shouldBe 4L // sum of per-party counts: alice=2 + bob=2 - summary.rewardedPartiesCount shouldBe 2L - summary.batchesCreatedCount should be >= 1L - } + "empty round returns zero counts" in { + for { + (store, historyId) <- newStore() + _ <- markRoundComplete(historyId, roundNumber) + summary <- store.computeAndStoreRewards( + roundNumber, + batchSize = 100, + testInputs, + ) + } yield { + summary.activePartiesCount shouldBe 0L + summary.activityRecordsCount shouldBe 0L + summary.rewardedPartiesCount shouldBe 0L + summary.batchesCreatedCount shouldBe 1L // empty root batch } + } - "non-zero threshold excludes low-activity parties from rewards" in { - for { - (store, historyId) <- newStore() - _ <- markRoundComplete(historyId, roundNumber) - // alice has high activity, bob has low activity - _ <- insertActivityRecord( - historyId, - roundNumber, - Seq("alice::provider", "bob::provider"), - Seq(5000000L, 50000L), - ) - // totalIssuanceForFeaturedAppRewards is 0.45, and alice gets almost all of it. Only alice will therefore be above the threshold of 0.4 - nonZeroThresholdInputs = testInputs.copy( - appRewardCouponThreshold = RewardComputationInputs.fromBigDecimal(BigDecimal("0.4")) - ) - summary <- store.computeAndStoreRewards( - roundNumber, - batchSize = 100, - nonZeroThresholdInputs, - ) - rewardPartyTotals <- store.getAppRewardPartyTotalsByRound(roundNumber) - } yield { - summary.activePartiesCount shouldBe 2L - summary.rewardedPartiesCount shouldBe 1L // only alice above threshold - rewardPartyTotals should have size 1 - rewardPartyTotals.head.appProviderParty shouldBe "alice::provider" - } + "rejects incomplete activity" in { + for { + (store, historyId) <- newStore() + // Activity in roundNumber but no meta row marking the round complete + _ <- insertActivityRecord(historyId, roundNumber, Seq("alice::provider"), Seq(500L)) + result <- store + .computeAndStoreRewards(roundNumber, batchSize = 100, inputs = testInputs) + .failed + } yield { + result.getMessage should include("Incomplete app activity") } + } - "empty round returns zero counts" in { - for { - (store, historyId) <- newStore() - _ <- markRoundComplete(historyId, roundNumber) - summary <- store.computeAndStoreRewards( - roundNumber, - batchSize = 100, - testInputs, - ) - } yield { - summary.activePartiesCount shouldBe 0L - summary.activityRecordsCount shouldBe 0L - summary.rewardedPartiesCount shouldBe 0L - summary.batchesCreatedCount shouldBe 1L // empty root batch - } + "produces root hash for complete round" in { + for { + (store, historyId) <- newStore() + _ <- markRoundComplete(historyId, roundNumber) + _ <- insertActivityRecord(historyId, roundNumber, Seq("alice::provider"), Seq(5000000L)) + _ <- store.computeAndStoreRewards(roundNumber, batchSize = 100, inputs = testInputs) + rootHash <- store.getAppRewardRootHashByRound(roundNumber) + activityTotals <- store.getAppActivityRoundTotalByRound(roundNumber) + } yield { + // Pipeline completed: activity aggregated and root hash produced + rootHash shouldBe defined + rootHash.value.rootHash.size shouldBe 32 + activityTotals shouldBe defined + activityTotals.value.totalRoundAppActivityWeight shouldBe 5000000L } + } + "re-run for same round raises error" in { + for { + (store, historyId) <- newStore() + _ <- markRoundComplete(historyId, roundNumber) + _ <- insertActivityRecord(historyId, roundNumber, Seq("alice::provider"), Seq(5000000L)) + _ <- store.computeAndStoreRewards(roundNumber, batchSize = 100, inputs = testInputs) + result <- store + .computeAndStoreRewards(roundNumber, batchSize = 100, inputs = testInputs) + .failed + } yield { + result shouldBe a[Exception] + } } - // -- assertMintingAllowanceWithinMintingCurve tests -------------------------------- - // Tested directly with fake round totals because normal computation - // cannot trigger the assertion — the tranche formula guarantees - // totalReward <= totalIssuance. This check is a safety net for bugs. + "rolls back when reward exceeds issuance" in { + for { + // Use a negative tolerance so any positive reward triggers the assertion + (store, historyId) <- newStore(rewardMintingAllowanceTolerance = BigDecimal(-1.0)) + _ <- markRoundComplete(historyId, roundNumber) + _ <- insertActivityRecord(historyId, roundNumber, Seq("alice::provider"), Seq(5000000L)) + result <- store + .computeAndStoreRewards(roundNumber, batchSize = 100, inputs = testInputs) + .failed + } yield { + result.getMessage should include("exceeds minting curve allowance") + } + } + } - "assertMintingAllowanceWithinMintingCurve" should { + // Tested directly with fake round totals because normal computation + // cannot trigger the assertion — the tranche formula guarantees + // totalReward <= totalIssuance. This check is a safety net for bugs. + "assertMintingAllowanceWithinMintingCurve" should { + + def mkParams(totalIssuance: BigDecimal): RewardIssuanceParams = + RewardIssuanceParams( + issuancePerFeaturedAppTraffic_CCperMB = BigDecimal(0), + threshold_CC = BigDecimal(0), + totalIssuanceForFeaturedAppRewards = totalIssuance, + unclaimedAppRewardAmount = BigDecimal(0), + ) - def mkParams(totalIssuance: BigDecimal): RewardIssuanceParams = - RewardIssuanceParams( - issuancePerFeaturedAppTraffic_CCperMB = BigDecimal(0), - threshold_CC = BigDecimal(0), - totalIssuanceForFeaturedAppRewards = totalIssuance, - unclaimedAppRewardAmount = BigDecimal(0), + def insertRoundTotal(historyId: Long, round: Long, amount: BigDecimal): Future[Unit] = + futureUnlessShutdownToFuture( + storage.underlying.queryAndUpdate( + sqlu"""insert into app_reward_round_totals + (history_id, round_number, total_app_reward_minting_allowance, + total_app_reward_thresholded, total_app_reward_unclaimed, + rewarded_app_provider_parties_count) + values ($historyId, $round, $amount, 0, 0, 1)""".map(_ => ()), + "test.insertRoundTotal", ) + ) - def insertRoundTotal(historyId: Long, round: Long, amount: BigDecimal): Future[Unit] = - futureUnlessShutdownToFuture( - storage.underlying.queryAndUpdate( - sqlu"""insert into app_reward_round_totals - (history_id, round_number, total_app_reward_minting_allowance, - total_app_reward_thresholded, total_app_reward_unclaimed, - rewarded_app_provider_parties_count) - values ($historyId, $round, $amount, 0, 0, 1)""".map(_ => ()), - "test.insertRoundTotal", + "pass when reward amount is within issuance" in { + for { + (store, historyId) <- newStore() + _ <- insertRoundTotal(historyId, roundNumber, BigDecimal(10.0)) + _ <- futureUnlessShutdownToFuture( + storage.queryAndUpdate( + store + .assertMintingAllowanceWithinMintingCurve(roundNumber, mkParams(BigDecimal(10.0))), + "test.assertMintingAllowanceWithinMintingCurve", ) ) + } yield succeed + } - "pass when reward amount is within issuance" in { - for { - (store, historyId) <- newStore() - _ <- insertRoundTotal(historyId, roundNumber, BigDecimal(10.0)) - _ <- futureUnlessShutdownToFuture( - storage.underlying.queryAndUpdate( - store - .assertMintingAllowanceWithinMintingCurve(roundNumber, mkParams(BigDecimal(10.0))), - "test.assertMintingAllowanceWithinMintingCurve", - ) + "fail when reward amount exceeds issuance by more than tolerance" in { + for { + (store, historyId) <- newStore() + // Reward exceeds issuance by 2x tolerance (0.002 > 0.001) + _ <- insertRoundTotal(historyId, roundNumber, BigDecimal(10.002)) + result <- futureUnlessShutdownToFuture( + storage.queryAndUpdate( + store.assertMintingAllowanceWithinMintingCurve( + roundNumber, + mkParams(BigDecimal(10.0)), + ), + "test.assertMintingAllowanceWithinMintingCurve", ) - } yield succeed - } - - "fail when reward amount exceeds issuance by more than tolerance" in { - for { - (store, historyId) <- newStore() - // Reward exceeds issuance by 2x tolerance (0.002 > 0.001) - _ <- insertRoundTotal(historyId, roundNumber, BigDecimal(10.002)) - result <- futureUnlessShutdownToFuture( - storage.underlying.queryAndUpdate( - store.assertMintingAllowanceWithinMintingCurve( - roundNumber, - mkParams(BigDecimal(10.0)), - ), - "test.assertMintingAllowanceWithinMintingCurve", - ) - ).failed - } yield { - result.getMessage should include("exceeds minting curve allowance") - } + ).failed + } yield { + result.getMessage should include("exceeds minting curve allowance") } + } - "pass when reward amount exceeds issuance within tolerance" in { - for { - (store, historyId) <- newStore() - _ <- insertRoundTotal(historyId, roundNumber, BigDecimal(10.0005)) - _ <- futureUnlessShutdownToFuture( - storage.underlying.queryAndUpdate( - store - .assertMintingAllowanceWithinMintingCurve(roundNumber, mkParams(BigDecimal(10.0))), - "test.assertMintingAllowanceWithinMintingCurve", - ) + "pass when reward amount exceeds issuance within tolerance" in { + for { + (store, historyId) <- newStore() + _ <- insertRoundTotal(historyId, roundNumber, BigDecimal(10.0005)) + _ <- futureUnlessShutdownToFuture( + storage.queryAndUpdate( + store + .assertMintingAllowanceWithinMintingCurve(roundNumber, mkParams(BigDecimal(10.0))), + "test.assertMintingAllowanceWithinMintingCurve", ) - } yield succeed - } + ) + } yield succeed } + } - // -- computeRewardTotals tests ------------------------------------------- + "computeRewardTotals" should { val rewardTotalsTestCases = Seq( // 5_000_000 / 1_000_000 * 2.0 = 10.0 @@ -808,74 +861,15 @@ class DbScanAppRewardsStoreTest ) rewardTotalsTestCases.foreach { tc => - s"computeRewardTotals — ${tc.description}" in { + tc.description in { RewardTotalsTests.run(tc) } } + } - // -- computeAndStoreRewards tests ------------------------------------------ - - "computeAndStoreRewards — rejects incomplete activity" in { - for { - (store, historyId) <- newStore() - // Activity in roundNumber but no meta row marking the round complete - _ <- insertActivityRecord(historyId, roundNumber, Seq("alice::provider"), Seq(500L)) - result <- store - .computeAndStoreRewards(roundNumber, batchSize = 100, inputs = testInputs) - .failed - } yield { - result.getMessage should include("Incomplete app activity") - } - } - - "computeAndStoreRewards — produces root hash for complete round" in { - for { - (store, historyId) <- newStore() - _ <- markRoundComplete(historyId, roundNumber) - _ <- insertActivityRecord(historyId, roundNumber, Seq("alice::provider"), Seq(5000000L)) - _ <- store.computeAndStoreRewards(roundNumber, batchSize = 100, inputs = testInputs) - rootHash <- store.getAppRewardRootHashByRound(roundNumber) - activityTotals <- store.getAppActivityRoundTotalByRound(roundNumber) - } yield { - // Pipeline completed: activity aggregated and root hash produced - rootHash shouldBe defined - rootHash.value.rootHash.size shouldBe 32 - activityTotals shouldBe defined - activityTotals.value.totalRoundAppActivityWeight shouldBe 5000000L - } - } - - "computeAndStoreRewards — re-run for same round raises error" in { - for { - (store, historyId) <- newStore() - _ <- markRoundComplete(historyId, roundNumber) - _ <- insertActivityRecord(historyId, roundNumber, Seq("alice::provider"), Seq(5000000L)) - _ <- store.computeAndStoreRewards(roundNumber, batchSize = 100, inputs = testInputs) - result <- store - .computeAndStoreRewards(roundNumber, batchSize = 100, inputs = testInputs) - .failed - } yield { - result shouldBe a[Exception] - } - } - - "computeAndStoreRewards — rolls back when reward exceeds issuance" in { - for { - // Use a negative tolerance so any positive reward triggers the assertion - (store, historyId) <- newStore(rewardMintingAllowanceTolerance = BigDecimal(-1.0)) - _ <- markRoundComplete(historyId, roundNumber) - _ <- insertActivityRecord(historyId, roundNumber, Seq("alice::provider"), Seq(5000000L)) - result <- store - .computeAndStoreRewards(roundNumber, batchSize = 100, inputs = testInputs) - .failed - } yield { - result.getMessage should include("exceeds minting curve allowance") - } - } - - // -- computeRewardHashes tests -------------------------------------------- + "computeRewardHashes" should { - "computeRewardHashes — 3 activity parties, 2 rewarded, batchSize=2" in { + "3 activity parties, 2 rewarded, batchSize=2" in { // 2 rewarded parties fit in 1 batch of size 2 for { (_, batchHashes, _) <- setupAndComputeHashes( @@ -891,7 +885,7 @@ class DbScanAppRewardsStoreTest } } - "computeRewardHashes — single party produces single leaf batch" in { + "single party produces single leaf batch" in { for { (_, batchHashes, _) <- setupAndComputeHashes(partyCount = 1, batchSize = 100) } yield { @@ -901,7 +895,7 @@ class DbScanAppRewardsStoreTest } } - "computeRewardHashes — 3 levels: 9 parties, batchSize=2" in { + "3 levels: 9 parties, batchSize=2" in { // batchSize=2 → level 0: 5 batches, level 1: 3, level 2: 2 for { (_, batchHashes, _) <- setupAndComputeHashes(partyCount = 9, batchSize = 2) @@ -918,7 +912,7 @@ class DbScanAppRewardsStoreTest } } - "computeRewardHashes — exact boundary: 4 parties, batchSize=2" in { + "exact boundary: 4 parties, batchSize=2" in { // batchSize=2 → level 0: 2 batches, level 1: 1 batch (aggregation stops) for { (_, batchHashes, _) <- setupAndComputeHashes(partyCount = 4, batchSize = 2) @@ -932,7 +926,7 @@ class DbScanAppRewardsStoreTest } } - "computeRewardHashes — all parties fit in one batch, no aggregation" in { + "all parties fit in one batch, no aggregation" in { // 3 parties, batchSize=100 → single leaf batch, no aggregation for { (_, batchHashes, rootHash) <- setupAndComputeHashes(partyCount = 3, batchSize = 100) @@ -947,7 +941,7 @@ class DbScanAppRewardsStoreTest } } - "computeRewardHashes — root hash exists after multi-level aggregation" in { + "root hash exists after multi-level aggregation" in { for { (store, _, _) <- setupAndComputeHashes(partyCount = 5, batchSize = 2) computed <- store.roundsWithComputedRewards(Seq(roundNumber)) @@ -960,7 +954,7 @@ class DbScanAppRewardsStoreTest ("no activity parties", 0, -1), ("activity but no rewarded parties", 1, 0), ).foreach { case (desc, parties, rewarded) => - s"computeRewardHashes — $desc produces empty root hash" in { + s"$desc produces empty root hash" in { for { (store, batchHashes, rootHash) <- setupAndComputeHashes( partyCount = parties, @@ -982,7 +976,7 @@ class DbScanAppRewardsStoreTest } } - "computeRewardHashes — re-run for same round raises error" in { + "re-run for same round raises error" in { for { (store, _, _) <- setupAndComputeHashes(partyCount = 1, batchSize = 100) result <- store.computeRewardHashes(roundNumber, batchSize = 100).failed @@ -990,10 +984,11 @@ class DbScanAppRewardsStoreTest result shouldBe a[Exception] } } + } - // -- lookupBatchByHash tests ---------------------------------------------- + "lookupBatchByHash" should { - "lookupBatchByHash — returns None for non-existent hash" in { + "returns None for non-existent hash" in { for { (store, _) <- newStore() result <- store.lookupBatchByHash( @@ -1005,7 +1000,7 @@ class DbScanAppRewardsStoreTest } } - "lookupBatchByHash — leaf batch returns MintingAllowances" in { + "leaf batch returns MintingAllowances" in { for { (store, historyId) <- newStore() _ <- store.insertAppActivityPartyTotals( @@ -1037,7 +1032,7 @@ class DbScanAppRewardsStoreTest } } - "lookupBatchByHash — internal batch returns BatchOfBatches" in { + "internal batch returns BatchOfBatches" in { for { (store, historyId) <- newStore() // 4 parties / batchSize=2 → 2 level-0 batches → 1 level-1 batch @@ -1078,7 +1073,6 @@ class DbScanAppRewardsStoreTest childHashes shouldBe level0Hashes } } - } private val verdictCounter = new java.util.concurrent.atomic.AtomicLong(1) @@ -1214,7 +1208,7 @@ class DbScanAppRewardsStoreTest val n = storeCounter.getAndIncrement() val participantId = mkParticipantId(s"rewards-test-$n") val updateHistory = new UpdateHistory( - storage.underlying, + storage, migrationId, s"app_rewards_test_$n", participantId, @@ -1344,7 +1338,7 @@ object DbScanAppRewardsStoreTest { * trafficPrice/amuletPrice = 1.0 so 1 MB of traffic = 1 CC of reward. */ val testInputs: RewardComputationInputs = { - import RewardComputationInputs.{fromBigDecimal as n} + import RewardComputationInputs.fromBigDecimal as n val tickDurationMicros = 600L * 1000000L val microsPerYear = 365L * 24 * 3600 * 1000000L val roundsPerYear = BigDecimal(microsPerYear) / BigDecimal(tickDurationMicros) diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/QueryAcsSnapshotPaginationTokenTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/QueryAcsSnapshotPaginationTokenTest.scala new file mode 100644 index 0000000000..0b5c5da666 --- /dev/null +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/QueryAcsSnapshotPaginationTokenTest.scala @@ -0,0 +1,51 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.scan.store + +import com.digitalasset.canton.BaseTest +import org.lfdecentralizedtrust.splice.scan.store.AcsSnapshotStore.QueryAcsSnapshotPaginationToken +import org.lfdecentralizedtrust.splice.scan.store.AcsSnapshotStore.QueryAcsSnapshotPaginationToken.RowIdQueryAcsSnapshotPaginationToken +import org.scalatest.wordspec.AnyWordSpec +import scala.util.Try + +class QueryAcsSnapshotPaginationTokenTest extends AnyWordSpec with BaseTest { + + "RowIdQueryAcsSnapshotPaginationToken" should { + + "encode to base64 and decode back" in { + val token = RowIdQueryAcsSnapshotPaginationToken(42L) + val encoded = token.encodeToBase64 + val decoded = QueryAcsSnapshotPaginationToken.tryDecodeFromBase64(encoded) + decoded shouldBe token + } + + "produce different encoded values for different row ids" in { + val token1 = RowIdQueryAcsSnapshotPaginationToken(1L) + val token2 = RowIdQueryAcsSnapshotPaginationToken(2L) + token1.encodeToBase64 should not equal token2.encodeToBase64 + } + } + + "QueryAcsSnapshotPaginationToken.decodeFromBase64" should { + + "return Left for an invalid base64 string" in { + val result = Try(QueryAcsSnapshotPaginationToken.tryDecodeFromBase64("not-valid-base64!!!")) + result.isFailure should be(true) + } + + "return Left for valid base64 but invalid JSON content" in { + val encoded = java.util.Base64.getEncoder.encodeToString("not-a-long".getBytes("UTF-8")) + val result = Try(QueryAcsSnapshotPaginationToken.tryDecodeFromBase64(encoded)) + result.isFailure should be(true) + } + + "return Left for valid base64 with JSON object instead of long" in { + val encoded = + java.util.Base64.getEncoder.encodeToString("""{"after": 42}""".getBytes("UTF-8")) + val result = Try(QueryAcsSnapshotPaginationToken.tryDecodeFromBase64(encoded)) + result.isFailure should be(true) + } + } + +} diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/ScanEventStoreTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/ScanEventStoreTest.scala index 9a0f061b3f..f066410b23 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/ScanEventStoreTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/ScanEventStoreTest.scala @@ -9,9 +9,10 @@ import org.lfdecentralizedtrust.splice.store.{ HistoryMetrics, PageLimit, StoreTestBase, + TimestampWithMigrationId, UpdateHistory, } -import org.lfdecentralizedtrust.splice.scan.store.db.DbScanVerdictStore +import org.lfdecentralizedtrust.splice.scan.store.db.{DbAppActivityRecordStore, DbScanVerdictStore} import org.lfdecentralizedtrust.splice.scan.store.db.DbScanVerdictStore.{TrafficSummaryT, EnvelopeT} import org.lfdecentralizedtrust.splice.store.db.SplicePostgresTest import com.digitalasset.canton.resource.DbStorage @@ -122,14 +123,14 @@ class ScanEventStoreTest extends StoreTestBase with HasExecutionContext with Spl // Fetch with cursor events2 <- fetchEvents( ctx.eventStore, - Some((domainMigrationId, recordTs1.minusSeconds(1))), + Some(TimestampWithMigrationId(recordTs1.minusSeconds(1), domainMigrationId)), domainMigrationId, pageLimit, ) // Fetch after recordTs1 events3 <- fetchEvents( ctx.eventStore, - Some((domainMigrationId, recordTs1)), + Some(TimestampWithMigrationId(recordTs1, domainMigrationId)), domainMigrationId, pageLimit, ) @@ -169,14 +170,14 @@ class ScanEventStoreTest extends StoreTestBase with HasExecutionContext with Spl // Fetch with cursor events2 <- fetchEvents( ctx.eventStore, - Some((domainMigrationId, recordTs1.minusSeconds(1))), + Some(TimestampWithMigrationId(recordTs1.minusSeconds(1), domainMigrationId)), domainMigrationId, pageLimit, ) // Fetch after recordTs1 events3 <- fetchEvents( ctx.eventStore, - Some((domainMigrationId, recordTs1)), + Some(TimestampWithMigrationId(recordTs1, domainMigrationId)), domainMigrationId, pageLimit, ) @@ -218,14 +219,14 @@ class ScanEventStoreTest extends StoreTestBase with HasExecutionContext with Spl // Fetch with cursor events2 <- fetchEvents( ctx.eventStore, - Some((domainMigrationId, recordTs1.minusSeconds(1))), + Some(TimestampWithMigrationId(recordTs1.minusSeconds(1), domainMigrationId)), domainMigrationId, pageLimit, ) // Fetch after recordTs1 events3 <- fetchEvents( ctx.eventStore, - Some((domainMigrationId, recordTs1)), + Some(TimestampWithMigrationId(recordTs1, domainMigrationId)), domainMigrationId, pageLimit, ) @@ -267,12 +268,17 @@ class ScanEventStoreTest extends StoreTestBase with HasExecutionContext with Spl events <- fetchEvents(ctx1.eventStore, None, mig1, pageLimit) events2 <- fetchEvents( ctx1.eventStore, - Some((mig0, recordTs1.minusSeconds(1))), + Some(TimestampWithMigrationId(recordTs1.minusSeconds(1), mig0)), mig1, pageLimit, ) // after recordTs1 - events3 <- fetchEvents(ctx1.eventStore, Some((mig0, recordTs1)), mig1, pageLimit) + events3 <- fetchEvents( + ctx1.eventStore, + Some(TimestampWithMigrationId(recordTs1, mig0)), + mig1, + pageLimit, + ) // Fetch by id works across migrationIds e1 <- ctx1.eventStore.getEventByUpdateId(updateId1, domainMigrationId) e2 <- ctx1.eventStore.getEventByUpdateId(updateId2, domainMigrationId) @@ -429,14 +435,14 @@ class ScanEventStoreTest extends StoreTestBase with HasExecutionContext with Spl // Fetch with cursor events2 <- fetchEvents( ctx.eventStore, - Some((domainMigrationId, recordTs1.minusSeconds(1))), + Some(TimestampWithMigrationId(recordTs1.minusSeconds(1), domainMigrationId)), domainMigrationId, pageLimit, ) // Fetch after latest verdict events3 <- fetchEvents( ctx.eventStore, - Some((domainMigrationId, recordTs2)), + Some(TimestampWithMigrationId(recordTs2, domainMigrationId)), domainMigrationId, pageLimit, ) @@ -474,14 +480,14 @@ class ScanEventStoreTest extends StoreTestBase with HasExecutionContext with Spl // Fetch with cursor events2 <- fetchEvents( ctx.eventStore, - Some((domainMigrationId, recordTs1.minusSeconds(1))), + Some(TimestampWithMigrationId(recordTs1.minusSeconds(1), domainMigrationId)), domainMigrationId, pageLimit, ) // Fetch after latest assignment events3 <- fetchEvents( ctx.eventStore, - Some((domainMigrationId, recordTs2)), + Some(TimestampWithMigrationId(recordTs2, domainMigrationId)), domainMigrationId, pageLimit, ) @@ -518,14 +524,14 @@ class ScanEventStoreTest extends StoreTestBase with HasExecutionContext with Spl // Fetch with cursor events2 <- fetchEvents( ctx.eventStore, - Some((domainMigrationId, recordTs1.minusSeconds(1))), + Some(TimestampWithMigrationId(recordTs1.minusSeconds(1), domainMigrationId)), domainMigrationId, pageLimit, ) // Fetch after latest verdict events3 <- fetchEvents( ctx.eventStore, - Some((domainMigrationId, recordTs2)), + Some(TimestampWithMigrationId(recordTs2, domainMigrationId)), domainMigrationId, pageLimit, ) @@ -564,14 +570,14 @@ class ScanEventStoreTest extends StoreTestBase with HasExecutionContext with Spl // Fetch with cursor events2 <- fetchEvents( ctx.eventStore, - Some((domainMigrationId, recordTs1.minusSeconds(1))), + Some(TimestampWithMigrationId(recordTs1.minusSeconds(1), domainMigrationId)), domainMigrationId, pageLimit, ) // Fetch after latest unassignment events3 <- fetchEvents( ctx.eventStore, - Some((domainMigrationId, recordTs2)), + Some(TimestampWithMigrationId(recordTs2, domainMigrationId)), domainMigrationId, pageLimit, ) @@ -813,7 +819,7 @@ class ScanEventStoreTest extends StoreTestBase with HasExecutionContext with Spl { val allow = ScanEventStore.allowF( - afterO = Some((mig0, recordTs1)), + afterO = Some(TimestampWithMigrationId(recordTs1, mig0)), currentMigrationId = mig1, currentMigrationCap = capMin, ) @@ -824,7 +830,7 @@ class ScanEventStoreTest extends StoreTestBase with HasExecutionContext with Spl { val allow = ScanEventStore.allowF( - afterO = Some((mig0, recordTs1)), + afterO = Some(TimestampWithMigrationId(recordTs1, mig0)), currentMigrationId = mig1, currentMigrationCap = cap3, ) @@ -838,7 +844,7 @@ class ScanEventStoreTest extends StoreTestBase with HasExecutionContext with Spl { val allow = ScanEventStore.allowF( - afterO = Some((mig1, recordTs2)), + afterO = Some(TimestampWithMigrationId(recordTs2, mig1)), currentMigrationId = mig1, currentMigrationCap = cap3, ) @@ -849,7 +855,7 @@ class ScanEventStoreTest extends StoreTestBase with HasExecutionContext with Spl { val allow = ScanEventStore.allowF( - afterO = Some((mig2, recordTs2)), + afterO = Some(TimestampWithMigrationId(recordTs2, mig2)), currentMigrationId = mig2, currentMigrationCap = cap2, ) @@ -880,7 +886,18 @@ class ScanEventStoreTest extends StoreTestBase with HasExecutionContext with Spl } private def newVerdictStore(updateHistory: UpdateHistory) = - new DbScanVerdictStore(storage.underlying, updateHistory, None, loggerFactory) + new DbScanVerdictStore( + storage.underlying, + updateHistory, + new DbAppActivityRecordStore( + storage.underlying, + updateHistory, + DbAppActivityRecordStore.IngestionVersions(1, 0), + false, + loggerFactory, + ), + loggerFactory, + ) private def insertUpdate( updateHistory: UpdateHistory, @@ -1031,7 +1048,7 @@ class ScanEventStoreTest extends StoreTestBase with HasExecutionContext with Spl private def fetchEvents( es: ScanEventStore, - afterO: Option[(Long, CantonTimestamp)], + afterO: Option[TimestampWithMigrationId], currentMigrationId: Long, limit: PageLimit, ): Future[Seq[ScanEventStore#Event]] = { diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/AcsSnapshotBulkStorageCommitFromStagingTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/AcsSnapshotBulkStorageCommitFromStagingTest.scala index 44adcd701e..2b3b0a314b 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/AcsSnapshotBulkStorageCommitFromStagingTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/AcsSnapshotBulkStorageCommitFromStagingTest.scala @@ -52,7 +52,8 @@ class AcsSnapshotBulkStorageCommitFromStagingTest zstdCompressionLevel = 3, ) val appConfig = BulkStorageConfig( - snapshotPollingInterval = NonNegativeFiniteDuration.ofSeconds(5) + snapshotPollingInterval = NonNegativeFiniteDuration.ofSeconds(5), + bftCheckEnabled = false, // bft checks are tested elsewhere ) override val initialBuckets: Seq[String] = Seq("staging", "committed") @@ -104,6 +105,8 @@ class AcsSnapshotBulkStorageCommitFromStagingTest committedConnection, reader, appConfig, + null, // not used when bft reads are disabled + _ => (), loggerFactory, ) val commitService = { @@ -137,21 +140,25 @@ class AcsSnapshotBulkStorageCommitFromStagingTest objectCount: Int, ): Unit = { (0 until objectCount).foreach { i => - stagingConnection - .createObject( - s"${bulkStorageTestConfig.getSegmentFolder(ts(day), None)}/ACS_$i.zstd", - s"dummy acs snapshot at ${ts(day)} (object $i)".getBytes, - ) - .futureValue + ScanStorageConfig.Encoding.all.toList.foreach { encoding => + stagingConnection + .createObject( + s"${bulkStorageTestConfig.getSegmentFolder(ts(day), None)}/${encoding.storageKey("ACS", i)}", + s"dummy acs snapshot at ${ts(day)} (object $i)".getBytes, + ) + .futureValue + } } } def assertCommittedObjectsForSnapshot(day: Int, expectedCount: Int): Assertion = { - val expectedKeys = (0 until expectedCount).map { i => - s"${bulkStorageTestConfig.getSegmentFolder(ts(day), None)}/ACS_$i.zstd" + val expectedKeys = (0 until expectedCount).flatMap { i => + ScanStorageConfig.Encoding.all.toList.map { encoding => + s"${bulkStorageTestConfig.getSegmentFolder(ts(day), None)}/${encoding.storageKey("ACS", i)}" + } } reader - .getCommittedObjectsForAcsSnapshotAtOrBefore(ts(day)) + .getCommittedObjectsForAcsSnapshotAtOrBefore(ts(day), ScanStorageConfig.Encoding.all) .futureValue .objects .map(_.key) should contain theSameElementsAs @@ -215,7 +222,8 @@ class AcsSnapshotBulkStorageCommitFromStagingTest committedConnection .copyObject( "staging", - s"${bulkStorageTestConfig.getSegmentFolder(ts(4), None)}/ACS_0.zstd", + s"${bulkStorageTestConfig.getSegmentFolder(ts(4), None)}/${ScanStorageConfig.Encoding.CompactJson + .storageKey("ACS", 0)}", ) .futureValue @@ -236,12 +244,14 @@ class AcsSnapshotBulkStorageCommitFromStagingTest committedConnection .copyObject( "staging", - s"${bulkStorageTestConfig.getSegmentFolder(ts(5), None)}/ACS_$i.zstd", + s"${bulkStorageTestConfig + .getSegmentFolder(ts(5), None)}/${ScanStorageConfig.Encoding.CompactJson.storageKey("ACS", i)}", ) .futureValue stagingConnection .deleteObject( - s"${bulkStorageTestConfig.getSegmentFolder(ts(5), None)}/ACS_$i.zstd" + s"${bulkStorageTestConfig + .getSegmentFolder(ts(5), None)}/${ScanStorageConfig.Encoding.CompactJson.storageKey("ACS", i)}" ) .futureValue } diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/AcsSnapshotBulkStorageWriterFromDbTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/AcsSnapshotBulkStorageWriterFromDbTest.scala index e700ce4a2d..d5768f5968 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/AcsSnapshotBulkStorageWriterFromDbTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/AcsSnapshotBulkStorageWriterFromDbTest.scala @@ -23,7 +23,11 @@ import org.apache.pekko.stream.scaladsl.{Sink, Source} import org.lfdecentralizedtrust.splice.config.AutomationConfig import org.lfdecentralizedtrust.splice.environment.{DarResources, RetryProvider, SpliceMetrics} import org.lfdecentralizedtrust.splice.http.v0.definitions as httpApi -import org.lfdecentralizedtrust.splice.scan.admin.http.CompactJsonScanHttpEncodings +import org.lfdecentralizedtrust.splice.scan.admin.http.{ + CompactJsonScanHttpEncodings, + ProtobufJsonScanHttpEncodings, + ScanHttpEncodings, +} import org.lfdecentralizedtrust.splice.scan.config.{BulkStorageConfig, ScanStorageConfig} import org.lfdecentralizedtrust.splice.scan.store.{ AcsSnapshotStore, @@ -50,6 +54,7 @@ import org.slf4j.event.Level import java.time.Instant import java.time.temporal.ChronoUnit +import java.util.concurrent.ConcurrentHashMap import scala.concurrent.{ExecutionContext, Future} import scala.jdk.CollectionConverters.* import scala.concurrent.duration.* @@ -106,49 +111,86 @@ class AcsSnapshotBulkStorageWriterFromDbTest ) .map(_.createdEventsInPage) } yield { - val objectKeys = s3Objects.contents.asScala.map(_.key()).sorted - objectKeys should have length 7 - objectKeys.foreach( - _ should startWith("2026-01-02T00:00:00Z~2026-01-03T00:00:00Z/ACS_") - ) - val objectCountMetrics = metricsFactory.metrics.counters.get( - SpliceMetrics.MetricsPrefix :+ "history" :+ "bulk-storage" :+ "object-count" - ) - val numObjectsFromMetric = objectCountMetrics.value - .get(MetricsContext.Empty) - .value - .markers - .get(MetricsContext("object_type" -> "ACS_snapshots")) - .value - .get() - numObjectsFromMetric shouldBe 7 + def checkEncoding(encoding: ScanStorageConfig.Encoding) = { + /* We hard-code the expected digests to enforce that the persisted data format does not change. + These values must not be modified unless there is a conscious decision to change the persisted format, + with a migration plan for how to apply it consistently across SVs. */ + val (encodings, expectedDigests): (ScanHttpEncodings, Seq[String]) = + encoding match { + case ScanStorageConfig.Encoding.CompactJson => + ( + new CompactJsonScanHttpEncodings(identity, identity), + Seq( + "n6CV6dF9zpleq66YiXmCG96hw1BBakp1I8JjC5lf5n0=", + "bJDalSmiVKCk9QSc6sAWdahJNZQqVn51WmkFbQI6wkA=", + "noZU+He8HnCM38MujtpEle4NNGwnE7wN8z96V+HTdK0=", + "rwak+Y4JcInTiEa2yUKf8rjO3RD7ay/D2hmQG4BAa54=", + "YM7SNxHrU3xYyNOjgEqowitAvgsiX1f7tq0pCaD/OhQ=", + "Mb3D2ZOVQclMwuYEqLuTKhGqnUHCio6K61FBTXgt5Vs=", + "+5iW2M9Vz5y9sCtEWyrS3m+EUqnD50dXRVIMQAMSgBY=", + ), + ) + case ScanStorageConfig.Encoding.ProtobufJson => + ( + ProtobufJsonScanHttpEncodings, + Seq( + "NDdxcBFCRqz5hXHXqJkD9qqzc1C9t0PWZgHq2F9xsUA=", + "hluFPPWS1V2djExfppU+aiPqYx18s/qxZe83nFjthV0=", + "5D3k/XWBw/OhN5wus7XMuesHKhQwlktDFMFcO9lAI5g=", + "sov3P/ekZ1CRQUPYVMcA7tn2yO4EV+XYtlWc5NF2FP0=", + "tYYPDCgkg/s+dbJD9i6kxBHiMIKq2RB/D0+Fc5gzlAI=", + "CjQBhKAQ+KU7it/OCAkyDtKLNHJmWu2nsU4x1TcT+us=", + "ZonY8bJ5NA2b1Y/gOU2eeUF6lVsQqijKWDjP8kKWvhU=", + "G7eAcDOxoCsxpC9Qwo61IZFUFD0sZnqPf3/dolF9nXQ=", + ), + ) + } + val objectKeys = s3Objects.contents.asScala + .map(_.key()) + .sorted + .filter( + encoding.storageKeyRegex("ACS").matches + ) + objectKeys should have length expectedDigests.length.toLong + objectKeys.foreach( + _ should startWith(s"2026-01-02T00:00:00Z~2026-01-03T00:00:00Z/ACS_${encoding.key}") + ) + val objectCountMetrics = metricsFactory.metrics.counters.get( + SpliceMetrics.MetricsPrefix :+ "history" :+ "bulk-storage" :+ "object-count" + ) + val numObjectsFromMetric = objectCountMetrics.value + .get(MetricsContext.Empty) + .value + .markers + .get( + MetricsContext( + "object_type" -> "ACS_snapshots", + "encoding" -> encoding.key, + "bucket" -> "staging", + ) + ) + .value + .get() + numObjectsFromMetric shouldBe expectedDigests.length - val allContractsFromS3 = objectKeys.flatMap( - readUncompressAndDecode( - bucketConnection, - io.circe.parser.decode[httpApi.ActiveContract], + val allContractsFromS3 = objectKeys.flatMap( + readUncompressAndDecode( + bucketConnection, + io.circe.parser.decode[httpApi.ActiveContract], + ) ) - ) - allContracts.map(c => - new CompactJsonScanHttpEncodings(identity, identity) - .javaToHttpActiveContract(c.eventId, c.recordTime, c.event) - ) should contain theSameElementsInOrderAs allContractsFromS3 + allContracts.map(c => + encodings.javaToHttpActiveContract(c.eventId, c.recordTime, c.event) + ) should contain theSameElementsInOrderAs allContractsFromS3 - /* We hard-code the expected digests to enforce that the persisted data format does not change. - These values must not be modified unless there is a conscious decision to change the persisted format, - with a migration plan for how to apply it consistently across SVs. */ - bucketConnection - .getChecksums(objectKeys.toSeq) - .futureValue - .map(_.checksum) should contain theSameElementsInOrderAs Seq( - "n6CV6dF9zpleq66YiXmCG96hw1BBakp1I8JjC5lf5n0=", - "bJDalSmiVKCk9QSc6sAWdahJNZQqVn51WmkFbQI6wkA=", - "noZU+He8HnCM38MujtpEle4NNGwnE7wN8z96V+HTdK0=", - "rwak+Y4JcInTiEa2yUKf8rjO3RD7ay/D2hmQG4BAa54=", - "YM7SNxHrU3xYyNOjgEqowitAvgsiX1f7tq0pCaD/OhQ=", - "Mb3D2ZOVQclMwuYEqLuTKhGqnUHCio6K61FBTXgt5Vs=", - "+5iW2M9Vz5y9sCtEWyrS3m+EUqnD50dXRVIMQAMSgBY=", - ) + bucketConnection + .getChecksums(objectKeys.toSeq) + .futureValue + .map(_.checksum) should contain theSameElementsInOrderAs expectedDigests + } + + checkEncoding(ScanStorageConfig.Encoding.CompactJson) + checkEncoding(ScanStorageConfig.Encoding.ProtobufJson) } } @@ -223,7 +265,8 @@ class AcsSnapshotBulkStorageWriterFromDbTest reader.getCommittedObjectsForAcsSnapshotAtOrBefore(queryTs).futureValue getObjectsResult.objects.map(_.key) should contain theSameElementsInOrderAs (0 until expectedNumObjects).map(i => - s"$expectedTs~${expectedTs.add(1.days)}/ACS_$i.zstd" + s"$expectedTs~${expectedTs + .add(1.days)}/${ScanStorageConfig.Encoding.CompactJson.storageKey("ACS", i)}" ) getObjectsResult.objects.map(_.checksum).foreach { // We test elsewhere that computed and persisted checksums are correct, so here we just check that they are present and not empty @@ -312,7 +355,7 @@ class AcsSnapshotBulkStorageWriterFromDbTest store.queryAcsSnapshot( anyLong, any[CantonTimestamp], - any[Option[Long]], + any[Option[AcsSnapshotStore.QueryAcsSnapshotPaginationToken]], any[Limit], any[Seq[PartyId]], any[Seq[PackageQualifiedName]], @@ -321,14 +364,22 @@ class AcsSnapshotBulkStorageWriterFromDbTest ( migration: Long, timestamp: CantonTimestamp, - after: Option[Long], + after: Option[AcsSnapshotStore.QueryAcsSnapshotPaginationToken], limit: Limit, _: Seq[PartyId], _: Seq[PackageQualifiedName], ) => if (snapshots.contains(timestamp)) { Future { - val remaining = snapshotSize - after.getOrElse(0L) + val afterAsLong = after match { + case Some( + AcsSnapshotStore.QueryAcsSnapshotPaginationToken + .RowIdQueryAcsSnapshotPaginationToken(value) + ) => + value + case None => 0L + } + val remaining = snapshotSize - afterAsLong val numElems = math.min(limit.limit.toLong, remaining) val result = QueryAcsSnapshotResult( migration, @@ -336,7 +387,7 @@ class AcsSnapshotBulkStorageWriterFromDbTest Vector .range(0, numElems) .map(i => { - val idx = i + after.getOrElse(0L) + val idx = i + afterAsLong val amt = amulet( partyId, BigDecimal(idx), @@ -351,7 +402,12 @@ class AcsSnapshotBulkStorageWriterFromDbTest toCreatedEvent(amt), ) }), - if (numElems < remaining) Some(after.getOrElse(0L) + numElems) else None, + if (numElems < remaining) + Some( + AcsSnapshotStore.QueryAcsSnapshotPaginationToken + .RowIdQueryAcsSnapshotPaginationToken(afterAsLong + numElems) + ) + else None, ) result } @@ -403,19 +459,12 @@ class AcsSnapshotBulkStorageWriterFromDbTest bucketConnection: S3BucketConnection ): S3BucketConnection = { val s3BucketConnectionWithErrors = Mockito.spy(bucketConnection) - var failureCount = 0 + val failedKeys = ConcurrentHashMap.newKeySet[String]() val _ = doAnswer { (invocation: InvocationOnMock) => val args = invocation.getArguments args.toList match { - case (key: String) :: _ if key.endsWith("2.zstd") => - if (failureCount < 1) { - failureCount += 1 - throw new RuntimeException(s"Simulated S3 error (#$failureCount)") - } else { - failureCount = 0 - logger.debug(s"No Simulated S3 error, resetting failureCount to 0") - invocation.callRealMethod().asInstanceOf[s3BucketConnectionWithErrors.AppendWriteObject] - } + case (key: String) :: _ if key.endsWith("2.zstd") && failedKeys.add(key) => + throw new RuntimeException(s"Simulated S3 error for $key") case _ => invocation.callRealMethod().asInstanceOf[s3BucketConnectionWithErrors.AppendWriteObject] } diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/BulkStorageCommitFromStagingTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/BulkStorageCommitFromStagingTest.scala index be368bc401..639d6ac845 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/BulkStorageCommitFromStagingTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/BulkStorageCommitFromStagingTest.scala @@ -3,40 +3,64 @@ package org.lfdecentralizedtrust.splice.scan.store.bulk +import com.digitalasset.canton.config.NonNegativeFiniteDuration import com.digitalasset.canton.lifecycle.FutureUnlessShutdown import com.digitalasset.canton.logging.SuppressionRule import com.digitalasset.canton.resource.DbStorage import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.{HasActorSystem, HasExecutionContext} +import org.apache.pekko.NotUsed +import org.apache.pekko.http.scaladsl.model.Uri +import org.lfdecentralizedtrust.splice.config.NetworkAppClientConfig +import org.lfdecentralizedtrust.splice.scan.config.ScanAppClientConfig +import org.lfdecentralizedtrust.splice.test.HasRetryProvider import org.slf4j.event.Level -import org.apache.pekko.stream.scaladsl.Keep +import org.apache.pekko.stream.scaladsl.{Flow, Keep} import org.apache.pekko.stream.testkit.scaladsl.{TestSink, TestSource} +import org.lfdecentralizedtrust.splice.environment.SpliceLedgerClient +import org.lfdecentralizedtrust.splice.http.HttpClient +import org.lfdecentralizedtrust.splice.http.v0.definitions.GetBulkObjectChecksumsResponse +import org.lfdecentralizedtrust.splice.scan.admin.api.client.{ + BftScanConnection, + SingleScanConnection, +} import org.lfdecentralizedtrust.splice.scan.config.BulkStorageConfig import org.lfdecentralizedtrust.splice.store.S3BucketConnection.ObjectKeyAndChecksum import org.lfdecentralizedtrust.splice.store.{HasS3Mock, StoreTestBase} import org.lfdecentralizedtrust.splice.store.db.SplicePostgresTest +import org.lfdecentralizedtrust.splice.util.TemplateJsonDecoder +import org.lfdecentralizedtrust.splice.scan.util.PeerBftScanConnection import java.security.MessageDigest import java.util.Base64 -import scala.concurrent.Future +import scala.concurrent.{ExecutionContext, Future} import scala.jdk.CollectionConverters.* +import scala.concurrent.duration.* class BulkStorageCommitFromStagingTest extends StoreTestBase with HasExecutionContext with HasActorSystem with HasS3Mock - with SplicePostgresTest { + with SplicePostgresTest + with HasRetryProvider { override val initialBuckets = Seq("staging", "committed") - val appConfig = BulkStorageConfig() + implicit val httpClient: HttpClient = null + implicit val templateJsonDecoder: TemplateJsonDecoder = null "BulkStorageCommitFromStaging" should { + val appConfig = BulkStorageConfig( + bftCheckEnabled = false + ) + "successfully move objects from staging to committed S3 bucket" in { val (stagingS3Connection, committedS3Connection, objsWithDigests) = setupTest - triggerCopyFlow(stagingS3Connection, committedS3Connection, objsWithDigests) + triggerCopyFlowAndAssertCompletion( + newCopyFlow(stagingS3Connection, committedS3Connection, objsWithDigests) + ) assertObjectsMoved(stagingS3Connection, committedS3Connection, objsWithDigests) } @@ -54,37 +78,299 @@ class BulkStorageCommitFromStagingTest loggerFactory.assertLogsSeq(SuppressionRule.LevelAndAbove(Level.DEBUG))( { - triggerCopyFlow(stagingS3Connection, committedS3Connection, objsWithDigests) + triggerCopyFlowAndAssertCompletion( + newCopyFlow(stagingS3Connection, committedS3Connection, objsWithDigests) + ) }, logEntries => forExactly(1, logEntries)(_.message should include("Skipping copy")), ) assertObjectsMoved(stagingS3Connection, committedS3Connection, objsWithDigests) } + + def newCopyFlow( + stagingS3Connection: S3BucketConnectionForUnitTests, + committedS3Connection: S3BucketConnectionForUnitTests, + objsWithDigests: Seq[ObjectKeyAndChecksum], + ) = { + BulkStorageCommitFromStaging[String]( + stagingS3Connection, + committedS3Connection, + _ => Future.successful(objsWithDigests), + appConfig, + null, // not used when bft reads are disabled + loggerFactory, + ) + } + } - private def triggerCopyFlow( - stagingS3Connection: S3BucketConnectionForUnitTests, - committedS3Connection: S3BucketConnectionForUnitTests, - objsWithDigests: Seq[ObjectKeyAndChecksum], - ) = { - val flow = BulkStorageCommitFromStaging[String]( - stagingS3Connection, - committedS3Connection, - _ => Future.successful(objsWithDigests), - appConfig, - loggerFactory, + "BulkStorageCommitFromStaging with BFT reads enabled" should { + val appConfig = BulkStorageConfig( + bftRetryInterval = NonNegativeFiniteDuration.ofSeconds(1) ) + "successfully move objects from staging to committed S3 bucket when there's full consensus" in { + val (stagingS3Connection, committedS3Connection, objsWithDigests) = setupTest + + val mockScanConnections = new MockScanConnections(objsWithDigests) + Seq.range(0, 7).foreach { i => + mockScanConnections.scanAgrees(i) + } + + val flow = newCopyFlow( + stagingS3Connection, + committedS3Connection, + objsWithDigests, + mockScanConnections, + ) + + triggerCopyFlowAndAssertCompletion(flow) + + assertObjectsMoved(stagingS3Connection, committedS3Connection, objsWithDigests) + } + + "wait until all objects are known to the peers, and report disagreement on consensus correctly" in { + val (stagingS3Connection, committedS3Connection, objsWithDigests) = setupTest + + val mockScanConnections = new MockScanConnections(objsWithDigests) + + val flow = newCopyFlow( + stagingS3Connection, + committedS3Connection, + objsWithDigests, + mockScanConnections, + ) + + val (pub, sub) = TestSource + .probe[String] + .via(flow) + .toMat(TestSink.probe[String])(Keep.both) + .run() + + clue("When one object is not known to the peers, the copy flow should not complete") { + Seq.range(0, 2).foreach(i => mockScanConnections.scanAgrees(i)) + Seq.range(2, 7).foreach(i => mockScanConnections.scanMissingAnObject(i, 1)) + + sub.request(1) + pub.sendNext("go") + sub.expectNoMessage(20.seconds) + + stagingS3Connection.listObjects.futureValue + .contents() + .asScala should have size objsWithDigests.size.toLong + committedS3Connection.listObjects.futureValue.contents().asScala shouldBe empty + } + + // errors on mismatching digests continue past the first clue for some time until enough scans are updated to agree on the digests, + // so we make the assertion on the logs fairly wide here to avoid the late error logs failing the log checker + loggerFactory.assertLogsSeq(SuppressionRule.LevelAndAbove(Level.ERROR))( + { + clue( + "Simulate a majority disagreeing with our digests, the copy flow should not complete and an error should be emitted" + ) { + Seq.range(2, 7).foreach(i => mockScanConnections.scanDisagreesOnDigest(i, 1)) + sub.expectNoMessage(20.seconds) + stagingS3Connection.listObjects.futureValue + .contents() + .asScala should have size objsWithDigests.size.toLong + committedS3Connection.listObjects.futureValue.contents().asScala shouldBe empty + + } + + clue("Enough scans do agree - the copy flow should complete successfully") { + Seq.range(2, 5).foreach(i => mockScanConnections.scanAgrees(i)) + sub.expectNext(20.seconds, "go") + assertObjectsMoved(stagingS3Connection, committedS3Connection, objsWithDigests) + } + + }, + logEntries => + forAtLeast(1, logEntries)( + _.message should include( + "Checksums do not match for objects" + ) + ), + ) + } + + "ignore digest mismatches for objects listed in debugObjectsToNotCommit and not copy them to the committed bucket" in { + val (stagingS3Connection, committedS3Connection, objsWithDigests) = setupTest + + val ignoredObject = objsWithDigests(1) + + val mockScanConnections = new MockScanConnections(objsWithDigests) + // all peers disagree with us on the digest of the ignored object only + Seq.range(0, 7).foreach(i => mockScanConnections.scanDisagreesOnDigest(i, 1)) + + val flow = newCopyFlow( + stagingS3Connection, + committedS3Connection, + objsWithDigests, + mockScanConnections, + appConfig.copy(debugObjectsToNotCommit = Seq(ignoredObject.key)), + ) + + loggerFactory.assertLogsSeq(SuppressionRule.LevelAndAbove(Level.ERROR))( + { + triggerCopyFlowAndAssertCompletion(flow) + }, + logEntries => + forAll(logEntries)( + _.message should include("Checksums do not match for objects") + ), + ) + + val expectedCommittedObjects = objsWithDigests.filterNot(_.key == ignoredObject.key) + + clue("All objects have been deleted from staging") { + stagingS3Connection.listObjects.futureValue.contents().asScala shouldBe empty + } + clue("Only the non-ignored objects have been copied to the committed bucket") { + committedS3Connection.listObjects.futureValue + .contents() + .asScala + .map(_.key()) should contain theSameElementsAs expectedCommittedObjects.map(_.key) + } + clue("Checksums of objects in committed S3 bucket match the expected digests") { + committedS3Connection + .getChecksums(expectedCommittedObjects.map(_.key)) + .futureValue should contain theSameElementsAs expectedCommittedObjects + } + } + + class MockScanConnections( + objsWithDigests: Seq[ObjectKeyAndChecksum] + ) { + + private val singleScanConnections: Seq[SingleScanConnection] = Seq.range(0, 7).map { i => + val mockConn = mock[SingleScanConnection] + when(mockConn.config) thenReturn ScanAppClientConfig( + NetworkAppClientConfig( + Uri(s"http://dummy-admin-$i") + ) + ) + when(mockConn.url) thenReturn Uri(s"http://scan_$i") + mockConn + } + + def scanAgrees(idx: Integer): Unit = { + when( + singleScanConnections(idx) + .getBulkObjectChecksums(any[Seq[String]])(any[ExecutionContext], any[TraceContext]) + ) + .thenReturn( + Future.successful( + new GetBulkObjectChecksumsResponse( + objsWithDigests + .map(_.checksum) + .map(digest => new GetBulkObjectChecksumsResponse.Checksums(Some(digest))) + .toVector + ) + ) + ) + () + } + + def scanDisagreesOnDigest(scanIdx: Integer, objIdx: Integer): Unit = { + when( + singleScanConnections(scanIdx) + .getBulkObjectChecksums(any[Seq[String]])(any[ExecutionContext], any[TraceContext]) + ) + .thenReturn( + Future.successful( + new GetBulkObjectChecksumsResponse( + objsWithDigests + .map(_.checksum) + .updated(objIdx, "wrong-digest") + .map(digest => new GetBulkObjectChecksumsResponse.Checksums(Some(digest))) + .toVector + ) + ) + ) + } + + def scanMissingAnObject(scanIdx: Integer, objIdx: Integer): Unit = { + when( + singleScanConnections(scanIdx) + .getBulkObjectChecksums(any[Seq[String]])(any[ExecutionContext], any[TraceContext]) + ) + .thenReturn( + Future.successful( + new GetBulkObjectChecksumsResponse( + objsWithDigests + .map(_.checksum) + .map(Some(_)) + .updated(objIdx, None) + .map(oDigest => new GetBulkObjectChecksumsResponse.Checksums(oDigest)) + .toVector + ) + ) + ) + } + + private val scanList = new BftScanConnection.AllDsoScansBft( + initialScanConnections = singleScanConnections, + initialFailedConnections = Map.empty, + connectionBuilder = _ => Future.failed(new RuntimeException("Shouldn't be refreshing!")), + scanUrlsChangedCallback = _ => Future.unit, + getScans = BftScanConnection.Bft.getScansInDsoRules, + scansRefreshInterval = NonNegativeFiniteDuration.ofDays(10), + retryProvider = testRetryProvider, + loggerFactory = loggerFactory, + ) + private val bftConnection = new BftScanConnection( + amuletLedgerClient = mock[SpliceLedgerClient], + amuletRulesCacheTimeToLive = NonNegativeFiniteDuration.ofSeconds(1), + scanList = scanList, + clock = wallClock, + retryProvider = testRetryProvider, + loggerFactory = loggerFactory, + ) + val peerBftConnection: PeerBftScanConnection = mock[PeerBftScanConnection] + when(peerBftConnection.connection(any[TraceContext])) + .thenReturn(Future.successful(bftConnection)) + } + + def newCopyFlow( + stagingS3Connection: S3BucketConnectionForUnitTests, + committedS3Connection: S3BucketConnectionForUnitTests, + objsWithDigests: Seq[ObjectKeyAndChecksum], + mockScanConnections: MockScanConnections, + config: BulkStorageConfig = appConfig, + ) = { + new BulkStorageCommitFromStaging[String]( + stagingS3Connection, + committedS3Connection, + _ => Future.successful(objsWithDigests), + config, + mockScanConnections.peerBftConnection, + loggerFactory, + ).getFlow + } + } + + private def triggerCopyFlowAndAssertCompletion( + flow: Flow[String, String, NotUsed] + ) = { val (pub, sub) = TestSource .probe[String] .via(flow) .toMat(TestSink.probe[String])(Keep.both) .run() - sub.request(1) - pub.sendNext("go") - sub.expectNext("go") + try { + sub.request(1) + pub.sendNext("go") + sub.expectNext("go") + pub.sendComplete() + sub.expectComplete() + } catch { + case ex: Throwable => + pub.sendError(ex) + sub.cancel() + throw ex + } } private def assertObjectsMoved( diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/S3UploadTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/S3UploadTest.scala index 62d6754e3e..586ef6f0fa 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/S3UploadTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/S3UploadTest.scala @@ -6,16 +6,21 @@ package org.lfdecentralizedtrust.splice.scan.store.bulk import org.apache.pekko.stream.scaladsl.Keep import org.apache.pekko.stream.testkit.scaladsl.{TestSink, TestSource} import org.apache.pekko.util.ByteString -import org.lfdecentralizedtrust.splice.store.{HasS3Mock, StoreTestBase} +import org.lfdecentralizedtrust.splice.config.S3Config +import org.lfdecentralizedtrust.splice.store.{HasS3Mock, S3BucketConnection, StoreTestBase} +import com.digitalasset.canton.logging.NamedLoggerFactory import scala.util.Random import scala.concurrent.duration.* +import scala.concurrent.{ExecutionContext, Future, Promise} import scala.jdk.CollectionConverters.* - import java.nio.ByteBuffer +import java.util.concurrent.atomic.AtomicInteger class S3UploadTest extends StoreTestBase with HasS3Mock { + private val emptyDigest = "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=" + "S3 multipart uploads" should { "work" in { @@ -35,6 +40,27 @@ class S3UploadTest extends StoreTestBase with HasS3Mock { new String(content.toArray, "UTF-8") shouldBe "helloworld" } } + + "not corrupt the checksum if finish() is called more than once" in { + val expectedContent = "idempotency test" + val bucketConnection = new S3BucketConnectionForUnitTests(s3ConfigMock(), loggerFactory) + val o = bucketConnection.newAppendWriteObject("finish-twice") + val part = ByteBuffer.wrap(expectedContent.getBytes("UTF-8")) + + o.prepareUploadNext(part) + for { + _ <- o.upload(1, part) + _ <- o.finish() + checksumAfterFirstFinish <- bucketConnection.getChecksums(Seq("finish-twice")) + _ <- o.finish() + checksumAfterSecondFinish <- bucketConnection.getChecksums(Seq("finish-twice")) + content <- bucketConnection.readFullObject("finish-twice") + } yield { + checksumAfterFirstFinish.map(_.checksum) should not contain emptyDigest + checksumAfterSecondFinish shouldBe checksumAfterFirstFinish + new String(content.toArray, "UTF-8") shouldBe expectedContent + } + } } "GroupedWeightS3Object" should { @@ -59,6 +85,7 @@ class S3UploadTest extends StoreTestBase with HasS3Mock { .run() val it = data.iterator + def sendBytes(n: Int) = pub.sendNext(it.getByteString(n)) @@ -105,6 +132,7 @@ class S3UploadTest extends StoreTestBase with HasS3Mock { .run() val it = data.iterator + def sendBytes(n: Int) = pub.sendNext(it.getByteString(n)) @@ -115,5 +143,84 @@ class S3UploadTest extends StoreTestBase with HasS3Mock { sub.expectError() succeed } + + "not finish an object twice when upstream completes while the object is being finished" in { + // Regression test for the race that produced correct object content with a wrong checksum: + // an object that is done by size starts being finished from uploadCallback; `state` is only + // advanced later, in the async finishCallback. Upstream completion is delivered eagerly + // (independently of demand), so onUpstreamFinish could land in that window and call finish() + // a second time on the very same object. + val bucketConnection = new GatedFinishS3Connection(s3ConfigMock(), loggerFactory) + + val (pub, sub) = TestSource + .probe[ByteString] + .via( + GroupedWeightS3ObjectFlow( + bucketConnection, + getObjectKey = i => s"race_$i", + maxObjectSize = 10L, + maxParallelPartUploads = 2, + loggerFactory, + ) + ) + .toMat(TestSink.probe[String])(Keep.both) + .run() + + sub.request(5) + // Exactly hits maxObjectSize, so the object is done by size and finish() is started + // from uploadCallback as soon as the single part upload completes. + pub.sendNext(ByteString(Random.nextBytes(10))) + + // Wait until the flow is blocked inside finish() + eventually() { + bucketConnection.finishCount.get() shouldBe 1 + } + + // Complete upstream while finish() is still in flight + pub.sendComplete() + always(durationOfSuccess = 2.seconds) { + bucketConnection.finishCount.get() shouldBe 1 + } + + bucketConnection.releaseFinish() + sub.expectNext(20.seconds) shouldBe "race_0" + sub.expectComplete() + + val checksums = bucketConnection.getChecksums(Seq("race_0")).futureValue + checksums should have size 1 + checksums.map(_.checksum) should not contain emptyDigest + succeed + } + } + + /** An S3 connection whose `finish()` blocks until [[releaseFinish]] is called, and which counts + * how many times `finish()` was invoked. + */ + private class GatedFinishS3Connection( + s3Config: S3Config, + loggerFactory: NamedLoggerFactory, + ) extends S3BucketConnectionForUnitTests(s3Config, loggerFactory) { + val finishCount = new AtomicInteger(0) + private val gate = Promise[Unit]() + + def releaseFinish(): Unit = { val _ = gate.trySuccess(()) } + + override def newAppendWriteObject( + key: String + )(implicit ec: ExecutionContext): AppendWriteObject = + new AppendWriteObjectForUnitTests(key) { + override def finish(): Future[Unit] = { + val _ = finishCount.incrementAndGet() + gate.future.flatMap(_ => super.finish()) + } + } + } + + "S3BucketConnection" should { + "Get checksums does not panic on non existing object" in { + val bucketConnection = new S3BucketConnection(s3ConfigMock(), loggerFactory) + val checksum = bucketConnection.getChecksums(Seq("non-existing-object")).futureValue + checksum shouldBe empty + } } } diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/UpdateHistoryBulkStorageTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/UpdateHistoryBulkStorageTest.scala index 62ddcd750e..6a9eed5319 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/UpdateHistoryBulkStorageTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/bulk/UpdateHistoryBulkStorageTest.scala @@ -25,7 +25,11 @@ import org.lfdecentralizedtrust.splice.config.AutomationConfig import org.lfdecentralizedtrust.splice.environment.{DarResources, RetryProvider, SpliceMetrics} import org.lfdecentralizedtrust.splice.environment.ledger.api.TransactionTreeUpdate import org.lfdecentralizedtrust.splice.http.v0.definitions.UpdateHistoryItemV2 -import org.lfdecentralizedtrust.splice.scan.admin.http.CompactJsonScanHttpEncodings +import org.lfdecentralizedtrust.splice.scan.admin.http.{ + CompactJsonScanHttpEncodings, + ProtobufJsonScanHttpEncodings, + ScanHttpEncodings, +} import org.lfdecentralizedtrust.splice.scan.config.{BulkStorageConfig, ScanStorageConfig} import org.lfdecentralizedtrust.splice.scan.store.{ScanKeyValueProvider, ScanKeyValueStore} import org.lfdecentralizedtrust.splice.store.UpdateHistory.UpdateHistoryResponse @@ -57,7 +61,8 @@ class UpdateHistoryBulkStorageTest zstdCompressionLevel = 3, ) val appConfig = BulkStorageConfig( - updatesPollingInterval = NonNegativeFiniteDuration.ofSeconds(5) + updatesPollingInterval = NonNegativeFiniteDuration.ofSeconds(5), + bftCheckEnabled = false, // bft checks are tested elsewhere ) "UpdateHistoryBulkStorage" should { @@ -104,22 +109,43 @@ class UpdateHistoryBulkStorageTest "Ingest 1000 more events. Now the last timestamp will be beyond the segment, so the source will complete and emit the object keys" ) { mockStore.mockIngestion(1000) - probe.expectNext(20.seconds) should contain theSameElementsInOrderAs Seq( - "1970-01-01T00:00:00.100Z~1970-01-01T00:00:02.300Z/updates_0.zstd", - "1970-01-01T00:00:00.100Z~1970-01-01T00:00:02.300Z/updates_1.zstd", + val expectedKeys = ScanStorageConfig.Encoding.all.toList.flatMap(e => + Seq( + s"1970-01-01T00:00:00.100Z~1970-01-01T00:00:02.300Z/${e.storageKey("updates", 0)}", + s"1970-01-01T00:00:00.100Z~1970-01-01T00:00:02.300Z/${e.storageKey("updates", 1)}", + ) + ) + val actualKeys = probe.expectNext(20.seconds) + def filterKeys(keys: Seq[String], encoding: ScanStorageConfig.Encoding) = + keys.filter(encoding.storageKeyRegex("updates").matches) + actualKeys should contain theSameElementsAs expectedKeys + // Confirm encoding-specific keys are in the correct order + ScanStorageConfig.Encoding.all.toList.foreach(e => + filterKeys(actualKeys, e) should contain theSameElementsInOrderAs filterKeys( + expectedKeys, + e, + ) ) probe.expectComplete() val objectCountMetrics = metricsFactory.metrics.counters .get(SpliceMetrics.MetricsPrefix :+ "history" :+ "bulk-storage" :+ "object-count") .value - val numObjectsFromMetric = objectCountMetrics - .get(MetricsContext.Empty) - .value - .markers - .get(MetricsContext("object_type" -> "updates")) - .value - .get() - numObjectsFromMetric shouldBe 2 + def numObjectsFromMetric(encoding: ScanStorageConfig.Encoding): Long = + objectCountMetrics + .get(MetricsContext.Empty) + .value + .markers + .get( + MetricsContext( + "object_type" -> "updates", + "encoding" -> encoding.key, + "bucket" -> "staging", + ) + ) + .value + .get() + numObjectsFromMetric(ScanStorageConfig.Encoding.CompactJson) shouldBe 2 + numObjectsFromMetric(ScanStorageConfig.Encoding.ProtobufJson) shouldBe 2 } clue("Check that the dumped content is correct") { @@ -134,27 +160,50 @@ class UpdateHistoryBulkStorageTest update.update.update.recordTime <= toTimestamp ) } yield { - val objectKeys = s3Objects.contents.asScala.map(_.key()).sorted - objectKeys should have length 2 - s3Objects.contents().get(0).size().toInt should be >= maxFileSize.toInt - val allUpdatesFromS3 = objectKeys.flatMap( - readUncompressAndDecode(bucketConnection, io.circe.parser.decode[UpdateHistoryItemV2]) - ) - allUpdatesFromS3.length shouldBe segmentUpdates.length - allUpdatesFromS3 - .map( - new CompactJsonScanHttpEncodings(identity, identity).httpToLapiUpdate - ) should contain theSameElementsInOrderAs segmentUpdates - /* We hard-code the expected digests to enforce that the persisted data format does not change. - These values must not be modified unless there is a conscious decision to change the persisted format, - with a migration plan for how to apply it consistently across SVs. */ - bucketConnection - .getChecksums(objectKeys.toSeq) - .futureValue - .map(_.checksum) should contain theSameElementsInOrderAs Seq( - "MM+DyxPP6UgpAaSCsm99j4ZAtYIK3TIrPmxFyodBrQQ=", - "2oWb5Um18xwnJTMkC4yilyrcsUADYoxtV7toJi29VsI=", - ) + def checkEncoding(encoding: ScanStorageConfig.Encoding) = { + /* We hard-code the expected digests to enforce that the persisted data format does not change. + These values must not be modified unless there is a conscious decision to change the persisted format, + with a migration plan for how to apply it consistently across SVs. */ + val (encodings, expectedDigests): (ScanHttpEncodings, Seq[String]) = encoding match { + case ScanStorageConfig.Encoding.CompactJson => + ( + new CompactJsonScanHttpEncodings(identity, identity), + Seq( + "MM+DyxPP6UgpAaSCsm99j4ZAtYIK3TIrPmxFyodBrQQ=", + "2oWb5Um18xwnJTMkC4yilyrcsUADYoxtV7toJi29VsI=", + ), + ) + case ScanStorageConfig.Encoding.ProtobufJson => + ( + ProtobufJsonScanHttpEncodings, + Seq( + "9QrYwnzkSce+GIh82uzY+1JHv4ukYC+llD0Idx1GDio=", + "pCOz8MG6Zoxup4NGnzBx48kFPm582cWn+GxWSZFyq+E=", + ), + ) + } + + val filteredS3Objects = s3Objects.contents.asScala + .filter(o => encoding.storageKeyRegex("updates").matches(o.key())) + val objectKeys = filteredS3Objects.map(_.key()).sorted + objectKeys should have length expectedDigests.length.toLong + filteredS3Objects(0).size().toInt should be >= maxFileSize.toInt + val allUpdatesFromS3 = objectKeys.flatMap( + readUncompressAndDecode(bucketConnection, io.circe.parser.decode[UpdateHistoryItemV2]) + ) + allUpdatesFromS3.length shouldBe segmentUpdates.length + allUpdatesFromS3 + .map( + encodings.httpToLapiUpdate + ) should contain theSameElementsInOrderAs segmentUpdates + bucketConnection + .getChecksums(objectKeys.toSeq) + .futureValue + .map(_.checksum) should contain theSameElementsInOrderAs expectedDigests + } + + checkEncoding(ScanStorageConfig.Encoding.CompactJson) + checkEncoding(ScanStorageConfig.Encoding.ProtobufJson) } } } @@ -382,28 +431,28 @@ class UpdateHistoryBulkStorageTest loggerFactory, ) - val d20u0 = "2015-10-20T00:00:00Z~2015-10-21T00:00:00Z/updates_0.zstd" - val d20u1 = "2015-10-20T00:00:00Z~2015-10-21T00:00:00Z/updates_1.zstd" - val d21u0 = "2015-10-21T00:00:00Z~2015-10-22T00:00:00Z/updates_0.zstd" - val d21u1 = "2015-10-21T00:00:00Z~2015-10-22T00:00:00Z/updates_1.zstd" - val d22u0 = "2015-10-22T00:00:00Z~2015-10-23T00:00:00Z/updates_0.zstd" - val d22u1 = "2015-10-22T00:00:00Z~2015-10-23T00:00:00Z/updates_1.zstd" - val d23u0 = "2015-10-23T00:00:00Z~2015-10-24T00:00:00Z/updates_0.zstd" - val d23u1 = "2015-10-23T00:00:00Z~2015-10-24T00:00:00Z/updates_1.zstd" - val d24u0 = "2015-10-24T00:00:00Z~2015-10-25T00:00:00Z/updates_0.zstd" - val d24u1 = "2015-10-24T00:00:00Z~2015-10-25T00:00:00Z/updates_1.zstd" - val allObjs = Seq( - d20u0, - d20u1, - d21u0, - d21u1, - d22u0, - d22u1, - d23u0, - d23u1, - d24u0, - d24u1, - ) + def makeObjectKeys(dates: String, prefix: String = "updates"): Seq[String] = + ScanStorageConfig.Encoding.all.toList.flatMap { encoding => + Seq(0, 1).map { i => + s"${dates}/${encoding.storageKey(prefix, i)}" + } + } + + def getCommitted(start: String, end: String, limit: Int, nextPageTokenO: Option[String]) = + reader.getCommittedUpdatesBetweenDates( + CantonTimestamp.tryFromInstant(Instant.parse(start)), + CantonTimestamp.tryFromInstant(Instant.parse(end)), + PageLimit.tryCreate(limit), + nextPageTokenO, + ScanStorageConfig.Encoding.all, + ) + + val d20 = makeObjectKeys("2015-10-20T00:00:00Z~2015-10-21T00:00:00Z") + val d21 = makeObjectKeys("2015-10-21T00:00:00Z~2015-10-22T00:00:00Z") + val d22 = makeObjectKeys("2015-10-22T00:00:00Z~2015-10-23T00:00:00Z") + val d23 = makeObjectKeys("2015-10-23T00:00:00Z~2015-10-24T00:00:00Z") + val d24 = makeObjectKeys("2015-10-24T00:00:00Z~2015-10-25T00:00:00Z") + val allObjs = d20 ++ d21 ++ d22 ++ d23 ++ d24 Future .sequence(allObjs.map { bucketConnection.createObject(_) @@ -411,108 +460,58 @@ class UpdateHistoryBulkStorageTest .futureValue // A wider range than the data - val res1 = reader - .getCommittedUpdatesBetweenDates( - CantonTimestamp.tryFromInstant(Instant.parse("2015-10-10T00:00:00Z")), - CantonTimestamp.tryFromInstant(Instant.parse("2015-10-30T00:00:00Z")), - PageLimit.tryCreate(10), - None, - ) - .futureValue - res1.objects.map(_.key) should contain theSameElementsInOrderAs Seq( - d20u0, - d20u1, - d21u0, - d21u1, - d22u0, - d22u1, - d23u0, - d23u1, - ) + val res1 = getCommitted("2015-10-10T00:00:00Z", "2015-10-30T00:00:00Z", 20, None).futureValue + res1.objects.map(_.key) should contain theSameElementsInOrderAs d20 ++ d21 ++ d22 ++ d23 res1.nextPageTokenO shouldBe Some("2015-10-23T00:00:00Z~2015-10-24T00:00:00Z/") - val res1b = reader - .getCommittedUpdatesBetweenDates( - CantonTimestamp.tryFromInstant(Instant.parse("2015-10-10T00:00:00Z")), - CantonTimestamp.tryFromInstant(Instant.parse("2015-10-30T00:00:00Z")), - PageLimit.tryCreate(10), - res1.nextPageTokenO, - ) - .futureValue + val res1b = getCommitted( + "2015-10-10T00:00:00Z", + "2015-10-30T00:00:00Z", + 20, + res1.nextPageTokenO, + ).futureValue res1b.objects.map(_.key) shouldBe empty res1b.nextPageTokenO shouldBe Some("2015-10-23T00:00:00Z~2015-10-24T00:00:00Z/") // A smaller range within the data - val res2 = reader - .getCommittedUpdatesBetweenDates( - CantonTimestamp.tryFromInstant(Instant.parse("2015-10-21T16:00:00Z")), - CantonTimestamp.tryFromInstant(Instant.parse("2015-10-21T16:00:05Z")), - PageLimit.tryCreate(10), - None, - ) - .futureValue - res2.objects.map(_.key) should contain theSameElementsInOrderAs Seq(d21u0, d21u1) + val res2 = getCommitted("2015-10-21T16:00:00Z", "2015-10-21T16:00:05Z", 20, None).futureValue + res2.objects.map(_.key) should contain theSameElementsInOrderAs d21 res2.nextPageTokenO shouldBe None // pagination - val res3 = reader - .getCommittedUpdatesBetweenDates( - CantonTimestamp.tryFromInstant(Instant.parse("2015-10-01T12:00:00Z")), - CantonTimestamp.tryFromInstant(Instant.parse("2015-10-21T16:00:05Z")), - PageLimit.tryCreate( - 3 - ), // on purpose 3 even though we expect only 2 back (since the response is always full days of updates) - None, - ) - .futureValue - res3.objects.map(_.key) should contain theSameElementsInOrderAs Seq(d20u0, d20u1) + val res3 = getCommitted( + "2015-10-01T12:00:00Z", + "2015-10-21T16:00:05Z", + 5, // on purpose 5 even though we expect only 4 back (since the response is always full days of updates) + None, + ).futureValue + res3.objects.map(_.key) should contain theSameElementsInOrderAs d20 res3.nextPageTokenO shouldBe Some("2015-10-20T00:00:00Z~2015-10-21T00:00:00Z/") - val res3b = reader - .getCommittedUpdatesBetweenDates( - CantonTimestamp.tryFromInstant(Instant.parse("2015-10-01T12:00:00Z")), - CantonTimestamp.tryFromInstant(Instant.parse("2015-10-21T16:00:05Z")), - PageLimit.tryCreate(3), - res3.nextPageTokenO, - ) - .futureValue - res3b.objects.map(_.key) should contain theSameElementsInOrderAs Seq(d21u0, d21u1) + val res3b = getCommitted( + "2015-10-01T12:00:00Z", + "2015-10-21T16:00:05Z", + 5, + res3.nextPageTokenO, + ).futureValue + res3b.objects.map(_.key) should contain theSameElementsInOrderAs d21 res3b.nextPageTokenO shouldBe None // exact match with start and end of segments - val res4 = reader - .getCommittedUpdatesBetweenDates( - CantonTimestamp.tryFromInstant(Instant.parse("2015-10-21T00:00:00Z")), - CantonTimestamp.tryFromInstant(Instant.parse("2015-10-23T00:00:00Z")), - PageLimit.tryCreate(4), - None, - ) - .futureValue + val res4 = getCommitted("2015-10-21T00:00:00Z", "2015-10-23T00:00:00Z", 8, None).futureValue res4.objects - .map(_.key) should contain theSameElementsInOrderAs Seq(d21u0, d21u1, d22u0, d22u1) + .map(_.key) should contain theSameElementsInOrderAs d21 ++ d22 res4.nextPageTokenO shouldBe None // limit too low for first folder - val ex = reader - .getCommittedUpdatesBetweenDates( - CantonTimestamp.tryFromInstant(Instant.parse("2015-10-21T00:00:00Z")), - CantonTimestamp.tryFromInstant(Instant.parse("2015-10-23T00:00:00Z")), - PageLimit.tryCreate(1), - None, - ) - .failed - .futureValue + val ex = + getCommitted("2015-10-21T00:00:00Z", "2015-10-23T00:00:00Z", 3, None).failed.futureValue ex shouldBe a[StatusRuntimeException] ex.asInstanceOf[StatusRuntimeException] .getStatus .getCode shouldBe io.grpc.Status.Code.INVALID_ARGUMENT // Test handling an empty segment: Simulate no updates in 2015-10-25 to 2015-10-26 - val d26u0 = "2015-10-26T00:00:00Z~2015-10-27T00:00:00Z/updates_0.zstd" - val d26u1 = "2015-10-26T00:00:00Z~2015-10-27T00:00:00Z/updates_1.zstd" - val moreObjs = Seq( - "2015-10-25T00:00:00Z~2015-10-26T00:00:00Z/ACS_0.zstd", - d26u0, - d26u1, - ) + val d26 = makeObjectKeys("2015-10-26T00:00:00Z~2015-10-27T00:00:00Z") + val moreObjs = makeObjectKeys("2015-10-25T00:00:00Z~2015-10-26T00:00:00Z", "ACS") ++ d26 Future .sequence(moreObjs.map { bucketConnection.createObject(_) @@ -546,25 +545,16 @@ class UpdateHistoryBulkStorageTest ) ) // Query up to the middle of the empty segment - val res5 = reader - .getCommittedUpdatesBetweenDates( - CantonTimestamp.tryFromInstant(Instant.parse("2015-10-20T00:00:00Z")), - CantonTimestamp.tryFromInstant(Instant.parse("2015-10-25T12:00:00Z")), - PageLimit.tryCreate(20), - None, - ) - .futureValue + val res5 = getCommitted("2015-10-20T00:00:00Z", "2015-10-25T12:00:00Z", 20, None).futureValue // First response contains all data, but with a next page token res5.objects.map(_.key) should contain theSameElementsInOrderAs allObjs res5.nextPageTokenO shouldBe Some("2015-10-24T00:00:00Z~2015-10-25T00:00:00Z/") - val res5b = reader - .getCommittedUpdatesBetweenDates( - CantonTimestamp.tryFromInstant(Instant.parse("2015-10-21T00:00:00Z")), - CantonTimestamp.tryFromInstant(Instant.parse("2015-10-25T12:00:00Z")), - PageLimit.tryCreate(20), - res5.nextPageTokenO, - ) - .futureValue + val res5b = getCommitted( + "2015-10-21T00:00:00Z", + "2015-10-25T12:00:00Z", + 20, + res5.nextPageTokenO, + ).futureValue // Second page should be empty, with no nextPageToken res5b.objects.map(_.key) shouldBe empty res5b.nextPageTokenO shouldBe None @@ -596,17 +586,15 @@ class UpdateHistoryBulkStorageTest val store = mock[UpdateHistory] when( store.getUpdatesWithoutImportUpdates( - any[Option[(Long, CantonTimestamp)]], + any[Option[TimestampWithMigrationId]], any[Limit], )(any[TraceContext]) ).thenAnswer { ( - afterO: Option[(Long, CantonTimestamp)], + afterO: Option[TimestampWithMigrationId], limit: Limit, ) => - val after = afterO - .map(a => TimestampWithMigrationId(a._2, a._1)) - .getOrElse(TimestampWithMigrationId(CantonTimestamp.MinValue, 0L)) + val after = afterO.getOrElse(TimestampWithMigrationId(CantonTimestamp.MinValue, 0L)) Future.successful( data .filter(update => diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/ScanHistoryBackfillingTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/ScanHistoryBackfillingTest.scala index 4ced0ea26c..24d2739446 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/ScanHistoryBackfillingTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/ScanHistoryBackfillingTest.scala @@ -22,7 +22,7 @@ class ScanHistoryBackfillingTest extends UpdateHistoryTestBase { "ScanHistoryBackfilling" should { "backfill from one complete history" in { for { - testData <- setup() + testData <- setupTestData() // Backfill backfillingTerminated <- backfillAll( @@ -93,7 +93,7 @@ class ScanHistoryBackfillingTest extends UpdateHistoryTestBase { "backfill from one incomplete history" in { for { - testData <- setup() + testData <- setupTestData() // Backfill part 1 - at this point, the destination history has only replicated up to record time 5 backfillingTerminated1 <- backfillAll( @@ -153,7 +153,7 @@ class ScanHistoryBackfillingTest extends UpdateHistoryTestBase { destinationHistory: UpdateHistory, ) - private def setup(): Future[TestData] = { + private def setupTestData(): Future[TestData] = { val storeA0 = mkStore(domainMigrationId = 0, participantId = participant1) val storeA1 = mkStore(domainMigrationId = 1, participantId = participant1) val storeA2 = mkStore(domainMigrationId = 2, participantId = participant1) diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/AcsSnapshotStoreTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/AcsSnapshotStoreTest.scala index 4c27a6c133..c3574b6cfd 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/AcsSnapshotStoreTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/AcsSnapshotStoreTest.scala @@ -432,7 +432,7 @@ class AcsSnapshotStoreTest def queryRecursive( store: AcsSnapshotStore, - after: Option[Long], + after: Option[AcsSnapshotStore.QueryAcsSnapshotPaginationToken], acc: Vector[String], partyIds: Seq[PartyId], templates: Seq[PackageQualifiedName], diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/DbScanRewardsReferenceStoreTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/DbScanRewardsReferenceStoreTest.scala index 686f647f03..0e2ad85086 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/DbScanRewardsReferenceStoreTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/DbScanRewardsReferenceStoreTest.scala @@ -24,7 +24,14 @@ import org.lfdecentralizedtrust.splice.environment.ledger.api.TreeUpdateOrOffset import org.lfdecentralizedtrust.splice.environment.{DarResources, RetryProvider} import org.lfdecentralizedtrust.splice.scan.store.ScanRewardsReferenceStore import org.lfdecentralizedtrust.splice.scan.store.db.DbScanRewardsReferenceStore -import org.lfdecentralizedtrust.splice.store.{HardLimit, Limit, PageLimit, StoreTestBase, TcsStore} +import org.lfdecentralizedtrust.splice.store.{ + HardLimit, + Limit, + PageLimit, + StoreTestBase, + TcsStore, + TimestampWithMigrationId, +} import org.lfdecentralizedtrust.splice.util.{ResourceTemplateDecoder, TemplateJsonDecoder} import slick.jdbc.JdbcProfile @@ -479,10 +486,16 @@ class DbScanRewardsReferenceStoreTest result.get(ts(275)) shouldBe None // round4.opensAt before earliest archived_at result.get(ts(350)) shouldBe None // round4.opensAt before earliest archived_at result.get(ts(375)) shouldBe None // gap: round4 archived, round5 not yet open - result(ts(400)) shouldBe (5L, ts(400)) + result(ts(400)) shouldBe TimestampWithMigrationId(ts(400), 5L) result.get(ts(401)) shouldBe None // 401 was not present in request - result(ts(450)) shouldBe (5L, ts(400)) // round5 open, round6 not yet open - result(ts(550)) shouldBe (5L, ts(400)) // both open, lowest round selected + result(ts(450)) shouldBe TimestampWithMigrationId( + ts(400), + 5L, + ) // round5 open, round6 not yet open + result(ts(550)) shouldBe TimestampWithMigrationId( + ts(400), + 5L, + ) // both open, lowest round selected } } } diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/ScanStoreTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/ScanStoreTest.scala index 220a8d562c..3d2271e786 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/ScanStoreTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/ScanStoreTest.scala @@ -57,7 +57,6 @@ import scala.concurrent.Future import scala.jdk.CollectionConverters.* import scala.jdk.OptionConverters.* import scala.math.BigDecimal.javaBigDecimal2bigDecimal -import scala.reflect.ClassTag import org.lfdecentralizedtrust.splice.config.IngestionConfig import org.lfdecentralizedtrust.splice.store.MultiDomainAcsStore.IngestionSink.IngestionStart.{ InitializeAcsAtLatestOffset, @@ -332,144 +331,6 @@ abstract class ScanStoreTest } } - "listTransactions" should { - "return the most recent txs in pages" in { - val limit = 10 - val nrTransfers = 20 - val round = 1L - val now = java.time.Instant.EPOCH - val zero = BigDecimal(0) - val fakeOffset = "0" - val txs: List[TransferTxLogEntry] = (1 to nrTransfers).map { i => - TransferTxLogEntry( - offset = fakeOffset, - eventId = s"$i", - domainId = dummyDomain, - date = Some(now), - sender = Some( - SenderAmount( - user1, - BigDecimal(i), - zero, - zero, - zero, - zero, - zero, - zero, - Some(zero), - None, - ) - ), - balanceChanges = Seq(), - receivers = Seq(ReceiverAmount(user2, BigDecimal(i), zero)), - round = round, - ) - }.toList - def stripEventIdAndOffset(tx: TransferTxLogEntry) = - tx.copy(eventId = "", offset = fakeOffset) - val expectedFirstPage = txs.reverse.take(limit).toList - val expectedSecondPage = txs.reverse.drop(limit).take(limit).toList - - def transferFromTransaction( - store: ScanStore, - amuletRulesContract: Contract[ - splice.amuletrules.AmuletRules.ContractId, - splice.amuletrules.AmuletRules, - ], - tx: TransferTxLogEntry, - ) = { - val sender = tx.sender.getOrElse(throw txMissingField()) - val senderParty = sender.party - val senderAmount = sender.inputAmuletAmount - val receiverParty = tx.receivers(0).party - val receiverAmount = tx.receivers(0).amount - dummyDomain - .exercise( - contract = amuletRulesContract, - interfaceId = Some(splice.amuletrules.AmuletRules.TEMPLATE_ID_WITH_PACKAGE_ID), - choiceName = Transfer.choice.name, - choiceArgument = mkAmuletRules_Transfer( - mkTransferInputOutput( - senderParty, - senderParty, - List(mkInputAmulet()), - List(mkTransferOutput(receiverParty, receiverAmount)), - ) - ), - exerciseResult = mkTransferResultRecord( - round = round, - inputAppRewardAmount = sender.inputAppRewardAmount.toDouble, - inputAmuletAmount = senderAmount.toDouble, - inputValidatorRewardAmount = sender.inputValidatorRewardAmount.toDouble, - inputSvRewardAmount = sender.inputSvRewardAmount.fold(0.0)(_.toDouble), - balanceChanges = Map(), - amuletPrice = 1.0, - ), - )( - store.multiDomainAcsStore - ) - .map(_ => ()) - } - - for { - store <- mkStore() - amuletRulesContract = amuletRules() - _ <- txs.foldLeft(Future.successful(())) { (f, tx) => - f.flatMap { _ => - transferFromTransaction( - store, - amuletRulesContract, - tx, - ) - } - } - } yield { - val firstPageDescending = store - .listByType[TransferTxLogEntry](None, SortOrder.Descending, limit) - .futureValue - .toList - - firstPageDescending - .map(stripEventIdAndOffset) should be( - expectedFirstPage - .map(stripEventIdAndOffset) - ) - val nextPageDescending = store - .listByType[TransferTxLogEntry]( - Some(firstPageDescending.last.eventId), - SortOrder.Descending, - limit, - ) - .futureValue - .toList - - nextPageDescending - .map(stripEventIdAndOffset) should be( - expectedSecondPage - .map(stripEventIdAndOffset) - ) - - val firstPageAscending = store - .listByType[TransferTxLogEntry](None, SortOrder.Ascending, limit) - .futureValue - .toList - - firstPageAscending should be(nextPageDescending.reverse) - - val nextPageAscending = store - .listByType[TransferTxLogEntry]( - Some(firstPageAscending.last.eventId), - SortOrder.Ascending, - limit, - ) - .futureValue - .toList - - nextPageAscending should be(firstPageDescending.reverse) - } - } - } - "votes" should { "listVoteRequestResults" should { @@ -515,19 +376,11 @@ abstract class ScanStoreTest _ <- closeVoteRequest(store, 2) _ <- closeVoteRequest(store, 1) page1 <- store.listVoteRequestResults( - None, - None, - None, - None, - None, + VoteResultsFilters(), PageLimit.tryCreate(3), ) page2 <- store.listVoteRequestResults( - None, - None, - None, - None, - None, + VoteResultsFilters(), PageLimit.tryCreate(3), page1.nextPageToken, ) @@ -585,19 +438,11 @@ abstract class ScanStoreTest _ <- closeVoteRequest(store, 2) _ <- closeVoteRequest(store, 6) page1 <- store.listVoteRequestResults( - None, - None, - None, - None, - None, + VoteResultsFilters(), PageLimit.tryCreate(3), ) page2 <- store.listVoteRequestResults( - None, - None, - None, - None, - None, + VoteResultsFilters(), PageLimit.tryCreate(3), page1.nextPageToken, ) @@ -610,6 +455,60 @@ abstract class ScanStoreTest } } + "countVoteRequestResults" should { + + "count vote results matching the filters" in { + val base = Instant.parse("2024-03-01T10:00:00Z") + val accepted = Set(1, 3, 4, 6) + def sortKeyAt(n: Int) = base.plusSeconds(n.toLong) + def recordTime(n: Int) = base.plusSeconds(100L + n.toLong) + val voteRequests = (1 to 6).map { n => + voteRequest( + requester = userParty(n), + votes = Seq( + new Vote(userParty(n).toProtoPrimitive, true, new Reason("", ""), Optional.empty()) + ), + ) + } + val results = + (1 to 6).map(n => + if (accepted(n)) mkVoteRequestResult(voteRequests(n - 1), effectiveAt = sortKeyAt(n)) + else mkRejectedVoteRequestResult(voteRequests(n - 1), completedAt = sortKeyAt(n)) + ) + def closeVoteRequest(store: ScanStore, n: Int) = + dummyDomain.exercise( + contract = dsoRules(dsoParty), + interfaceId = Some(DsoRules.TEMPLATE_ID_WITH_PACKAGE_ID), + choiceName = DsoRulesCloseVoteRequest.choice.name, + choiceArgument = mkCloseVoteRequest(voteRequests(n - 1).contractId), + exerciseResult = results(n - 1).toValue, + recordTime = recordTime(n), + )(store.multiDomainAcsStore) + for { + store <- mkStore() + _ <- MonadUtil.sequentialTraverse(voteRequests)( + dummyDomain.create(_)(store.multiDomainAcsStore) + ) + _ <- MonadUtil.sequentialTraverse(1 to 6)(closeVoteRequest(store, _)) + total <- store.countVoteRequestResults(VoteResultsFilters()) + acceptedCount <- store.countVoteRequestResults( + VoteResultsFilters(accepted = Some(true)) + ) + rejectedCount <- store.countVoteRequestResults( + VoteResultsFilters(accepted = Some(false)) + ) + requesterCount <- store.countVoteRequestResults( + VoteResultsFilters(requester = Some(userParty(1).toProtoPrimitive)) + ) + } yield { + total shouldBe 6L + acceptedCount shouldBe 4L + rejectedCount shouldBe 2L + requesterCount shouldBe 1L + } + } + } + "lookupLatestSvRewardWeightChange" should { "return the weight of the latest accepted UpdateSvRewardWeight before the given time" in { @@ -1302,11 +1201,7 @@ abstract class ScanStoreTest } yield { store .listVoteRequestResults( - Some("AddSv"), - Some(true), - None, - None, - None, + VoteResultsFilters(actionName = Some("AddSv"), accepted = Some(true)), PageLimit.tryCreate(1), ) .futureValue @@ -1315,11 +1210,7 @@ abstract class ScanStoreTest .loneElement shouldBe result2 store .listVoteRequestResults( - Some("SRARC_AddSv"), - Some(false), - None, - None, - None, + VoteResultsFilters(actionName = Some("SRARC_AddSv"), accepted = Some(false)), PageLimit.tryCreate(1), ) .futureValue @@ -1328,11 +1219,7 @@ abstract class ScanStoreTest .size shouldBe (0) store .listVoteRequestResults( - None, - None, - None, - None, - None, + VoteResultsFilters(), PageLimit.tryCreate(1), ) .futureValue @@ -1341,11 +1228,9 @@ abstract class ScanStoreTest .size shouldBe (1) store .listVoteRequestResults( - None, - None, - None, - Some(Instant.now().truncatedTo(ChronoUnit.MICROS).plusSeconds(3600).toString), - None, + VoteResultsFilters(effectiveFrom = + Some(Instant.now().truncatedTo(ChronoUnit.MICROS).plusSeconds(3600).toString) + ), PageLimit.tryCreate(1), ) .futureValue @@ -1354,11 +1239,9 @@ abstract class ScanStoreTest .size shouldBe (0) store .listVoteRequestResults( - None, - None, - None, - Some(Instant.now().truncatedTo(ChronoUnit.MICROS).minusSeconds(3600).toString), - None, + VoteResultsFilters(effectiveFrom = + Some(Instant.now().truncatedTo(ChronoUnit.MICROS).minusSeconds(3600).toString) + ), PageLimit.tryCreate(1), ) .futureValue @@ -1379,20 +1262,6 @@ abstract class ScanStoreTest ): Future[UpdateHistory] private lazy val user1 = userParty(1) - private lazy val user2 = userParty(2) - - implicit class ScanStoreExt(store: ScanStore) { - @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf")) - def listByType[T](beginAfterEventId: Option[String], sortOrder: SortOrder, limit: Int)(implicit - tag: ClassTag[T] - ): Future[Seq[T]] = { - store - .listTransactions(beginAfterEventId, sortOrder, PageLimit.tryCreate(limit)) - .map(_.collect { - case c if tag.runtimeClass.isInstance(c) => c.asInstanceOf[T] - }.toSeq) - } - } } trait AmuletTransferUtil { self: StoreTestBase => def mkInputAmulet() = { @@ -2014,11 +1883,7 @@ class DbScanStoreTest // because ingestion in these store tests is simulated by directly interacting with the ingestion sink storeReingest .listVoteRequestResults( - Some("AddSv"), - Some(true), - None, - None, - None, + VoteResultsFilters(actionName = Some("AddSv"), accepted = Some(true)), PageLimit.tryCreate(1), ) .futureValue diff --git a/apps/splitwell/src/main/scala/org/lfdecentralizedtrust/splice/splitwell/metrics/SplitwellAppMetrics.scala b/apps/splitwell/src/main/scala/org/lfdecentralizedtrust/splice/splitwell/metrics/SplitwellAppMetrics.scala index 0e091cf30c..44e266e0d8 100644 --- a/apps/splitwell/src/main/scala/org/lfdecentralizedtrust/splice/splitwell/metrics/SplitwellAppMetrics.scala +++ b/apps/splitwell/src/main/scala/org/lfdecentralizedtrust/splice/splitwell/metrics/SplitwellAppMetrics.scala @@ -14,6 +14,6 @@ import com.digitalasset.canton.metrics.DbStorageHistograms */ class SplitwellAppMetrics( metricsFactory: LabeledMetricsFactory, - storageHistograms: DbStorageHistograms, + histograms: DbStorageHistograms, loggerFactory: NamedLoggerFactory, -) extends BaseSpliceMetrics("splitwell", metricsFactory, storageHistograms, loggerFactory) {} +) extends BaseSpliceMetrics("splitwell", metricsFactory, histograms, loggerFactory) {} diff --git a/apps/sv/frontend/index.html b/apps/sv/frontend/index.html index 8cf03993b2..32252600a0 100644 --- a/apps/sv/frontend/index.html +++ b/apps/sv/frontend/index.html @@ -5,16 +5,13 @@ + - + diff --git a/apps/sv/frontend/src/App.tsx b/apps/sv/frontend/src/App.tsx index 2ec65affcb..812a5385cf 100644 --- a/apps/sv/frontend/src/App.tsx +++ b/apps/sv/frontend/src/App.tsx @@ -39,6 +39,7 @@ import { useConfigPollInterval, useSvConfig } from './utils'; import { Governance } from './routes/governance'; import { VoteRequestDetails } from './routes/voteRequestDetails'; import { CreateProposal } from './routes/createProposal'; +import DelegateElection from './routes/delegateElection'; const Providers: React.FC = ({ children }) => { const config = useSvConfig(); @@ -95,6 +96,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> } /> } /> @@ -111,8 +113,8 @@ const App: React.FC = () => { - Super Validator Operations - + Supervalidator Operations + diff --git a/apps/sv/frontend/src/__tests__/components/copyable-identifier.test.tsx b/apps/sv/frontend/src/__tests__/components/copyable-identifier.test.tsx new file mode 100644 index 0000000000..90a415352d --- /dev/null +++ b/apps/sv/frontend/src/__tests__/components/copyable-identifier.test.tsx @@ -0,0 +1,159 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { GlobalStyles, ThemeProvider } from '@mui/material'; +import { PartyId, theme } from '@canton-network/splice-common-frontend'; +import { describe, expect, test } from 'vitest'; + +import CopyableIdentifier from '../../components/beta/CopyableIdentifier'; +import MemberIdentifier from '../../components/beta/MemberIdentifier'; +import { partyIdScrollGlobalStyles } from '../../components/beta/identifierStyles'; +import PartyIdScrollTracks from '../../components/PartyIdScrollTracks'; + +const LONG_CONTRACT_ID = + '00deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + +const LONG_PARTY_ID = `digital-asset-2::12200eab17c2b87a3da9f7b3b81d371ff794a4515fa3a0b258422a251d6148b031d`; + +const NarrowContainer: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +

{children}
+); + +describe('CopyableIdentifier', () => { + test('renders the full contract ID for horizontal scrolling', () => { + render(); + + expect(screen.getByTestId('contract-id-value')).toHaveTextContent(LONG_CONTRACT_ID); + expect(screen.getByTestId('contract-id-scroll')).toHaveStyle({ overflowX: 'auto' }); + }); + + test('keeps the copy button adjacent to short identifiers', () => { + render( +
+ +
+ ); + + expect(screen.getByTestId('short-id')).toHaveStyle({ + display: 'inline-flex', + width: 'fit-content', + }); + }); + + test('compact scroll keeps #1785 scrolling at the Figma width', async () => { + render( + + + + ); + + const scroll = screen.getByTestId('contract-id-scroll'); + expect(scroll).toHaveStyle({ overflowX: 'auto', maxWidth: '270px' }); + expect(screen.getByTestId('contract-id-value')).toHaveTextContent(LONG_CONTRACT_ID); + + Object.defineProperty(scroll, 'scrollWidth', { configurable: true, value: 400 }); + Object.defineProperty(scroll, 'clientWidth', { configurable: true, value: 100 }); + Object.defineProperty(scroll, 'scrollLeft', { configurable: true, value: 0 }); + fireEvent.scroll(scroll); + + await waitFor(() => { + expect(screen.queryByTestId('contract-id-ellipsis-cue')).not.toBeInTheDocument(); + expect(screen.getByTestId('contract-id-scroll-track')).toBeInTheDocument(); + }); + }); + + test('fullWidth fills the parent and keeps scrolling', () => { + render( +
+ +
+ ); + + expect(screen.getByTestId('contract-id')).toHaveStyle({ width: '100%', display: 'flex' }); + expect(screen.getByTestId('contract-id-scroll')).toHaveStyle({ overflowX: 'auto' }); + expect(screen.getByTestId('contract-id-value')).toHaveTextContent(LONG_CONTRACT_ID); + }); + + test('trims long identifiers to the Figma ellipsis width without a narrow parent', () => { + render( + + ); + + expect(screen.getByTestId('contract-id-value')).toHaveTextContent(LONG_CONTRACT_ID); + expect(screen.getByTestId('contract-id-value')).toHaveAttribute('title', LONG_CONTRACT_ID); + expect(screen.getByTestId('contract-id-ellipsis')).toHaveStyle({ + overflow: 'hidden', + maxWidth: '270px', + }); + expect(screen.getByTestId('contract-id-value')).toHaveStyle({ textOverflow: 'ellipsis' }); + }); +}); + +describe('MemberIdentifier', () => { + test('renders the full party ID instead of an abbreviated preview', () => { + render( + + ); + + expect(screen.getByTestId('member-value')).toHaveTextContent(LONG_PARTY_ID); + expect(screen.getByTestId('member-value')).not.toHaveTextContent('...'); + expect(screen.getByTestId('member-scroll')).toHaveStyle({ overflowX: 'auto' }); + }); + + test('supports ellipsis overflow for compact layouts', () => { + render( + + + + ); + + expect(screen.getByTestId('member-value')).toHaveTextContent(LONG_PARTY_ID); + expect(screen.getByTestId('member-value')).toHaveAttribute('title', LONG_PARTY_ID); + expect(screen.getByTestId('member-ellipsis')).toHaveStyle({ overflow: 'hidden' }); + expect(screen.getByTestId('member-value')).toHaveStyle({ textOverflow: 'ellipsis' }); + expect(screen.queryByTestId('member-scroll')).not.toBeInTheDocument(); + }); +}); + +describe('common PartyId', () => { + test('does not ellipsize the party ID when SV scroll styles are applied', async () => { + render( + + + + + + + + ); + + const input = await screen.findByTestId('sv-party-id-input'); + const partyIdRoot = input.closest('.party-id'); + + expect(input).toHaveDisplayValue(LONG_PARTY_ID); + expect(input).toHaveStyle({ textOverflow: 'clip' }); + expect(partyIdRoot).toHaveClass('identifier-scroll-area'); + expect(partyIdRoot?.querySelector('.party-id-scroll-track')).not.toBeNull(); + }); +}); diff --git a/apps/sv/frontend/src/__tests__/governance/action-required-section.test.tsx b/apps/sv/frontend/src/__tests__/governance/action-required-section.test.tsx index 8a72cf3096..b5bd2e3e19 100644 --- a/apps/sv/frontend/src/__tests__/governance/action-required-section.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/action-required-section.test.tsx @@ -11,24 +11,27 @@ import { VoteRequest } from '@daml.js/splice-dso-governance/lib/Splice/DsoRules' import { MemoryRouter } from 'react-router'; import dayjs from 'dayjs'; import { dateTimeFormatISO } from '@canton-network/splice-common-frontend-utils'; +import { svPartyId, voteRequests } from '../mocks/constants'; + +const sampleContractId = voteRequests.dso_rules_vote_requests[0] + .contract_id as ContractId; const requests: ActionRequiredData[] = [ { actionName: 'Feature Application', description: 'Test description for feature application', - contractId: '2abcde123456' as ContractId, + contractId: sampleContractId, votingCloses: '2024-09-25 11:00', createdAt: '2024-09-25 11:00', - requester: 'sv1', + requester: svPartyId, }, { actionName: 'Set DSO Rules Configuration', description: 'Test description for DSO rules configuration', - contractId: '2bcde123456' as ContractId, + contractId: voteRequests.dso_rules_vote_requests[1].contract_id as ContractId, votingCloses: '2024-09-25 11:00', createdAt: '2024-09-25 11:00', - requester: 'sv2', - isYou: true, + requester: svPartyId, }, ]; @@ -76,10 +79,10 @@ describe('Action Required', () => { const actionRequired = { actionName: 'Feature Application', description: 'Test description', - contractId: '2abcde123456' as ContractId, + contractId: sampleContractId, votingCloses: closesDate, createdAt: createdDate, - requester: 'sv1', + requester: svPartyId, }; render( @@ -104,23 +107,22 @@ describe('Action Required', () => { expect(votingCloses).toBeInTheDocument(); expect(votingCloses.textContent).toBe('10 days'); - const requester = screen.getByTestId('action-required-requester-identifier-value'); - expect(requester).toBeInTheDocument(); - expect(requester.textContent).toBe(actionRequired.requester); + const submittedBy = screen.getByTestId('action-required-submitted-by-identifier-value'); + expect(submittedBy).toBeInTheDocument(); + expect(submittedBy.textContent).toBe(svPartyId); const viewDetails = screen.getByTestId('action-required-view-details'); expect(viewDetails).toBeInTheDocument(); }); - test('should render isYou badge for requests created by viewing sv', () => { + test('should render submitted by with copy button and no You badge', () => { const actionRequired = { actionName: 'Feature Application', description: 'Test description', - contractId: '2abcde123456' as ContractId, + contractId: sampleContractId, votingCloses: '2029-09-25 11:00', createdAt: '2029-09-25 11:00', - requester: 'sv1', - isYou: true, + requester: svPartyId, }; render( @@ -129,18 +131,22 @@ describe('Action Required', () => { ); - const isYou = screen.getByTestId('action-required-requester-identifier-badge'); - expect(isYou).toBeInTheDocument(); + expect( + screen.getByTestId('action-required-submitted-by-identifier-copy-button') + ).toBeInTheDocument(); + expect( + screen.queryByTestId('action-required-submitted-by-identifier-badge') + ).not.toBeInTheDocument(); }); - test('should not render isYou badge for requests created by other svs', () => { + test('should render vote proposal contract id with full value', () => { const actionRequired = { actionName: 'Feature Application', description: 'Test description', - contractId: '2abcde123456' as ContractId, + contractId: sampleContractId, votingCloses: '2029-09-25 11:00', createdAt: '2029-09-25 11:00', - requester: 'sv1', + requester: svPartyId, }; render( @@ -149,8 +155,8 @@ describe('Action Required', () => { ); - const isYou = screen.queryByTestId('action-required-requester-identifier-badge'); - - expect(isYou).not.toBeInTheDocument(); + expect(screen.getByTestId('action-required-contract-id-value').textContent).toBe( + sampleContractId + ); }); }); diff --git a/apps/sv/frontend/src/__tests__/governance/create-proposal.test.tsx b/apps/sv/frontend/src/__tests__/governance/create-proposal.test.tsx index 599a7fcbb3..d85fb78cdc 100644 --- a/apps/sv/frontend/src/__tests__/governance/create-proposal.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/create-proposal.test.tsx @@ -38,11 +38,8 @@ async function checkActionSelection(actionName: string, actionValue: string, tes const selectInput = actionDropdown.querySelector('[role="combobox"]') as HTMLElement; await user.click(selectInput); - await waitFor(async () => { - const actionToSelect = screen.getByText(actionName); - expect(actionToSelect).toBeInTheDocument(); - await user.click(actionToSelect); - }); + const actionToSelect = await screen.findByText(actionName); + await user.click(actionToSelect); const nextButton = screen.getByText('Next'); expect(nextButton).toBeInTheDocument(); @@ -92,9 +89,6 @@ describe('Create Proposal', () => { ); - const actionSelectionTitle = screen.getByText('Select an Action'); - expect(actionSelectionTitle).toBeDefined(); - const actionDropdown = screen.getByTestId('select-action'); expect(actionDropdown).toBeDefined(); @@ -194,20 +188,19 @@ describe('Create Proposal', () => { const nextButton = screen.getByText('Next'); expect(nextButton).toBeDefined(); - expect(nextButton.getAttribute('disabled')).toBeDefined(); + expect(nextButton.getAttribute('disabled')).not.toBeNull(); const actionDropdown = screen.getByTestId('select-action'); expect(actionDropdown).toBeDefined(); const selectInput = actionDropdown.querySelector('[role="combobox"]') as HTMLElement; - user.click(selectInput); + await user.click(selectInput); + + const actionToSelect = await screen.findByText('Offboard Member'); + await user.click(actionToSelect); await waitFor(() => { - const actionToSelect = screen.getByText('Offboard Member'); - expect(actionToSelect).toBeDefined(); - user.click(actionToSelect); + expect(nextButton.getAttribute('disabled')).toBeNull(); }); - - expect(nextButton.getAttribute('disabled')).toBe(''); }); }); diff --git a/apps/sv/frontend/src/__tests__/governance/forms/create-unallocated-unclaimed-activity-form.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/create-unallocated-unclaimed-activity-form.test.tsx index bb124965f5..67858a367d 100644 --- a/apps/sv/frontend/src/__tests__/governance/forms/create-unallocated-unclaimed-activity-form.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/forms/create-unallocated-unclaimed-activity-form.test.tsx @@ -10,7 +10,11 @@ import { describe, expect, test } from 'vitest'; import App from '../../../App'; import { CreateUnallocatedUnclaimedActivityRecordForm } from '../../../components/forms/CreateUnallocatedUnclaimedActivityRecordForm'; import { SvConfigProvider } from '../../../utils'; -import { PROPOSAL_SUMMARY_SUBTITLE, PROPOSAL_SUMMARY_TITLE } from '../../../utils/constants'; +import { + CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + PROPOSAL_REVIEW_TITLE, + PROPOSAL_SUMMARY_SUBTITLE, +} from '../../../utils/constants'; import { Wrapper } from '../../helpers'; import { svPartyId } from '../../mocks/constants'; import { server, svUrl } from '../../setup/setup'; @@ -32,7 +36,7 @@ describe('SV user can', () => { const button = screen.getByRole('button', { name: 'Log In' }); await user.click(button); - expect(await screen.findAllByDisplayValue(svPartyId)).not.toBe([]); + expect(await screen.findAllByDisplayValue(svPartyId)).not.toHaveLength(0); }); }); @@ -47,7 +51,7 @@ describe('Create Unallocated Unclaimed Activity Record Form', () => { expect( screen.getByTestId('create-unallocated-unclaimed-activity-record-form') ).toBeInTheDocument(); - expect(screen.getByText('Proposal type')).toBeInTheDocument(); + expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument(); const actionInput = screen.getByTestId('create-unallocated-unclaimed-activity-record-action'); expect(actionInput).toBeInTheDocument(); @@ -99,7 +103,7 @@ describe('Create Unallocated Unclaimed Activity Record Form', () => { expect(submitButton).toBeInTheDocument(); await user.click(submitButton); - expect(submitButton.getAttribute('disabled')).toBeDefined(); + expect(submitButton.getAttribute('disabled')).not.toBeNull(); await expect(async () => await user.click(submitButton)).rejects.toThrowError( /Unable to perform pointer interaction/ ); @@ -368,7 +372,7 @@ describe('Create Unallocated Unclaimed Activity Record Form', () => { await user.click(submitButton); - expect(screen.getByText(PROPOSAL_SUMMARY_TITLE)).toBeInTheDocument(); + expect(screen.getByText(PROPOSAL_REVIEW_TITLE)).toBeInTheDocument(); }); test('should show error on form if submission fails', async () => { diff --git a/apps/sv/frontend/src/__tests__/governance/forms/grant-revoke-featured-app-form.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/grant-revoke-featured-app-form.test.tsx index e13c9d4aaf..aaca03db31 100644 --- a/apps/sv/frontend/src/__tests__/governance/forms/grant-revoke-featured-app-form.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/forms/grant-revoke-featured-app-form.test.tsx @@ -13,7 +13,12 @@ import dayjs from 'dayjs'; import { GrantRevokeFeaturedAppForm } from '../../../components/forms/GrantRevokeFeaturedAppForm'; import { server, svUrl } from '../../setup/setup'; import { http, HttpResponse } from 'msw'; -import { PROPOSAL_SUMMARY_SUBTITLE, PROPOSAL_SUMMARY_TITLE } from '../../../utils/constants'; +import { + CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + CREATE_PROPOSAL_LABEL_PROVIDER_PARTY_ID, + PROPOSAL_REVIEW_TITLE, + PROPOSAL_SUMMARY_SUBTITLE, +} from '../../../utils/constants'; describe('SV user can', () => { test('login and see the SV party ID', async () => { @@ -32,7 +37,7 @@ describe('SV user can', () => { const button = screen.getByRole('button', { name: 'Log In' }); await user.click(button); - expect(await screen.findAllByDisplayValue(svPartyId)).not.toBe([]); + expect(await screen.findAllByDisplayValue(svPartyId)).not.toHaveLength(0); }); }); @@ -45,7 +50,7 @@ describe('Grant Featured App Form', () => { ); expect(screen.getByTestId('grant-featured-app-form')).toBeInTheDocument(); - expect(screen.getByText('Proposal type')).toBeInTheDocument(); + expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument(); const actionInput = screen.getByTestId('grant-featured-app-action'); expect(actionInput).toBeInTheDocument(); @@ -69,7 +74,7 @@ describe('Grant Featured App Form', () => { const providerInput = screen.getByTestId('grant-featured-app-idValue-title'); expect(providerInput).toBeInTheDocument(); - expect(providerInput.textContent).toBe('Provider Party ID'); + expect(providerInput.textContent).toBe(CREATE_PROPOSAL_LABEL_PROVIDER_PARTY_ID); expect(screen.getByText('Review Proposal')).toBeInTheDocument(); }); @@ -212,7 +217,142 @@ describe('Grant Featured App Form', () => { await user.click(submitButton); - expect(screen.getByText(PROPOSAL_SUMMARY_TITLE)).toBeInTheDocument(); + expect(screen.getByText(PROPOSAL_REVIEW_TITLE)).toBeInTheDocument(); + }); + + test('activity weight is optional and is sent to backend as null when left blank', async () => { + let requestBody = ''; + server.use( + http.post(`${svUrl}/v0/admin/sv/voterequest/create`, async ({ request }) => { + requestBody = await request.text(); + return HttpResponse.json({}); + }) + ); + + const user = userEvent.setup(); + + render( + + + + ); + + const activityWeightInput = screen.getByTestId('grant-featured-app-activityWeight'); + expect(activityWeightInput.getAttribute('value')).toBe(''); + + const actionInput = screen.getByTestId('grant-featured-app-action'); + const submitButton = screen.getByTestId('submit-button'); + + const summaryInput = screen.getByTestId('grant-featured-app-summary'); + await user.type(summaryInput, 'Summary of the proposal'); + + const urlInput = screen.getByTestId('grant-featured-app-url'); + await user.type(urlInput, 'https://example.com'); + + const providerInput = screen.getByTestId('grant-featured-app-idValue'); + await user.type(providerInput, 'a-party-id::1014912492'); + + await user.click(activityWeightInput); + + await user.click(actionInput); // using this to trigger the onBlur event which triggers the validation + + await waitFor(() => { + expect(screen.queryByText('Validating provider...')).not.toBeInTheDocument(); + }); + + await waitFor(async () => { + expect(submitButton).not.toBeDisabled(); + }); + + await user.click(submitButton); // review proposal + + expect(screen.getByTestId('grantRightActivityWeight-field').textContent).toBe(''); + + await user.click(submitButton); // submit proposal + + await waitFor(() => { + expect(requestBody).toContain('"activityWeight":null'); + }); + }); + + test('should send explicit activity weight to backend when provided', async () => { + let requestBody = ''; + server.use( + http.post(`${svUrl}/v0/admin/sv/voterequest/create`, async ({ request }) => { + requestBody = await request.text(); + return HttpResponse.json({}); + }) + ); + + const user = userEvent.setup(); + + render( + + + + ); + + const actionInput = screen.getByTestId('grant-featured-app-action'); + const submitButton = screen.getByTestId('submit-button'); + + const summaryInput = screen.getByTestId('grant-featured-app-summary'); + await user.type(summaryInput, 'Summary of the proposal'); + + const urlInput = screen.getByTestId('grant-featured-app-url'); + await user.type(urlInput, 'https://example.com'); + + const providerInput = screen.getByTestId('grant-featured-app-idValue'); + await user.type(providerInput, 'a-party-id::1014912492'); + + const activityWeightInput = screen.getByTestId('grant-featured-app-activityWeight'); + await user.type(activityWeightInput, '2.5'); + + await user.click(actionInput); // using this to trigger the onBlur event which triggers the validation + + await waitFor(() => { + expect(screen.queryByText('Validating provider...')).not.toBeInTheDocument(); + }); + + await waitFor(async () => { + expect(submitButton).not.toBeDisabled(); + }); + + await user.click(submitButton); // review proposal + await user.click(submitButton); // submit proposal + + await waitFor(() => { + expect(requestBody).toContain('"activityWeight":"2.5"'); + }); + }); + + test('activity weight rejects negative numbers and more than 10 decimal places', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const activityWeightInput = screen.getByTestId('grant-featured-app-activityWeight'); + const activityWeightError = screen.getByTestId('grant-featured-app-activityWeight-error'); + + await user.type(activityWeightInput, '-1'); + await waitFor(() => { + expect(activityWeightError.textContent).toBe('Weight must be a valid non-negative number'); + }); + + await user.clear(activityWeightInput); + await user.type(activityWeightInput, '1.1234567891'); + await waitFor(() => { + expect(activityWeightError.textContent).toBe(''); + }); + + await user.clear(activityWeightInput); + await user.type(activityWeightInput, '1.12345678912'); + await waitFor(() => { + expect(activityWeightError.textContent).toBe('Weight can have at most 10 decimal places'); + }); }); }); @@ -229,7 +369,9 @@ describe('Revoke Featured App Form', () => { fireEvent.blur(partyIdInput); await waitFor(() => { - expect(screen.queryByText('Loading featured app rights...')).not.toBeInTheDocument(); + expect( + screen.queryByText('Loading Featured Application Contract IDs...') + ).not.toBeInTheDocument(); }); const rightCidDropdown = screen.getByTestId('revoke-featured-app-rightCid-dropdown'); @@ -254,7 +396,7 @@ describe('Revoke Featured App Form', () => { ); expect(screen.getByTestId('revoke-featured-app-form')).toBeInTheDocument(); - expect(screen.getByText('Proposal type')).toBeInTheDocument(); + expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument(); const actionInput = screen.getByTestId('revoke-featured-app-action'); expect(actionInput).toBeInTheDocument(); @@ -274,7 +416,7 @@ describe('Revoke Featured App Form', () => { const partyIdTitle = screen.getByTestId('revoke-featured-app-partyId-title'); expect(partyIdTitle).toBeInTheDocument(); - expect(partyIdTitle.textContent).toBe('Provider Party ID'); + expect(partyIdTitle.textContent).toBe(CREATE_PROPOSAL_LABEL_PROVIDER_PARTY_ID); const rightCidDropdown = screen.getByTestId('revoke-featured-app-rightCid-dropdown'); expect(rightCidDropdown).toBeInTheDocument(); @@ -296,12 +438,12 @@ describe('Revoke Featured App Form', () => { await waitFor(() => { expect(screen.getByTestId('revoke-featured-app-rightCid')).toHaveTextContent( - 'No featured application rights found for this provider' + 'No Featured Application Contract IDs found for this provider' ); }); expect(screen.getByTestId('revoke-featured-app-rightCid-error')).not.toHaveTextContent( - 'No featured application rights found for this provider' + 'No Featured Application Contract IDs found for this provider' ); expect(screen.getByTestId('revoke-featured-app-rightCid-dropdown')).toBeDisabled(); @@ -407,7 +549,7 @@ describe('Revoke Featured App Form', () => { await user.click(submitButton); - expect(screen.getByText(PROPOSAL_SUMMARY_TITLE)).toBeInTheDocument(); + expect(screen.getByText(PROPOSAL_REVIEW_TITLE)).toBeInTheDocument(); expect(screen.getByTestId('revokeProviderPartyId-title').textContent).toBe('Provider Party ID'); expect(screen.getByTestId('revokeProviderPartyId-field').textContent).toBe( 'a-party-id::1014912492' diff --git a/apps/sv/frontend/src/__tests__/governance/forms/offboard-sv-form.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/offboard-sv-form.test.tsx index 0118ba544f..48723ab78f 100644 --- a/apps/sv/frontend/src/__tests__/governance/forms/offboard-sv-form.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/forms/offboard-sv-form.test.tsx @@ -13,7 +13,13 @@ import dayjs from 'dayjs'; import { OffboardSvForm } from '../../../components/forms/OffboardSvForm'; import { server, svUrl } from '../../setup/setup'; import { http, HttpResponse } from 'msw'; -import { PROPOSAL_SUMMARY_SUBTITLE, PROPOSAL_SUMMARY_TITLE } from '../../../utils/constants'; +import { + CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + PROPOSAL_REVIEW_TITLE, + PROPOSAL_SUMMARY_PLACEHOLDER, + PROPOSAL_SUMMARY_SUBTITLE, + URL_PLACEHOLDER, +} from '../../../utils/constants'; describe('SV user can', () => { test('login and see the SV party ID', async () => { @@ -32,7 +38,7 @@ describe('SV user can', () => { const button = screen.getByRole('button', { name: 'Log In' }); await user.click(button); - expect(await screen.findAllByDisplayValue(svPartyId)).not.toBe([]); + expect(await screen.findAllByDisplayValue(svPartyId)).not.toHaveLength(0); }); }); @@ -45,7 +51,7 @@ describe('Offboard SV Form', () => { ); expect(screen.getByTestId('offboard-sv-form')).toBeInTheDocument(); - expect(screen.getByText('Proposal type')).toBeInTheDocument(); + expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument(); const actionInput = screen.getByTestId('offboard-sv-action'); expect(actionInput).toBeInTheDocument(); @@ -54,6 +60,7 @@ describe('Offboard SV Form', () => { const summaryInput = screen.getByTestId('offboard-sv-summary'); expect(summaryInput).toBeInTheDocument(); expect(summaryInput.getAttribute('value')).toBeNull(); + expect(summaryInput.getAttribute('placeholder')).toBe(PROPOSAL_SUMMARY_PLACEHOLDER); const summarySubtitle = screen.getByTestId('offboard-sv-summary-subtitle'); expect(summarySubtitle).toBeInTheDocument(); @@ -62,10 +69,12 @@ describe('Offboard SV Form', () => { const urlInput = screen.getByTestId('offboard-sv-url'); expect(urlInput).toBeInTheDocument(); expect(urlInput.getAttribute('value')).toBe(''); + expect(urlInput).toHaveAttribute('placeholder', URL_PLACEHOLDER); const memberInput = screen.getByTestId('offboard-sv-member-dropdown'); expect(memberInput).toBeInTheDocument(); expect(memberInput.getAttribute('value')).toBe(''); + expect(screen.getByText('Select a member')).toBeInTheDocument(); expect(screen.getByText('Review Proposal')).toBeInTheDocument(); }); @@ -85,7 +94,7 @@ describe('Offboard SV Form', () => { expect(screen.getByText('Review Proposal')).toBeInTheDocument(); await user.click(submitButton); - expect(submitButton.getAttribute('disabled')).toBeDefined(); + expect(submitButton.getAttribute('disabled')).not.toBeNull(); await expect(async () => await user.click(submitButton)).rejects.toThrowError( /Unable to perform pointer interaction/ ); @@ -109,11 +118,8 @@ describe('Offboard SV Form', () => { const selectInput = screen.getByRole('combobox'); fireEvent.mouseDown(selectInput); - await waitFor(async () => { - const memberToSelect = screen.getByText('Digital-Asset-Eng-2'); - expect(memberToSelect).toBeInTheDocument(); - await user.click(memberToSelect); - }); + const memberToSelect = await screen.findByText('Digital-Asset-Eng-2'); + await user.click(memberToSelect); await user.click(actionInput); // using this to trigger the onBlur event which triggers the validation @@ -221,10 +227,8 @@ describe('Offboard SV Form', () => { const selectInput = screen.getByRole('combobox'); fireEvent.mouseDown(selectInput); - await waitFor(async () => { - const memberToSelect = screen.getByText('Digital-Asset-Eng-2'); - await user.click(memberToSelect); - }); + const memberToSelect = await screen.findByText('Digital-Asset-Eng-2'); + await user.click(memberToSelect); expect(screen.getByText('Review Proposal')).toBeInTheDocument(); const submitButton = screen.getByTestId('submit-button'); @@ -236,7 +240,7 @@ describe('Offboard SV Form', () => { await user.click(submitButton); - expect(screen.getByText(PROPOSAL_SUMMARY_TITLE)).toBeInTheDocument(); + expect(screen.getByText(PROPOSAL_REVIEW_TITLE)).toBeInTheDocument(); }); test('should show error on form if submission fails', async () => { @@ -265,10 +269,8 @@ describe('Offboard SV Form', () => { const selectInput = screen.getByRole('combobox'); fireEvent.mouseDown(selectInput); - await waitFor(async () => { - const memberToSelect = screen.getByText('Digital-Asset-Eng-2'); - await user.click(memberToSelect); - }); + const memberToSelect = await screen.findByText('Digital-Asset-Eng-2'); + await user.click(memberToSelect); expect(screen.getByText('Review Proposal')).toBeInTheDocument(); const submitButton = screen.getByTestId('submit-button'); @@ -312,10 +314,8 @@ describe('Offboard SV Form', () => { const selectInput = screen.getByRole('combobox'); fireEvent.mouseDown(selectInput); - await waitFor(async () => { - const memberToSelect = screen.getByText('Digital-Asset-Eng-2'); - await user.click(memberToSelect); - }); + const memberToSelect = await screen.findByText('Digital-Asset-Eng-2'); + await user.click(memberToSelect); const submitButton = screen.getByTestId('submit-button'); await user.click(actionInput); // using this to trigger the onBlur event which triggers the validation diff --git a/apps/sv/frontend/src/__tests__/governance/forms/pending-fields.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/pending-fields.test.tsx index f84059d16d..9391e48ba6 100644 --- a/apps/sv/frontend/src/__tests__/governance/forms/pending-fields.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/forms/pending-fields.test.tsx @@ -78,7 +78,7 @@ describe('DSO Pending Fields', () => { const button = screen.getByRole('button', { name: 'Log In' }); await user.click(button); - expect(await screen.findAllByDisplayValue(svPartyId)).not.toBe([]); + expect(await screen.findAllByDisplayValue(svPartyId)).not.toHaveLength(0); }); }); diff --git a/apps/sv/frontend/src/__tests__/governance/forms/set-amulet-rules-form.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/set-amulet-rules-form.test.tsx index 728ca1538c..112720c447 100644 --- a/apps/sv/frontend/src/__tests__/governance/forms/set-amulet-rules-form.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/forms/set-amulet-rules-form.test.tsx @@ -13,7 +13,11 @@ import { SetAmuletConfigRulesForm } from '../../../components/forms/SetAmuletCon import dayjs from 'dayjs'; import { dateTimeFormatISO } from '@canton-network/splice-common-frontend-utils'; import { server, svUrl } from '../../setup/setup'; -import { PROPOSAL_SUMMARY_SUBTITLE, PROPOSAL_SUMMARY_TITLE } from '../../../utils/constants'; +import { + CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + PROPOSAL_REVIEW_TITLE, + PROPOSAL_SUMMARY_SUBTITLE, +} from '../../../utils/constants'; describe('SV user can', () => { test('login and see the SV party ID', async () => { @@ -32,7 +36,7 @@ describe('SV user can', () => { const button = screen.getByRole('button', { name: 'Log In' }); user.click(button); - expect(await screen.findAllByDisplayValue(svPartyId)).not.toBe([]); + expect(await screen.findAllByDisplayValue(svPartyId)).not.toHaveLength(0); }); }); @@ -45,7 +49,7 @@ describe('Set Amulet Config Rules Form', () => { ); expect(screen.getByTestId('set-amulet-config-rules-form')).toBeInTheDocument(); - expect(screen.getByText('Proposal type')).toBeInTheDocument(); + expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument(); const actionInput = screen.getByTestId('set-amulet-config-rules-action'); expect(actionInput).toBeInTheDocument(); @@ -80,7 +84,11 @@ describe('Set Amulet Config Rules Form', () => { { timeout: 1000 } ); - expect(screen.getByTestId('json-diffs-details')).toBeInTheDocument(); + const jsonDiffsToggle = screen.getByTestId('json-diff-toggle'); + expect(screen.getByText('JSON')).toBeInTheDocument(); + expect(jsonDiffsToggle).toHaveTextContent('Show JSON'); + expect(jsonDiffsToggle).toHaveAttribute('aria-expanded', 'false'); + expect(screen.getByTestId('json-diffs-details')).not.toBeVisible(); }); test( @@ -99,7 +107,7 @@ describe('Set Amulet Config Rules Form', () => { expect(submitButton).toBeInTheDocument(); await user.click(submitButton); - expect(submitButton.getAttribute('disabled')).toBeDefined(); + expect(submitButton.getAttribute('disabled')).not.toBeNull(); await expect(async () => await user.click(submitButton)).rejects.toThrowError( /Unable to perform pointer interaction/ ); @@ -241,6 +249,53 @@ describe('Set Amulet Config Rules Form', () => { expect(changes.length).toBe(2); }); + test('reward config minting scheme renders as a dropdown', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + // Minting scheme should render as a Select, not a TextField + const mintingField = screen.getByTestId('config-field-rewardConfigMintingVersion'); + expect(mintingField).toBeInTheDocument(); + const selectInput = mintingField.querySelector('[role="combobox"]') as HTMLElement; + expect(selectInput).toBeInTheDocument(); + + // Open dropdown and verify options + await user.click(selectInput); + expect(screen.getByText('Featured App Markers (pre CIP-104)')).toBeInTheDocument(); + expect(screen.getByText('Traffic-Based App Rewards (CIP-104)')).toBeInTheDocument(); + + // Select an option + await user.click(screen.getByText('Traffic-Based App Rewards (CIP-104)')); + + // Verify current value is shown after change + const currentValue = screen.getByTestId('config-current-value-rewardConfigMintingVersion'); + expect(currentValue).toBeInTheDocument(); + }); + + test('reward config dry-run scheme includes None option', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const dryRunField = screen.getByTestId('config-field-rewardConfigDryRunVersion'); + expect(dryRunField).toBeInTheDocument(); + const selectInput = dryRunField.querySelector('[role="combobox"]') as HTMLElement; + expect(selectInput).toBeInTheDocument(); + + // Open dropdown and verify None option exists + await user.click(selectInput); + expect(screen.getByText('None (disabled)')).toBeInTheDocument(); + }); + test('should show proposal review page after form completion', async () => { const user = userEvent.setup(); @@ -274,7 +329,9 @@ describe('Set Amulet Config Rules Form', () => { await user.click(submitButton); - expect(screen.getByText(PROPOSAL_SUMMARY_TITLE)).toBeInTheDocument(); + expect(screen.getByText(PROPOSAL_REVIEW_TITLE)).toBeInTheDocument(); + expect(screen.queryByText('JSON')).not.toBeInTheDocument(); + expect(screen.getByTestId('json-diff-toggle')).toHaveTextContent('Show JSON'); }); test('should show error on form if submission fails', { timeout: 10000 }, async () => { @@ -376,19 +433,22 @@ describe('Set Amulet Config Rules Form', () => { const c2Input = screen.getByTestId('config-field-transferConfigTransferFeeInitialRate'); await user.type(c2Input, '9.99'); - const jsonDiffs = screen.getByText('JSON Diffs'); - expect(jsonDiffs).toBeInTheDocument(); + const jsonDiffsToggle = screen.getByTestId('json-diff-toggle'); + expect(jsonDiffsToggle).toHaveTextContent('Show JSON'); + expect(jsonDiffsToggle).toHaveAttribute('aria-expanded', 'false'); - await user.click(jsonDiffs); - expect(await screen.findByTestId('config-diffs-display')).toBeInTheDocument(); + await user.click(jsonDiffsToggle); + expect(await screen.findByTestId('config-diffs-display')).toBeVisible(); + expect(jsonDiffsToggle).toHaveTextContent('Hide JSON'); + expect(jsonDiffsToggle).toHaveAttribute('aria-expanded', 'true'); const reviewButton = screen.getByTestId('submit-button'); await waitFor(async () => { expect(reviewButton.getAttribute('disabled')).toBeNull(); }); - expect(jsonDiffs).toBeInTheDocument(); - await user.click(jsonDiffs); + expect(jsonDiffsToggle).toBeInTheDocument(); + await user.click(jsonDiffsToggle); expect(await screen.findByTestId('config-diffs-display')).toBeInTheDocument(); }); diff --git a/apps/sv/frontend/src/__tests__/governance/forms/set-dso-rules-form.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/set-dso-rules-form.test.tsx index 25753a34fd..e3929882c1 100644 --- a/apps/sv/frontend/src/__tests__/governance/forms/set-dso-rules-form.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/forms/set-dso-rules-form.test.tsx @@ -12,6 +12,12 @@ import { http, HttpResponse } from 'msw'; import { describe, expect, test } from 'vitest'; import App from '../../../App'; import { SetDsoConfigRulesForm } from '../../../components/forms/SetDsoConfigRulesForm'; +import { + CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + DATE_TIME_PLACEHOLDER, + REASON_PLACEHOLDER, + URL_PLACEHOLDER, +} from '../../../utils/constants'; import { SvConfigProvider } from '../../../utils'; import { Wrapper } from '../../helpers'; import { svPartyId } from '../../mocks/constants'; @@ -34,7 +40,7 @@ describe('SV user can', () => { const button = screen.getByRole('button', { name: 'Log In' }); await user.click(button); - expect(await screen.findAllByDisplayValue(svPartyId)).not.toBe([]); + expect(await screen.findAllByDisplayValue(svPartyId)).not.toHaveLength(0); }); }); @@ -47,7 +53,7 @@ describe('Set DSO Config Rules Form', () => { ); expect(screen.getByTestId('set-dso-config-rules-form')).toBeInTheDocument(); - expect(screen.getByText('Proposal type')).toBeInTheDocument(); + expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument(); const actionInput = screen.getByTestId('set-dso-config-rules-action'); expect(actionInput).toBeInTheDocument(); @@ -58,10 +64,21 @@ describe('Set DSO Config Rules Form', () => { const summaryInput = screen.getByTestId('set-dso-config-rules-summary'); expect(summaryInput).toBeInTheDocument(); expect(summaryInput.getAttribute('value')).not.toBeInTheDocument(); + expect(summaryInput).toHaveAttribute('placeholder', REASON_PLACEHOLDER); const urlInput = screen.getByTestId('set-dso-config-rules-url'); expect(urlInput).toBeInTheDocument(); expect(urlInput.getAttribute('value')).toBe(''); + expect(urlInput).toHaveAttribute('placeholder', URL_PLACEHOLDER); + + expect(screen.getByTestId('set-dso-config-rules-expiry-date-field')).toHaveAttribute( + 'placeholder', + DATE_TIME_PLACEHOLDER + ); + expect(screen.getByTestId('set-dso-config-rules-effective-date-field')).toHaveAttribute( + 'placeholder', + DATE_TIME_PLACEHOLDER + ); const configLabels = screen.getAllByTestId(/config-label-/); expect(configLabels.length).toBeGreaterThan(15); @@ -73,7 +90,11 @@ describe('Set DSO Config Rules Form', () => { /Unable to find an element/ ); - expect(screen.getByTestId('json-diffs-details')).toBeInTheDocument(); + const jsonDiffsToggle = screen.getByTestId('json-diff-toggle'); + expect(screen.getByText('JSON')).toBeInTheDocument(); + expect(jsonDiffsToggle).toHaveTextContent('Show JSON'); + expect(jsonDiffsToggle).toHaveAttribute('aria-expanded', 'false'); + expect(screen.getByTestId('json-diffs-details')).not.toBeVisible(); }); test('should render errors when submit button is clicked on new form', async () => { @@ -90,7 +111,7 @@ describe('Set DSO Config Rules Form', () => { expect(submitButton).toBeInTheDocument(); await user.click(submitButton); - expect(submitButton.getAttribute('disabled')).toBeDefined(); + expect(submitButton.getAttribute('disabled')).not.toBeNull(); expect(async () => await user.click(submitButton)).rejects.toThrowError( /Unable to perform pointer interaction/ ); @@ -263,7 +284,9 @@ describe('Set DSO Config Rules Form', () => { await user.click(submitButton); - expect(screen.getByText('Proposal Summary')).toBeInTheDocument(); + expect(screen.getByText('Proposal Review')).toBeInTheDocument(); + expect(screen.queryByText('JSON')).not.toBeInTheDocument(); + expect(screen.getByTestId('json-diff-toggle')).toHaveTextContent('Show JSON'); }); test('should show error on form if submission fails', async () => { @@ -374,19 +397,22 @@ describe('Set DSO Config Rules Form', () => { const c2Input = screen.getByTestId('config-field-voteCooldownTime'); await user.type(c2Input, '9999'); - const jsonDiffs = screen.getByText('JSON Diffs'); - expect(jsonDiffs).toBeInTheDocument(); + const jsonDiffsToggle = screen.getByTestId('json-diff-toggle'); + expect(jsonDiffsToggle).toHaveTextContent('Show JSON'); + expect(jsonDiffsToggle).toHaveAttribute('aria-expanded', 'false'); - await user.click(jsonDiffs); - expect(await screen.findByTestId('config-diffs-display')).toBeInTheDocument(); + await user.click(jsonDiffsToggle); + expect(await screen.findByTestId('config-diffs-display')).toBeVisible(); + expect(jsonDiffsToggle).toHaveTextContent('Hide JSON'); + expect(jsonDiffsToggle).toHaveAttribute('aria-expanded', 'true'); const reviewButton = screen.getByTestId('submit-button'); await waitFor(async () => { expect(reviewButton.getAttribute('disabled')).not.toBeInTheDocument(); }); - expect(jsonDiffs).toBeInTheDocument(); - await user.click(jsonDiffs); + expect(jsonDiffsToggle).toBeInTheDocument(); + await user.click(jsonDiffsToggle); expect(await screen.findByTestId('config-diffs-display')).toBeInTheDocument(); }); diff --git a/apps/sv/frontend/src/__tests__/governance/forms/update-featured-app-form.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/update-featured-app-form.test.tsx new file mode 100644 index 0000000000..a71c1b0afd --- /dev/null +++ b/apps/sv/frontend/src/__tests__/governance/forms/update-featured-app-form.test.tsx @@ -0,0 +1,315 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { dateTimeFormatISO } from '@canton-network/splice-common-frontend-utils'; +import dayjs from 'dayjs'; +import { SvConfigProvider } from '../../../utils'; +import App from '../../../App'; +import { svPartyId } from '../../mocks/constants'; +import { Wrapper } from '../../helpers'; +import { UpdateFeaturedAppForm } from '../../../components/forms/UpdateFeaturedAppForm'; +import { server, svUrl } from '../../setup/setup'; +import { http, HttpResponse } from 'msw'; +import { PROPOSAL_SUMMARY_SUBTITLE, PROPOSAL_SUMMARY_TITLE } from '../../../utils/constants'; + +// Logs in once so the admin client has an access token for the rest of the file. +describe('SV user can', () => { + test('login and see the SV party ID', async () => { + const user = userEvent.setup(); + render( + + + + ); + + expect(await screen.findByText('Log In')).toBeInTheDocument(); + + const input = screen.getByRole('textbox'); + await user.type(input, 'sv1'); + + const button = screen.getByRole('button', { name: 'Log In' }); + await user.click(button); + + expect(await screen.findAllByDisplayValue(svPartyId)).not.toBe([]); + }); +}); + +describe('Update Featured App Form', () => { + const fillOutUpdateForm = async (user: ReturnType) => { + const summaryInput = screen.getByTestId('update-featured-app-summary'); + await user.type(summaryInput, 'Summary of the proposal'); + + const urlInput = screen.getByTestId('update-featured-app-url'); + await user.type(urlInput, 'https://example.com'); + + const partyIdInput = screen.getByTestId('update-featured-app-partyId'); + await user.type(partyIdInput, 'a-party-id::1014912492'); + fireEvent.blur(partyIdInput); + + await waitFor(() => { + expect(screen.queryByText('Loading featured app rights...')).not.toBeInTheDocument(); + }); + + const rightCidDropdown = screen.getByTestId('update-featured-app-rightCid-dropdown'); + await waitFor( + () => { + expect(rightCidDropdown).not.toBeDisabled(); + }, + { timeout: 3000 } + ); + fireEvent.change(rightCidDropdown, { target: { value: 'rightCid123' } }); + fireEvent.blur(rightCidDropdown); + await waitFor(() => { + expect(screen.getByTestId('update-featured-app-rightCid-error').textContent).toBeFalsy(); + }); + + const activityWeightInput = screen.getByTestId('update-featured-app-activityWeight'); + await user.type(activityWeightInput, '2.5'); + }; + + test('should render all Form components', () => { + render( + + + + ); + + expect(screen.getByTestId('update-featured-app-form')).toBeInTheDocument(); + expect(screen.getByText('Proposal type')).toBeInTheDocument(); + + const actionInput = screen.getByTestId('update-featured-app-action'); + expect(actionInput).toBeInTheDocument(); + expect(actionInput.textContent).toBe('Update Featured Application'); + + const summaryInput = screen.getByTestId('update-featured-app-summary'); + expect(summaryInput).toBeInTheDocument(); + expect(summaryInput.getAttribute('value')).toBeNull(); + + const summarySubtitle = screen.getByTestId('update-featured-app-summary-subtitle'); + expect(summarySubtitle).toBeInTheDocument(); + expect(summarySubtitle.textContent).toBe(PROPOSAL_SUMMARY_SUBTITLE); + + const urlInput = screen.getByTestId('update-featured-app-url'); + expect(urlInput).toBeInTheDocument(); + expect(urlInput.getAttribute('value')).toBe(''); + + const partyIdInput = screen.getByTestId('update-featured-app-partyId'); + expect(partyIdInput).toBeInTheDocument(); + expect(partyIdInput.getAttribute('value')).toBe(''); + + const partyIdTitle = screen.getByTestId('update-featured-app-partyId-title'); + expect(partyIdTitle).toBeInTheDocument(); + expect(partyIdTitle.textContent).toBe('Provider Party ID'); + + const rightCidDropdown = screen.getByTestId('update-featured-app-rightCid-dropdown'); + expect(rightCidDropdown).toBeInTheDocument(); + expect(rightCidDropdown).toBeDisabled(); + + expect(screen.getByTestId('update-featured-app-activityWeight')).toBeInTheDocument(); + + expect(screen.getByText('Review Proposal')).toBeInTheDocument(); + }); + + test('activity weight is required', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const activityWeightInput = screen.getByTestId('update-featured-app-activityWeight'); + const actionInput = screen.getByTestId('update-featured-app-action'); + + await user.click(activityWeightInput); + await user.click(actionInput); // blur to trigger validation + + await waitFor(() => { + expect(screen.getByTestId('update-featured-app-activityWeight-error').textContent).toBe( + 'Weight is required' + ); + }); + }); + + test('should send new activity weight to backend', async () => { + let requestBody = ''; + server.use( + http.post(`${svUrl}/v0/admin/sv/voterequest/create`, async ({ request }) => { + requestBody = await request.text(); + return HttpResponse.json({}); + }) + ); + + const user = userEvent.setup(); + + render( + + + + ); + + await fillOutUpdateForm(user); + + const actionInput = screen.getByTestId('update-featured-app-action'); + await user.click(actionInput); // blur to trigger validation + + const submitButton = screen.getByTestId('submit-button'); + await waitFor(() => { + expect(submitButton).not.toBeDisabled(); + }); + + await user.click(submitButton); // review proposal + await user.click(submitButton); // submit proposal + + await waitFor(() => { + expect(requestBody).toContain('"newActivityWeight":"2.5"'); + }); + }); + + test('activity weight rejects negative numbers and more than 10 decimal places', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const activityWeightInput = screen.getByTestId('update-featured-app-activityWeight'); + const activityWeightError = screen.getByTestId('update-featured-app-activityWeight-error'); + + await user.type(activityWeightInput, '-1'); + await waitFor(() => { + expect(activityWeightError.textContent).toBe('Weight must be a valid non-negative number'); + }); + + await user.clear(activityWeightInput); + await user.type(activityWeightInput, '1.1234567891'); + await waitFor(() => { + expect(activityWeightError.textContent).toBe(''); + }); + + await user.clear(activityWeightInput); + await user.type(activityWeightInput, '1.12345678912'); + await waitFor(() => { + expect(activityWeightError.textContent).toBe('Weight can have at most 10 decimal places'); + }); + }); + + test('communicates when the provider has no featured app rights to update', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const partyIdInput = screen.getByTestId('update-featured-app-partyId'); + await user.type(partyIdInput, 'no-rights-party::1014912492'); + fireEvent.blur(partyIdInput); + + await waitFor(() => { + expect(screen.getByTestId('update-featured-app-rightCid')).toHaveTextContent( + 'No featured application rights found for this provider' + ); + }); + + expect(screen.getByTestId('update-featured-app-rightCid-dropdown')).toBeDisabled(); + }); + + test('expiry date must be in the future', async () => { + render( + + + + ); + + const expiryDateInput = screen.getByTestId('update-featured-app-expiry-date-field'); + expect(expiryDateInput).toBeInTheDocument(); + + const thePast = dayjs().subtract(1, 'day').format(dateTimeFormatISO); + const theFuture = dayjs().add(1, 'day').format(dateTimeFormatISO); + + fireEvent.change(expiryDateInput, { target: { value: thePast } }); + + await waitFor(() => { + expect(screen.queryByText('Expiration must be in the future')).toBeInTheDocument(); + }); + + fireEvent.change(expiryDateInput, { target: { value: theFuture } }); + + await waitFor(() => { + expect(screen.queryByText('Expiration must be in the future')).not.toBeInTheDocument(); + }); + }); + + test('effective date must be after expiry date', async () => { + render( + + + + ); + + const expiryDateInput = screen.getByTestId('update-featured-app-expiry-date-field'); + const effectiveDateInput = screen.getByTestId('update-featured-app-effective-date-field'); + + const expiryDate = dayjs().add(1, 'week'); + const effectiveDate = expiryDate.subtract(1, 'day'); + + fireEvent.change(expiryDateInput, { target: { value: expiryDate.format(dateTimeFormatISO) } }); + fireEvent.change(effectiveDateInput, { + target: { value: effectiveDate.format(dateTimeFormatISO) }, + }); + + await waitFor(() => { + expect( + screen.queryByText('Effective Date must be after expiration date') + ).toBeInTheDocument(); + }); + + const validEffectiveDate = expiryDate.add(1, 'day').format(dateTimeFormatISO); + + fireEvent.change(effectiveDateInput, { target: { value: validEffectiveDate } }); + + await waitFor(() => { + expect( + screen.queryByText('Effective Date must be after expiration date') + ).not.toBeInTheDocument(); + }); + }); + + test('should show proposal review page after form completion', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await fillOutUpdateForm(user); + + const actionInput = screen.getByTestId('update-featured-app-action'); + await user.click(actionInput); // blur to trigger validation + + const submitButton = screen.getByTestId('submit-button'); + await waitFor(() => { + expect(submitButton).not.toBeDisabled(); + }); + + await user.click(submitButton); // review proposal + + expect(screen.getByText(PROPOSAL_SUMMARY_TITLE)).toBeInTheDocument(); + expect(screen.getByTestId('updateProviderPartyId-field').textContent).toBe( + 'a-party-id::1014912492' + ); + expect(screen.getByTestId('updateRight-field').textContent).toBe('rightCid123'); + expect(screen.getByTestId('config-change-current-value').textContent).toBe('1.0'); + expect(screen.getByTestId('config-change-new-value').textContent).toBe('2.5'); + }); +}); diff --git a/apps/sv/frontend/src/__tests__/governance/forms/update-sv-reward-weight-form-test.test.tsx b/apps/sv/frontend/src/__tests__/governance/forms/update-sv-reward-weight-form-test.test.tsx index 7728867f43..2d6892d4bb 100644 --- a/apps/sv/frontend/src/__tests__/governance/forms/update-sv-reward-weight-form-test.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/forms/update-sv-reward-weight-form-test.test.tsx @@ -13,7 +13,10 @@ import { dateTimeFormatISO } from '@canton-network/splice-common-frontend-utils' import dayjs from 'dayjs'; import { server, svUrl } from '../../setup/setup'; import { http, HttpResponse } from 'msw'; -import { PROPOSAL_SUMMARY_SUBTITLE } from '../../../utils/constants'; +import { + CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + PROPOSAL_SUMMARY_SUBTITLE, +} from '../../../utils/constants'; describe('SV user can', () => { test('login and see the SV party ID', async () => { @@ -32,7 +35,7 @@ describe('SV user can', () => { const button = screen.getByRole('button', { name: 'Log In' }); user.click(button); - expect(await screen.findAllByDisplayValue(svPartyId)).not.toBe([]); + expect(await screen.findAllByDisplayValue(svPartyId)).not.toHaveLength(0); }); }); @@ -45,7 +48,7 @@ describe('Update Super Validator Reward Weight Form', () => { ); expect(screen.getByTestId('update-sv-reward-weight-form')).toBeInTheDocument(); - expect(screen.getByText('Proposal type')).toBeInTheDocument(); + expect(screen.getByText(CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE)).toBeInTheDocument(); const actionInput = screen.getByTestId('update-sv-reward-weight-action'); expect(actionInput).toBeInTheDocument(); @@ -86,7 +89,7 @@ describe('Update Super Validator Reward Weight Form', () => { expect(submitButton).toBeInTheDocument(); await user.click(submitButton); - expect(submitButton.getAttribute('disabled')).toBeDefined(); + expect(submitButton.getAttribute('disabled')).not.toBeNull(); await expect(async () => await user.click(submitButton)).rejects.toThrowError( /Unable to perform pointer interaction/ ); @@ -111,11 +114,8 @@ describe('Update Super Validator Reward Weight Form', () => { const selectInput = screen.getByRole('combobox'); fireEvent.mouseDown(selectInput); - await waitFor(async () => { - const memberToSelect = screen.getByText('Digital-Asset-Eng-2'); - expect(memberToSelect).toBeInTheDocument(); - await user.click(memberToSelect); - }); + const memberToSelect = await screen.findByText('Digital-Asset-Eng-2'); + await user.click(memberToSelect); const weightInput = screen.getByTestId('update-sv-reward-weight-weight'); expect(weightInput).toBeInTheDocument(); @@ -123,7 +123,7 @@ describe('Update Super Validator Reward Weight Form', () => { await user.click(actionInput); // using this to trigger the onBlur event which triggers the validation - expect(submitButton.getAttribute('disabled')).toBe(null); + expect(submitButton.getAttribute('disabled')).toBeNull(); }); test('expiry date must be in the future', async () => { @@ -222,13 +222,10 @@ describe('Update Super Validator Reward Weight Form', () => { const selectInput = screen.getByRole('combobox'); const validateCurrentWeightFor = async (sv: string, weight: string) => { - await waitFor(async () => { - fireEvent.mouseDown(selectInput); - const memberToSelect = screen.getByText(sv); - expect(memberToSelect).not.toBeNull(); - await user.click(memberToSelect); - expect(await screen.findByText(`Current Weight: ${weight}`)).toBeInTheDocument(); - }); + fireEvent.mouseDown(selectInput); + const memberToSelect = await screen.findByText(sv); + await user.click(memberToSelect); + expect(await screen.findByText(`Current Weight: ${weight}`)).toBeInTheDocument(); }; await validateCurrentWeightFor('Digital-Asset-2', '0_0010'); @@ -257,11 +254,8 @@ describe('Update Super Validator Reward Weight Form', () => { const selectInput = screen.getByRole('combobox'); fireEvent.mouseDown(selectInput); - await waitFor(async () => { - const memberToSelect = screen.getByText('Digital-Asset-Eng-2'); - expect(memberToSelect).toBeInTheDocument(); - await user.click(memberToSelect); - }); + const memberToSelect = await screen.findByText('Digital-Asset-Eng-2'); + await user.click(memberToSelect); expect(weightInput.getAttribute('value')).toBe(''); }); @@ -334,11 +328,8 @@ describe('Update Super Validator Reward Weight Form', () => { const selectInput = screen.getByRole('combobox'); fireEvent.mouseDown(selectInput); - await waitFor(async () => { - const memberToSelect = screen.getByText('Digital-Asset-Eng-2'); - expect(memberToSelect).toBeInTheDocument(); - await user.click(memberToSelect); - }); + const memberToSelect = await screen.findByText('Digital-Asset-Eng-2'); + await user.click(memberToSelect); const weightInput = screen.getByTestId('update-sv-reward-weight-weight'); expect(weightInput).toBeInTheDocument(); @@ -383,11 +374,8 @@ describe('Update Super Validator Reward Weight Form', () => { const selectInput = screen.getByRole('combobox'); fireEvent.mouseDown(selectInput); - await waitFor(async () => { - const memberToSelect = screen.getByText('Digital-Asset-Eng-2'); - expect(memberToSelect).toBeInTheDocument(); - await user.click(memberToSelect); - }); + const memberToSelect = await screen.findByText('Digital-Asset-Eng-2'); + await user.click(memberToSelect); const weightInput = screen.getByTestId('update-sv-reward-weight-weight'); expect(weightInput.getAttribute('value')).toBe(''); @@ -438,11 +426,8 @@ describe('Update Super Validator Reward Weight Form', () => { const selectInput = screen.getByRole('combobox'); fireEvent.mouseDown(selectInput); - await waitFor(async () => { - const memberToSelect = screen.getByText('Digital-Asset-Eng-2'); - expect(memberToSelect).toBeInTheDocument(); - await user.click(memberToSelect); - }); + const memberToSelect = await screen.findByText('Digital-Asset-Eng-2'); + await user.click(memberToSelect); const weightInput = screen.getByTestId('update-sv-reward-weight-weight'); expect(weightInput).toBeInTheDocument(); @@ -488,10 +473,8 @@ describe('Update Super Validator Reward Weight Form', () => { const selectInput = screen.getByRole('combobox'); fireEvent.mouseDown(selectInput); - await waitFor(async () => { - const memberToSelect = screen.getByText('Digital-Asset-Eng-2'); - await user.click(memberToSelect); - }); + const memberToSelect = await screen.findByText('Digital-Asset-Eng-2'); + await user.click(memberToSelect); const weightInput = screen.getByTestId('update-sv-reward-weight-weight'); await user.type(weightInput, '0_1000'); diff --git a/apps/sv/frontend/src/__tests__/governance/governance-page.test.tsx b/apps/sv/frontend/src/__tests__/governance/governance-page.test.tsx index c6d3c1dc60..931fa7d068 100644 --- a/apps/sv/frontend/src/__tests__/governance/governance-page.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/governance-page.test.tsx @@ -1,11 +1,20 @@ // Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { render, screen, within } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import { SvConfigProvider } from '../../utils'; import userEvent from '@testing-library/user-event'; +import dayjs from 'dayjs'; +import { dateTimeFormatISO } from '@canton-network/splice-common-frontend-utils'; import App from '../../App'; import { navigateToGovernancePage } from '../helpers'; +import { + activeProposalCid, + closedVoteCid, + voteResultsAmuletRules, + voteResultsDsoRules, +} from '../mocks/constants'; +import { CONTRACT_ID_VALIDATION_MESSAGE } from '../../utils/proposalSearch'; type UserEvent = ReturnType; @@ -95,6 +104,71 @@ describe('Governance Page', () => { expect(true).toBe(true); }); + test('should display total vote history count in the section badge', async () => { + const user = userEvent.setup(); + + render(); + + await navigateToGovernancePage(user); + + const expectedCount = voteResultsAmuletRules.dso_rules_vote_results + .concat(voteResultsDsoRules.dso_rules_vote_results) + .filter( + r => r.outcome.tag !== 'VRO_Accepted' || new Date(r.outcome.value.effectiveAt) < new Date() + ).length; + + const badge = await screen.findByTestId('vote-history-section-badge-count'); + await waitFor(() => expect(badge).toHaveTextContent(`${expectedCount}`)); + }); + + test('should display inflight votes count in the section badge', async () => { + const user = userEvent.setup(); + + render(); + + await navigateToGovernancePage(user); + + const badge = screen.getByTestId('inflight-proposals-section-badge-count'); + expect(badge).toHaveTextContent(''); + }); + + test('vote history details show the actual effective time for closed votes without targetEffectiveAt', async () => { + const user = userEvent.setup(); + + await login(user); + + await navigateToGovernancePage(user); + + // The first DsoRules vote result simulates an old-model accepted vote: + // no targetEffectiveAt on the request, actual effective time on the outcome. + const closedVote = voteResultsDsoRules.dso_rules_vote_results[0]; + const effectiveAt = + closedVote.outcome.tag === 'VRO_Accepted' ? closedVote.outcome.value.effectiveAt : undefined; + const expectedEffectiveAt = dayjs(effectiveAt).format(dateTimeFormatISO); + + const rows = screen.getAllByTestId('vote-history-row'); + const targetRow = rows.find( + row => + within(row).getByTestId('vote-history-row-vote-takes-effect').textContent === + expectedEffectiveAt + ); + expect(targetRow).toBeDefined(); + + await user.click(within(targetRow!).getByTestId('vote-history-row-action-name')); + + const votingInformation = await screen.findByTestId('proposal-details-voting-information'); + + const voteTakesEffectDuration = within(votingInformation).getByTestId( + 'proposal-details-vote-takes-effect-duration' + ); + expect(voteTakesEffectDuration.textContent?.trim()).not.toBe('Threshold'); + + const voteTakesEffectIso = within(votingInformation).getByTestId( + 'proposal-details-vote-takes-effect-value' + ); + expect(voteTakesEffectIso).toHaveTextContent(expectedEffectiveAt); + }); + test('click on Details link to see Proposal Details (Action Required)', async () => { const user = userEvent.setup(); @@ -109,7 +183,7 @@ describe('Governance Page', () => { await user.click(viewDetailsLink); - const proposalDetails = screen.getByTestId('proposal-details-title'); + const proposalDetails = screen.getByTestId('proposal-details-proposal-details'); expect(proposalDetails).toBeInTheDocument(); }); @@ -127,7 +201,7 @@ describe('Governance Page', () => { await user.click(viewDetailsLink); - const proposalDetails = screen.getByTestId('proposal-details-title'); + const proposalDetails = screen.getByTestId('proposal-details-proposal-details'); expect(proposalDetails).toBeInTheDocument(); const action = screen.getByTestId('proposal-details-action-value'); @@ -146,6 +220,11 @@ describe('Governance Page', () => { 'proposal-details-requester-party-id' ); expect(requesterInput).toBeInTheDocument(); + // Resolve SV display name (e.g. Digital-Asset-2) to full party ID for display + copy. + expect( + within(votingInformationSection).getByTestId('proposal-details-requester-party-id-value') + .textContent + ).toMatch(/::/); const votingClosesIso = within(votingInformationSection).getByTestId( 'proposal-details-voting-closes-value' @@ -187,4 +266,67 @@ describe('Governance Page', () => { expect(screen.getByTestId('your-vote-accept')).toBeInTheDocument(); expect(screen.getByTestId('your-vote-reject')).toBeInTheDocument(); }); + + describe('Proposal Search', () => { + test('renders search field and filters action required by full contract ID', async () => { + const user = userEvent.setup(); + await login(user); + await navigateToGovernancePage(user); + + expect(screen.getByTestId('proposal-search')).toBeInTheDocument(); + expect(screen.getAllByTestId('action-required-card')).toHaveLength(4); + + fireEvent.change(screen.getByTestId('proposal-search-input'), { + target: { value: activeProposalCid }, + }); + + await waitFor(() => { + expect(screen.getByTestId('proposal-search-clear')).toBeInTheDocument(); + expect(screen.getAllByTestId('action-required-card')).toHaveLength(1); + }); + }); + + test('filters vote history to matching full contract ID', async () => { + const user = userEvent.setup(); + await login(user); + await navigateToGovernancePage(user); + + expect(screen.getAllByTestId('vote-history-row')).toHaveLength(5); + + fireEvent.change(screen.getByTestId('proposal-search-input'), { + target: { value: closedVoteCid }, + }); + + await waitFor(() => { + expect(screen.getAllByTestId('vote-history-row')).toHaveLength(1); + }); + }); + + test('invalid contract ID shows validation and does not filter', async () => { + const user = userEvent.setup(); + await login(user); + await navigateToGovernancePage(user); + + const input = screen.getByTestId('proposal-search-input'); + fireEvent.change(input, { target: { value: 'not-a-valid-contract-id' } }); + + expect(screen.getByText(CONTRACT_ID_VALIDATION_MESSAGE)).toBeInTheDocument(); + expect(screen.getAllByTestId('action-required-card')).toHaveLength(4); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(screen.getByTestId('governance-page-header')).toBeInTheDocument(); + expect(screen.queryByTestId('proposal-details-title')).not.toBeInTheDocument(); + }); + + test('contract ID search navigates to proposal details on enter', async () => { + const user = userEvent.setup(); + await login(user); + await navigateToGovernancePage(user); + + const input = screen.getByTestId('proposal-search-input'); + fireEvent.change(input, { target: { value: activeProposalCid } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(await screen.findByTestId('proposal-details-title')).toBeInTheDocument(); + }); + }); }); diff --git a/apps/sv/frontend/src/__tests__/governance/governance-sorting.test.tsx b/apps/sv/frontend/src/__tests__/governance/governance-sorting.test.tsx index 9ff8bb2236..f593de50f4 100644 --- a/apps/sv/frontend/src/__tests__/governance/governance-sorting.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/governance-sorting.test.tsx @@ -11,6 +11,7 @@ import { } from '../../components/governance/ActionRequiredSection'; import { ProposalListingSection } from '../../components/governance/ProposalListingSection'; import { ProposalListingData } from '../../utils/types'; +import { svPartyId } from '../mocks/constants'; describe('Governance Page Sorting', () => { describe('Action Required Section', () => { @@ -22,7 +23,7 @@ describe('Governance Page Sorting', () => { contractId: 'c' as ContractId, votingCloses: '2025-01-25 12:00', createdAt: '2025-01-10 12:00', - requester: 'sv1', + requester: svPartyId, }, { actionName: 'Action A - Earliest', @@ -30,7 +31,7 @@ describe('Governance Page Sorting', () => { contractId: 'a' as ContractId, votingCloses: '2025-01-15 10:00', createdAt: '2025-01-10 12:00', - requester: 'sv1', + requester: svPartyId, }, { actionName: 'Action B - Middle', @@ -38,7 +39,7 @@ describe('Governance Page Sorting', () => { contractId: 'b' as ContractId, votingCloses: '2025-01-15 18:00', createdAt: '2025-01-10 12:00', - requester: 'sv1', + requester: svPartyId, }, ]; @@ -61,7 +62,7 @@ describe('Governance Page Sorting', () => { }); }); - describe('Inflight Votes Section', () => { + describe('In-flight Proposals Section', () => { const baseData: Omit< ProposalListingData, 'actionName' | 'contractId' | 'voteTakesEffect' | 'votingThresholdDeadline' | 'voteStats' @@ -69,6 +70,7 @@ describe('Governance Page Sorting', () => { yourVote: 'accepted', status: 'In Progress', acceptanceThreshold: BigInt(11), + requester: svPartyId, }; test('should sort with Threshold items first (by votes desc, then deadline asc), then dated items by effective date asc', () => { @@ -118,7 +120,7 @@ describe('Governance Page Sorting', () => { render( { status: 'Implemented', voteStats: { accepted: 8, rejected: 2, 'no-vote': 1 }, acceptanceThreshold: BigInt(11), + requester: svPartyId, }; test('renders in backend order without client re-sorting', () => { diff --git a/apps/sv/frontend/src/__tests__/governance/proposal-details-content.test.tsx b/apps/sv/frontend/src/__tests__/governance/proposal-details-content.test.tsx index 1de4bb9c29..5604b8c83a 100644 --- a/apps/sv/frontend/src/__tests__/governance/proposal-details-content.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/proposal-details-content.test.tsx @@ -13,6 +13,7 @@ import { ProposalVote, ProposalVotingInformation, UnclaimedActivityRecordProposal, + UpdateFeatureAppProposal, UpdateSvRewardWeightProposal, } from '../../utils/types'; import userEvent from '@testing-library/user-event'; @@ -23,6 +24,17 @@ import { ProposalVoteForm } from '../../components/governance/ProposalVoteForm'; import App from '../../App'; import { svPartyId } from '../mocks/constants'; import { Wrapper } from '../helpers'; +import { + EFFECTIVE_AT_LABEL, + PROPOSAL_CREATED_LABEL, + PROPOSAL_SUMMARY_TITLE, + SUPPORTING_URL_LABEL, + THRESHOLD_DEADLINE_LABEL, + URL_PLACEHOLDER, + VOTE_PROPOSAL_CONTRACT_ID_LABEL, + VOTE_REASON_PLACEHOLDER, + VOTE_REASON_URL_PLACEHOLDER, +} from '../../utils/constants'; const voteRequest = { contractId: 'abc123' as ContractId, @@ -158,8 +170,17 @@ describe('Proposal Details Content', () => { ); - const pageTitle = screen.getByTestId('proposal-details-title'); - expect(pageTitle.textContent).toMatch(/Proposal Details/); + expect(screen.getByTestId('proposal-details-title')).toHaveTextContent('Proposal Details'); + const backToAllVotes = screen.getByTestId('proposal-details-back-to-all-votes'); + expect(backToAllVotes).toHaveTextContent('Back to all votes'); + expect(backToAllVotes).toHaveAttribute('href', '/governance/proposals'); + + const proposalDetailsSection = screen.getByTestId('proposal-details-proposal-details'); + expect(proposalDetailsSection).toBeInTheDocument(); + // Figma starts at Action — no duplicate inner "Proposal Details" heading + expect( + within(proposalDetailsSection).queryByRole('heading', { name: 'Proposal Details' }) + ).toBeNull(); const action = screen.getByTestId('proposal-details-action-value'); expect(action.textContent).toMatch(/Offboard Member/); @@ -172,12 +193,49 @@ describe('Proposal Details Content', () => { ); expect(memberInput).toBeInTheDocument(); expect(memberInput.textContent).toBe('sv2'); + expect(within(offboardSection).getByTestId('proposal-details-member-party-id')).toHaveStyle({ + width: '100%', + }); + expect( + within(offboardSection).getByTestId('proposal-details-member-party-id-scroll') + ).toHaveStyle({ overflowX: 'auto', width: '100%' }); + expect(screen.getByTestId('proposal-details-summary-label').textContent).toBe( + PROPOSAL_SUMMARY_TITLE + ); const summary = screen.getByTestId('proposal-details-summary-value'); expect(summary.textContent).toMatch(/Summary of the proposal/); + expect(screen.getByTestId('proposal-details-url-label').textContent).toBe(SUPPORTING_URL_LABEL); + const url = screen.getByTestId('proposal-details-url'); expect(url.textContent).toMatch(/https:\/\/example.com/); + expect(url).toHaveStyle({ width: '100%' }); + expect(screen.getByTestId('proposal-details-url-scroll')).toHaveStyle({ + overflowX: 'auto', + width: '100%', + }); + + // Figma Offboard details order: Action → Member → Proposal Summary → Supporting URL → Contract ID + expect(screen.getByTestId('proposal-details-contractid-label').textContent).toBe( + VOTE_PROPOSAL_CONTRACT_ID_LABEL + ); + expect(screen.getByTestId('proposal-details-contractid-id')).toHaveStyle({ width: '100%' }); + expect(screen.getByTestId('proposal-details-contractid-id-scroll')).toHaveStyle({ + overflowX: 'auto', + width: '100%', + }); + const contractIdLabel = screen.getByTestId('proposal-details-contractid-label'); + expect( + action.compareDocumentPosition(offboardSection) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + offboardSection.compareDocumentPosition(summary) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect(summary.compareDocumentPosition(url) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect( + url.compareDocumentPosition(contractIdLabel) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); const votingInformationSection = screen.getByTestId('proposal-details-voting-information'); expect(votingInformationSection).toBeInTheDocument(); @@ -187,6 +245,26 @@ describe('Proposal Details Content', () => { ); expect(requesterInput).toBeInTheDocument(); expect(requesterInput.textContent).toBe('sv1'); + expect( + within(votingInformationSection).getByTestId('proposal-details-requester-party-id') + ).toHaveStyle({ width: '100%' }); + expect( + within(votingInformationSection).getByTestId('proposal-details-requester-party-id-scroll') + ).toHaveStyle({ overflowX: 'auto', width: '100%' }); + + expect(screen.getByTestId('proposal-details-created-at-label').textContent).toBe( + PROPOSAL_CREATED_LABEL + ); + expect(screen.getByTestId('proposal-details-created-at-value').textContent).toBe( + '2025-01-01 13:00' + ); + + expect(screen.getByTestId('proposal-details-threshold-deadline-label').textContent).toBe( + THRESHOLD_DEADLINE_LABEL + ); + expect(screen.getByTestId('proposal-details-effective-at-label').textContent).toBe( + EFFECTIVE_AT_LABEL + ); const votingClosesIso = within(votingInformationSection).getByTestId( 'proposal-details-voting-closes-value' @@ -209,7 +287,12 @@ describe('Proposal Details Content', () => { expect(screen.getByTestId('your-vote-form')).toBeInTheDocument(); expect(screen.getByTestId('your-vote-url-input')).toBeInTheDocument(); - expect(screen.getByTestId('your-vote-reason-input')).toBeInTheDocument(); + const reasonInput = screen.getByTestId('your-vote-reason-input'); + expect(reasonInput).toBeInTheDocument(); + expect(reasonInput.getAttribute('placeholder')).toBe(VOTE_REASON_PLACEHOLDER); + expect(screen.getByTestId('your-vote-url-input').getAttribute('placeholder')).toBe( + VOTE_REASON_URL_PLACEHOLDER + ); expect(screen.getByTestId('your-vote-accept')).toBeInTheDocument(); expect(screen.getByTestId('your-vote-reject')).toBeInTheDocument(); }); @@ -285,6 +368,83 @@ describe('Proposal Details Content', () => { expect(rightContractIdValue.textContent).toMatch(/rightContractId/); }); + test('should render update featured app proposal details', async () => { + const updateFeaturedAppDetails = { + actionName: 'Update Featured Application', + action: 'SRARC_UpdateFeaturedAppRight', + proposal: { + rightContractId: 'rightCid123', + newActivityWeight: '2.5', + } as UpdateFeatureAppProposal, + } as ProposalDetails; + + render( + + + + ); + + const action = screen.getByTestId('proposal-details-action-value'); + expect(action.textContent).toMatch('Update Featured Application'); + + const updateFeaturedAppSection = screen.getByTestId( + 'proposal-details-update-feature-app-section' + ); + expect(updateFeaturedAppSection).toBeInTheDocument(); + + const updateFeaturedAppLabel = screen.getByTestId('proposal-details-update-feature-app-label'); + expect(updateFeaturedAppLabel.textContent).toMatch('Featured Application Contract ID'); + + const updateFeaturedAppValue = screen.getByTestId('proposal-details-update-feature-app-value'); + expect(updateFeaturedAppValue.textContent).toMatch('rightCid123'); + + await waitFor(() => { + const currentFeaturedAppWeight = screen.getByTestId('config-change-current-value'); + expect(currentFeaturedAppWeight.textContent).toMatch('1.0'); + }); + + const newFeaturedAppWeight = screen.getByTestId('config-change-new-value'); + expect(newFeaturedAppWeight.textContent).toMatch('2.5'); + }); + + test('should show only new weight when featured app right is not found', async () => { + const updateFeaturedAppDetails = { + actionName: 'Update Featured Application', + action: 'SRARC_UpdateFeaturedAppRight', + proposal: { + rightContractId: 'archivedRightCid', // <- not 'rightCid123', so the mock returns not-found + newActivityWeight: '2.5', + reason: 'boosting rewards', + } as UpdateFeatureAppProposal, + } as ProposalDetails; + + render( + + + + ); + + // new value still shows + await waitFor(() => { + const newFeaturedAppWeight = screen.getByTestId('config-change-new-value'); + expect(newFeaturedAppWeight.textContent).toMatch('2.5'); + }); + // ...but there's no current-value box (contract archived → currentWeight '') + expect(screen.queryByTestId('config-change-current-value')).toBeNull(); + }); + test('should render update sv reward weight proposal details', () => { const svToUpdate = 'sv2'; const updateSvRewardWeightDetails = { @@ -417,7 +577,96 @@ describe('Proposal Details Content', () => { const maxNumInputsNewValue = within(changes[1]).getByTestId('config-change-new-value'); expect(maxNumInputsNewValue.textContent).toBe('4'); - expect(screen.getByTestId('json-diffs-details')).toBeInTheDocument(); + const jsonDiffsToggle = screen.getByTestId('json-diff-toggle'); + expect(jsonDiffsToggle).toHaveTextContent('Show JSON'); + expect(jsonDiffsToggle).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText('JSON')).not.toBeInTheDocument(); + expect(screen.getByTestId('json-diffs-details')).not.toBeVisible(); + + expect( + screen.queryByTestId('proposal-details-disabled-fields-warning') + ).not.toBeInTheDocument(); + }); + + test('should warn when disabled fields were altered in an amulet rules config proposal', () => { + const amuletRulesConfigDetails = { + actionName: 'Set Amulet Rules Config', + action: 'CRARC_SetConfig', + proposal: { + configChanges: [ + { + fieldName: 'transferConfigCreateFee', + label: 'Transfer (Create Fee)', + currentValue: '0.03', + newValue: '0.04', + }, + { + fieldName: 'decentralizedSynchronizerActiveSynchronizer', + label: 'The currently active synchronizer', + currentValue: 'global-domain::12', + newValue: 'global-domain::13', + isId: true, + disabled: true, + }, + ], + }, + } as ProposalDetails; + + render( + + + + ); + + const warning = screen.getByTestId('proposal-details-disabled-fields-warning'); + expect(warning).toBeInTheDocument(); + expect(warning.textContent).toMatch(/Disabled fields have been altered in this vote proposal/); + + const changes = screen.getAllByTestId('config-change'); + expect(changes[1]).toHaveAttribute('data-disabled', 'true'); + expect(within(changes[1]).getByTestId('config-change-disabled-label')).toHaveTextContent( + 'Disabled field' + ); + }); + + test('should warn when disabled fields were altered in a dso rules config proposal', () => { + const dsoRulesConfigDetails = { + actionName: 'Set DSO Rules Configuration', + action: 'SRARC_SetConfig', + proposal: { + configChanges: [ + { + fieldName: 'decentralizedSynchronizerActiveSynchronizerId', + label: 'Decentralized synchronizer: Active synchronizer identifier', + currentValue: 'global-domain::12', + newValue: 'global-domain::13', + isId: true, + disabled: true, + }, + ], + }, + } as ProposalDetails; + + render( + + + + ); + + expect(screen.getByTestId('proposal-details-disabled-fields-warning')).toBeInTheDocument(); + expect(screen.getByTestId('config-change-disabled-label')).toHaveTextContent('Disabled field'); }); test('should render dso rules config changes', () => { @@ -498,7 +747,11 @@ describe('Proposal Details Content', () => { ); expect(dsoNumUnclaimedRewardsThresholdNewValue.textContent).toBe('20'); - expect(screen.getByTestId('json-diffs-details')).toBeInTheDocument(); + const jsonDiffsToggle = screen.getByTestId('json-diff-toggle'); + expect(jsonDiffsToggle).toHaveTextContent('Show JSON'); + expect(jsonDiffsToggle).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText('JSON')).not.toBeInTheDocument(); + expect(screen.getByTestId('json-diffs-details')).not.toBeVisible(); }); }); @@ -575,6 +828,14 @@ describe('Proposal Details > Votes & Voting', () => { expect(acceptedVotesTab.getAttribute('aria-selected')).toBe('false'); expect(rejectedVotesTab.getAttribute('aria-selected')).toBe('false'); expect(noVoteVotesTab.getAttribute('aria-selected')).toBe('false'); + + const voterScroll = screen.getAllByTestId('proposal-details-voter-party-id-scroll'); + expect(voterScroll.length).toBeGreaterThan(0); + expect(voterScroll[0]).toHaveStyle({ overflowX: 'auto' }); + // Votes rows fill width and scroll — no fixed 270px cap. + expect(screen.getAllByTestId('proposal-details-voter-party-id')[0]).toHaveStyle({ + width: '100%', + }); }); test('should filter votes by tabs', async () => { @@ -795,9 +1056,11 @@ describe('Proposal Details > Votes & Voting', () => { const votingFormUrlInput = within(votingForm).getByTestId('your-vote-url-input'); expect(votingFormUrlInput).toBeInTheDocument(); + expect(votingFormUrlInput).toHaveAttribute('placeholder', URL_PLACEHOLDER); const votingFormReasonInput = within(votingForm).getByTestId('your-vote-reason-input'); expect(votingFormReasonInput).toBeInTheDocument(); + expect(votingFormReasonInput).toHaveAttribute('placeholder', VOTE_REASON_PLACEHOLDER); const votingFormAccept = within(votingForm).getByTestId('your-vote-accept'); expect(votingFormAccept).toBeInTheDocument(); @@ -848,6 +1111,12 @@ describe('Proposal Details > Votes & Voting', () => { expect(acceptButton.textContent).toMatch(/Accept/); expect(rejectButton).toBeInTheDocument(); expect(rejectButton.textContent).toMatch(/Reject/); + // Figma / #6912: Reject (left) → Accept (right); primary on the right + const voteButtons = within(votingForm).getAllByRole('button'); + expect(voteButtons.map(b => b.getAttribute('data-testid'))).toEqual([ + 'your-vote-reject', + 'your-vote-accept', + ]); }); test('render success message after api returns success', async () => { @@ -901,8 +1170,10 @@ describe('Proposal Details > Votes & Voting', () => { // This is because awaiting the button click makes it very difficult for the test runner to see the loading state user.click(acceptButton); - await waitFor(async () => { - expect(acceptButton.getAttribute('disabled')).toBeDefined(); + // once submission starts, the vote buttons are unmounted (replaced by the + // "Submitting..." state and then the submission message) + await waitFor(() => { + expect(acceptButton).not.toBeInTheDocument(); }); const submissionMessage = await screen.findByTestId('submission-message'); @@ -964,8 +1235,10 @@ describe('Proposal Details > Votes & Voting', () => { // This is because awaiting the button click makes it very difficult for the test runner to see the loading state user.click(acceptButton); - await waitFor(async () => { - expect(acceptButton.getAttribute('disabled')).toBeDefined(); + // once submission starts, the vote buttons are unmounted (replaced by the + // "Submitting..." state and then the submission message) + await waitFor(() => { + expect(acceptButton).not.toBeInTheDocument(); }); const submissionMessage = await screen.findByTestId('submission-message'); @@ -1060,3 +1333,127 @@ describe('Proposal Details > Votes & Voting', () => { expect(rejectButton).not.toBeDisabled(); }); }); + +describe('Open vote request whose effectivity has passed', () => { + const pastEffectivity = { + requester: 'sv1', + requesterIsYou: true, + votingThresholdDeadline: '2024-01-01 13:00', + voteTakesEffect: '2024-01-02 13:00', + status: 'In Progress', + } as ProposalVotingInformation; + + test('shows the vote form to an SV that has not voted', () => { + const votes: ProposalVote[] = [ + { sv: 'sv1', isYou: true, vote: 'no-vote' }, + { sv: 'sv3', vote: 'accepted', reason: { url: 'https://example.com', body: 'Reason' } }, + ]; + + render( + + + + ); + + const votingForm = screen.getByTestId('your-vote-form'); + expect(votingForm).toBeInTheDocument(); + expect(within(votingForm).getByTestId('your-vote-accept')).toBeInTheDocument(); + expect(within(votingForm).getByTestId('your-vote-reject')).toBeInTheDocument(); + }); + + test('shows the change-vote control to an SV that has already voted', async () => { + const user = userEvent.setup(); + const votes: ProposalVote[] = [ + { + sv: 'sv1', + isYou: true, + vote: 'accepted', + reason: { url: 'https://example.com', body: 'Reason' }, + }, + { sv: 'sv3', vote: 'accepted', reason: { url: 'https://example.com', body: 'Reason' } }, + ]; + + render( + + + + ); + + // An SV that has voted sees the Edit control rather than the form, until it starts editing. + expect(screen.queryByTestId('your-vote-form')).not.toBeInTheDocument(); + + const editButton = screen.getByTestId('your-vote-edit-button'); + expect(editButton).toBeInTheDocument(); + + await user.click(editButton); + + const votingForm = screen.getByTestId('your-vote-form'); + expect(votingForm).toBeInTheDocument(); + expect(within(votingForm).getByTestId('your-vote-reject')).toBeInTheDocument(); + }); + + test('shows SVs that have not voted as awaiting a response', () => { + const votes: ProposalVote[] = [ + { + sv: 'sv1', + isYou: true, + vote: 'accepted', + reason: { url: 'https://example.com', body: 'Reason' }, + }, + { sv: 'sv3', vote: 'no-vote' }, + ]; + + render( + + + + ); + + const noVoteTab = screen.getByTestId('no-vote-votes-tab'); + expect(noVoteTab.textContent).toMatch(/Awaiting Response/); + expect(noVoteTab.textContent).not.toMatch(/Did not Vote/); + + const statuses = screen + .getAllByTestId('proposal-details-vote-status-value') + .map(s => s.textContent); + expect(statuses).toContain('Awaiting Response'); + expect(statuses).not.toContain('No Vote'); + }); +}); + +describe('Closed proposal', () => { + test('does not show the vote form or the change-vote control', () => { + render( + + + + ); + + expect(screen.queryByTestId('your-vote-form')).not.toBeInTheDocument(); + expect(screen.queryByTestId('your-vote-edit-button')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/sv/frontend/src/__tests__/governance/proposal-listing.test.tsx b/apps/sv/frontend/src/__tests__/governance/proposal-listing.test.tsx index 98eaac5705..21fe59d81b 100644 --- a/apps/sv/frontend/src/__tests__/governance/proposal-listing.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/proposal-listing.test.tsx @@ -7,11 +7,16 @@ import { VoteRequest } from '@daml.js/splice-dso-governance/lib/Splice/DsoRules' import { ContractId } from '@daml/types'; import { ProposalListingData } from '../../utils/types'; import { MemoryRouter } from 'react-router'; +import { svPartyId, voteRequests } from '../mocks/constants'; + +const sampleContractId = voteRequests.dso_rules_vote_requests[0] + .contract_id as ContractId; const inflightVoteRequests: ProposalListingData[] = [ { actionName: 'Feature Application', - contractId: '2abcde123456' as ContractId, + contractId: sampleContractId, + requester: svPartyId, votingThresholdDeadline: '2025-09-25 11:00', voteTakesEffect: '2025-09-26 11:00', yourVote: 'no-vote', @@ -21,7 +26,8 @@ const inflightVoteRequests: ProposalListingData[] = [ }, { actionName: 'Set DSO Rules Configuration', - contractId: 'bcde123456' as ContractId, + contractId: voteRequests.dso_rules_vote_requests[1].contract_id as ContractId, + requester: svPartyId, votingThresholdDeadline: '2025-09-25 11:00', voteTakesEffect: '2025-09-26 11:00', yourVote: 'accepted', @@ -34,7 +40,8 @@ const inflightVoteRequests: ProposalListingData[] = [ const voteHistory: ProposalListingData[] = [ { actionName: 'Feature Application', - contractId: '2abcde123456' as ContractId, + contractId: sampleContractId, + requester: svPartyId, votingThresholdDeadline: '2025-09-25 11:00', voteTakesEffect: '2025-09-26 11:00', yourVote: 'no-vote', @@ -44,7 +51,8 @@ const voteHistory: ProposalListingData[] = [ }, { actionName: 'Set DSO Rules Configuration', - contractId: '2bcde123456' as ContractId, + contractId: voteRequests.dso_rules_vote_requests[1].contract_id as ContractId, + requester: svPartyId, votingThresholdDeadline: '2025-09-25 11:00', voteTakesEffect: '2025-09-26 11:00', yourVote: 'accepted', @@ -93,6 +101,8 @@ describe('Inflight Vote Requests', () => { const uniqueId = 'proposals-request'; const data = { actionName: 'Feature Application', + contractId: sampleContractId, + requester: svPartyId, votingThresholdDeadline: '2025-09-25 11:00', voteTakesEffect: '2025-09-26 11:00', yourVote: 'no-vote', @@ -136,10 +146,49 @@ describe('Inflight Vote Requests', () => { expect(yourVote.textContent).toMatch(/No Vote/); }); + test('should render submitted by column with full party id and copy button', () => { + const uniqueId = 'proposals-request'; + const data = { + actionName: 'Feature Application', + contractId: sampleContractId, + requester: svPartyId, + votingThresholdDeadline: '2025-09-25 11:00', + voteTakesEffect: '2025-09-26 11:00', + yourVote: 'accepted', + status: 'In Progress', + voteStats: { accepted: 0, rejected: 0, 'no-vote': 0 }, + acceptanceThreshold: BigInt(11), + } as ProposalListingData; + + render( + + + + ); + + expect(screen.getByText('SUBMITTED BY')).toBeInTheDocument(); + expect(screen.getByTestId(`${uniqueId}-row-submitted-by-identifier-value`).textContent).toBe( + svPartyId + ); + expect( + screen.getByTestId(`${uniqueId}-row-submitted-by-identifier-copy-button`) + ).toBeInTheDocument(); + expect( + screen.queryByTestId(`${uniqueId}-row-submitted-by-identifier-badge`) + ).not.toBeInTheDocument(); + }); + test('should render Accepted inflight vote request', () => { const uniqueId = 'proposals-request'; const data = { actionName: 'Feature Application', + contractId: sampleContractId, + requester: svPartyId, votingThresholdDeadline: '2025-09-25 11:00', voteTakesEffect: '2025-09-26 11:00', yourVote: 'accepted', @@ -171,6 +220,8 @@ describe('Inflight Vote Requests', () => { const uniqueId = 'proposals-request'; const data = { actionName: 'Feature Application', + contractId: sampleContractId, + requester: svPartyId, votingThresholdDeadline: '2025-09-25 11:00', voteTakesEffect: '2025-09-26 11:00', yourVote: 'rejected', @@ -255,6 +306,8 @@ describe('Vote history', () => { const uniqueId = 'vote-history'; const data = { actionName: 'Feature Application', + contractId: sampleContractId, + requester: svPartyId, votingThresholdDeadline: '2024-09-25 11:00', voteTakesEffect: '2024-09-26 11:00', yourVote: 'accepted', diff --git a/apps/sv/frontend/src/__tests__/governance/proposal-search-validation.test.ts b/apps/sv/frontend/src/__tests__/governance/proposal-search-validation.test.ts new file mode 100644 index 0000000000..ede3aa3fd0 --- /dev/null +++ b/apps/sv/frontend/src/__tests__/governance/proposal-search-validation.test.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { activeProposalCid, closedVoteCid } from '../mocks/constants'; +import { isValidContractId } from '../../utils/proposalSearch'; + +describe('isValidContractId', () => { + test.each([ + [activeProposalCid, true], + [closedVoteCid, true], + [`00${'ab'.repeat(32)}`, true], + [`01${'ab'.repeat(12)}`, true], + ['', false], + ['not-a-valid-contract-id', false], + [`00${'a'.repeat(65)}`, false], + [`00${'ab'.repeat(31)}`, false], + [`01${'a'.repeat(25)}`, false], + ['00', false], + ])('%p -> %s', (value, expected) => { + expect(isValidContractId(value)).toBe(expected); + }); +}); diff --git a/apps/sv/frontend/src/__tests__/governance/proposal-search.test.tsx b/apps/sv/frontend/src/__tests__/governance/proposal-search.test.tsx new file mode 100644 index 0000000000..3a525043b9 --- /dev/null +++ b/apps/sv/frontend/src/__tests__/governance/proposal-search.test.tsx @@ -0,0 +1,83 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; +import { MemoryRouter, Route, Routes, useSearchParams } from 'react-router'; +import { ProposalSearch } from '../../components/governance/ProposalSearch'; +import { activeProposalCid } from '../mocks/constants'; + +const SearchHarness: React.FC<{ onSearchChange: (query: string) => void }> = ({ + onSearchChange, +}) => { + const [searchParams, setSearchParams] = useSearchParams(); + + return ( + <> + + {searchParams.get('q') ?? ''} + + + ); +}; + +describe('ProposalSearch', () => { + test('does not reset input when stale URL update arrives while typing ahead', async () => { + const onSearchChange = vi.fn(); + render( + + + } /> + + + ); + + const input = screen.getByTestId('proposal-search-input'); + fireEvent.change(input, { target: { value: 'abc' } }); + + expect(input).toHaveValue('abc'); + expect(onSearchChange).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByTestId('set-stale-url')); + + expect(input).toHaveValue('abc'); + expect(onSearchChange).not.toHaveBeenCalled(); + }); + + test('syncs input from URL on initial load', () => { + render( + + + } /> + + + ); + + expect(screen.getByTestId('proposal-search-input')).toHaveValue('from-url'); + }); + + test('clear search removes q from the URL', () => { + const onSearchChange = vi.fn(); + render( + + + } /> + + + ); + + expect(screen.getByTestId('url-q').textContent).toBe(activeProposalCid); + + fireEvent.click(screen.getByTestId('proposal-search-clear')); + + expect(screen.getByTestId('proposal-search-input')).toHaveValue(''); + expect(screen.getByTestId('url-q').textContent).toBe(''); + expect(onSearchChange).toHaveBeenLastCalledWith(''); + }); +}); diff --git a/apps/sv/frontend/src/__tests__/governance/proposal-summary.test.tsx b/apps/sv/frontend/src/__tests__/governance/proposal-summary.test.tsx index 8922fa5003..7eca1496df 100644 --- a/apps/sv/frontend/src/__tests__/governance/proposal-summary.test.tsx +++ b/apps/sv/frontend/src/__tests__/governance/proposal-summary.test.tsx @@ -3,6 +3,13 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import { ProposalSummary } from '../../components/governance/ProposalSummary'; +import { + CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + EFFECTIVE_AT_LABEL, + PROPOSAL_REVIEW_TITLE, + SUPPORTING_URL_LABEL, + THRESHOLD_DEADLINE_LABEL, +} from '../../utils/constants'; import { ConfigChange } from '../../utils/types'; const url = 'https://example.com'; @@ -10,6 +17,29 @@ const summary = 'Summary of the proposal'; const expiryDate = '2025-09-25 11:00'; const effectiveDate = '2025-09-26 11:00'; +/** Shared labels for the post-rebase ProposalSummary / ProposalReviewField chrome. */ +const REVIEW_LABELS = { + title: PROPOSAL_REVIEW_TITLE, + action: CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + expiryDate: THRESHOLD_DEADLINE_LABEL, + effectiveDate: EFFECTIVE_AT_LABEL, + summary: 'Proposal Summary', + url: SUPPORTING_URL_LABEL, +} as const; + +function expectCommonReviewFields(actionName: string) { + expect(screen.getByTestId('proposal-review-title').textContent).toBe(REVIEW_LABELS.title); + expect(screen.getByTestId('action-title').textContent).toBe(REVIEW_LABELS.action); + expect(screen.getByTestId('action-field').textContent).toBe(actionName); + expect(screen.getByTestId('url-title').textContent).toBe(REVIEW_LABELS.url); + expect(screen.getByTestId('url-field').textContent).toBe(url); + expect(screen.getByTestId('summary-title').textContent).toBe(REVIEW_LABELS.summary); + expect(screen.getByTestId('summary-field').textContent).toBe(summary); + expect(screen.getByTestId('expiryDate-title').textContent).toBe(REVIEW_LABELS.expiryDate); + expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate); + expect(screen.getByTestId('effectiveDate-title').textContent).toBe(REVIEW_LABELS.effectiveDate); +} + describe('Review Proposal Component', () => { test('should render review proposal component for offboard member', () => { const actionName = 'Offboard Member'; @@ -29,23 +59,12 @@ describe('Review Proposal Component', () => { /> ); - expect(screen.getByTestId('action-title').textContent).toBe('Action'); - expect(screen.getByTestId('action-field').textContent).toBe(actionName); - - expect(screen.getByTestId('url-title').textContent).toBe('URL'); - expect(screen.getByTestId('url-field').textContent).toBe(url); - - expect(screen.getByTestId('summary-title').textContent).toBe('Summary'); - expect(screen.getByTestId('summary-field').textContent).toBe(summary); - - expect(screen.getByTestId('expiryDate-title').textContent).toBe('Threshold Deadline'); - expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate); - - expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date'); + expectCommonReviewFields(actionName); expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate); - expect(screen.getByTestId('offboardMember-title').textContent).toBe('Offboard Member'); - expect(screen.getByTestId('offboardMember-field').textContent).toBe(offboardMember); + expect(screen.getByTestId('offboardMember-title').textContent).toBe('Member'); + expect(screen.getByTestId('offboardMember-party-id-value').textContent).toBe(offboardMember); + expect(screen.getByTestId('offboardMember-party-id-copy-button')).toBeInTheDocument(); }); test('should render review proposal component for offboard member at Threshold', () => { @@ -66,7 +85,7 @@ describe('Review Proposal Component', () => { /> ); - expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date'); + expect(screen.getByTestId('effectiveDate-title').textContent).toBe(EFFECTIVE_AT_LABEL); expect(screen.getByTestId('effectiveDate-field').textContent).toBe('Threshold'); }); @@ -93,21 +112,16 @@ describe('Review Proposal Component', () => { /> ); - expect(screen.getByTestId('action-title').textContent).toBe('Action'); - expect(screen.getByTestId('action-field').textContent).toBe(actionName); - - expect(screen.getByTestId('url-title').textContent).toBe('URL'); - expect(screen.getByTestId('url-field').textContent).toBe(url); - - expect(screen.getByTestId('summary-title').textContent).toBe('Summary'); - expect(screen.getByTestId('summary-field').textContent).toBe(summary); - - expect(screen.getByTestId('expiryDate-title').textContent).toBe('Threshold Deadline'); - expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate); - - expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date'); + expectCommonReviewFields(actionName); expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate); + expect(screen.getByTestId('svRewardWeightMember-title').textContent).toBe('Member'); + expect(screen.getByTestId('svRewardWeightMember-party-id-value').textContent).toBe( + svRewardWeightMember + ); + expect(screen.getByTestId('svRewardWeightMember-party-id-copy-button')).toBeInTheDocument(); + + expect(screen.getByTestId('configChange-title').textContent).toBe('Proposed Changes'); expect(screen.getByTestId('config-change-field-label').textContent).toBe(title); expect(screen.getByTestId('config-change-current-value').textContent).toBe(currentWeight); expect(screen.getByTestId('config-change-new-value').textContent).toBe(svRewardWeight); @@ -116,6 +130,7 @@ describe('Review Proposal Component', () => { test('should render review proposal component for feature application', () => { const actionName = 'Feature Application'; const provider = 'Digital-Asset-Eng-2'; + const activityWeight = '2.5'; render( { effectiveDate={effectiveDate} formType="grant-right" grantRight={provider} + activityWeight={activityWeight} onEdit={() => {}} onSubmit={() => {}} /> ); - expect(screen.getByTestId('action-title').textContent).toBe('Action'); - expect(screen.getByTestId('action-field').textContent).toBe(actionName); - - expect(screen.getByTestId('url-title').textContent).toBe('URL'); - expect(screen.getByTestId('url-field').textContent).toBe(url); - - expect(screen.getByTestId('summary-title').textContent).toBe('Summary'); - expect(screen.getByTestId('summary-field').textContent).toBe(summary); - - expect(screen.getByTestId('expiryDate-title').textContent).toBe('Threshold Deadline'); - expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate); - - expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date'); + expectCommonReviewFields(actionName); expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate); expect(screen.getByTestId('grantRight-title').textContent).toBe('Provider Party ID'); - expect(screen.getByTestId('grantRight-field').textContent).toBe(provider); + expect(screen.getByTestId('grantRight-party-id-value').textContent).toBe(provider); + expect(screen.getByTestId('grantRight-party-id-copy-button')).toBeInTheDocument(); + + expect(screen.getByTestId('grantRightActivityWeight-title').textContent).toBe( + 'Activity Weight' + ); + expect(screen.getByTestId('grantRightActivityWeight-field').textContent).toBe(activityWeight); }); test('should render review proposal component for unfeature application', () => { @@ -170,28 +180,66 @@ describe('Review Proposal Component', () => { /> ); - expect(screen.getByTestId('action-title').textContent).toBe('Action'); - expect(screen.getByTestId('action-field').textContent).toBe(actionName); + expectCommonReviewFields(actionName); + expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate); + + expect(screen.getByTestId('revokeProviderPartyId-title').textContent).toBe('Provider Party ID'); + expect(screen.getByTestId('revokeProviderPartyId-party-id-value').textContent).toBe( + providerPartyId + ); + expect(screen.getByTestId('revokeProviderPartyId-party-id-copy-button')).toBeInTheDocument(); - expect(screen.getByTestId('url-title').textContent).toBe('URL'); - expect(screen.getByTestId('url-field').textContent).toBe(url); + expect(screen.getByTestId('revokeRight-title').textContent).toBe( + 'Featured Application Contract ID' + ); + expect(screen.getByTestId('revokeRight-field').textContent).toBe(contractId); + }); - expect(screen.getByTestId('summary-title').textContent).toBe('Summary'); - expect(screen.getByTestId('summary-field').textContent).toBe(summary); + test('should render review proposal component for update feature application', () => { + const actionName = 'Update Featured Application'; + const providerPartyId = 'a-party-id::1014912492'; + const rightCid = 'bcde123456'; + const currentActivityWeight = '1.0'; + const newActivityWeight = '2.5'; - expect(screen.getByTestId('expiryDate-title').textContent).toBe('Threshold Deadline'); - expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate); + render( + {}} + onSubmit={() => {}} + /> + ); - expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date'); + expectCommonReviewFields(actionName); expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate); - expect(screen.getByTestId('revokeProviderPartyId-title').textContent).toBe('Provider Party ID'); - expect(screen.getByTestId('revokeProviderPartyId-field').textContent).toBe(providerPartyId); + expect(screen.getByTestId('updateProviderPartyId-title').textContent).toBe('Provider Party ID'); + expect(screen.getByTestId('updateProviderPartyId-party-id-value').textContent).toBe( + providerPartyId + ); + expect(screen.getByTestId('updateProviderPartyId-party-id-copy-button')).toBeInTheDocument(); - expect(screen.getByTestId('revokeRight-title').textContent).toBe( + expect(screen.getByTestId('updateRight-title').textContent).toBe( 'Featured Application Contract ID' ); - expect(screen.getByTestId('revokeRight-field').textContent).toBe(contractId); + expect(screen.getByTestId('updateRight-field').textContent).toBe(rightCid); + + expect(screen.getByTestId('updateActivityWeight-title').textContent).toBe('Proposed Changes'); + expect(screen.getByTestId('config-change-current-value').textContent).toBe( + currentActivityWeight + ); + expect(screen.getByTestId('config-change-new-value').textContent).toBe(newActivityWeight); + + expect(screen.queryByTestId('updateReason-field')).not.toBeInTheDocument(); }); test('should render review proposal component for dso rules config', () => { @@ -228,22 +276,12 @@ describe('Review Proposal Component', () => { /> ); - expect(screen.getByTestId('action-title').textContent).toBe('Action'); - expect(screen.getByTestId('action-field').textContent).toBe(actionName); - - expect(screen.getByTestId('url-title').textContent).toBe('URL'); - expect(screen.getByTestId('url-field').textContent).toBe(url); - - expect(screen.getByTestId('summary-title').textContent).toBe('Summary'); - expect(screen.getByTestId('summary-field').textContent).toBe(summary); - - expect(screen.getByTestId('expiryDate-title').textContent).toBe('Threshold Deadline'); - expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate); - - expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date'); + expectCommonReviewFields(actionName); expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate); - expect(screen.getByText('Proposed Changes')).toBeDefined(); + expect(screen.getByTestId('configChange-title').textContent).toBe( + 'Proposed Configuration Changes' + ); expect(screen.getByText(numThresholdTitle)).toBeDefined(); expect(screen.getByText(voteCooldownTitle)).toBeDefined(); @@ -300,22 +338,12 @@ describe('Review Proposal Component', () => { /> ); - expect(screen.getByTestId('action-title').textContent).toBe('Action'); - expect(screen.getByTestId('action-field').textContent).toBe(actionName); - - expect(screen.getByTestId('url-title').textContent).toBe('URL'); - expect(screen.getByTestId('url-field').textContent).toBe(url); - - expect(screen.getByTestId('summary-title').textContent).toBe('Summary'); - expect(screen.getByTestId('summary-field').textContent).toBe(summary); - - expect(screen.getByTestId('expiryDate-title').textContent).toBe('Threshold Deadline'); - expect(screen.getByTestId('expiryDate-field').textContent).toBe(expiryDate); - - expect(screen.getByTestId('effectiveDate-title').textContent).toBe('Effective Date'); + expectCommonReviewFields(actionName); expect(screen.getByTestId('effectiveDate-field').textContent).toBe(effectiveDate); - expect(screen.getByText('Proposed Changes')).toBeDefined(); + expect(screen.getByTestId('configChange-title').textContent).toBe( + 'Proposed Configuration Changes' + ); expect(screen.getByText(feeTitle)).toBeDefined(); expect(screen.getByText(feeRateTitle)).toBeDefined(); diff --git a/apps/sv/frontend/src/__tests__/layout/sv-top-nav.test.tsx b/apps/sv/frontend/src/__tests__/layout/sv-top-nav.test.tsx new file mode 100644 index 0000000000..4f0d14f221 --- /dev/null +++ b/apps/sv/frontend/src/__tests__/layout/sv-top-nav.test.tsx @@ -0,0 +1,35 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { describe, expect, test, vi } from 'vitest'; + +import SvTopNav from '../../components/layout/SvTopNav'; + +const navLinks = [ + { name: 'Global Synchronizer Information', path: '/dso' }, + { name: 'Governance', path: '/governance' }, + { name: 'Teluma Price', path: '/amulet-price' }, + { name: 'Validators', path: '/validator-onboarding' }, +]; + +describe('SvTopNav', () => { + test('renders brand, centered nav cluster, and logout', () => { + render( + + + + ); + + expect(screen.getByTestId('app-title')).toHaveTextContent('Supervalidator Operations'); + expect(screen.getByTestId('sv-top-nav-links')).toBeInTheDocument(); + expect(screen.getByTestId('navlink-dso')).toBeInTheDocument(); + expect(screen.getByTestId('navlink-governance')).toBeInTheDocument(); + expect(screen.getByTestId('logout-button')).toBeInTheDocument(); + + const row = screen.getByTestId('sv-top-nav'); + expect(row).toHaveStyle({ display: 'flex' }); + expect(screen.getByTestId('sv-top-nav-spacer-start')).toBeInTheDocument(); + expect(screen.getByTestId('sv-top-nav-spacer-end')).toBeInTheDocument(); + }); +}); diff --git a/apps/sv/frontend/src/__tests__/mocks/constants.ts b/apps/sv/frontend/src/__tests__/mocks/constants.ts index 9abb24ca93..5ef3a43091 100644 --- a/apps/sv/frontend/src/__tests__/mocks/constants.ts +++ b/apps/sv/frontend/src/__tests__/mocks/constants.ts @@ -292,7 +292,8 @@ export const voteResultsDsoRules: ListDsoRulesVoteResultsResponse = { url: '', body: 'd', }, - trackingCid: null, + trackingCid: + '99f1a2cbcd5a2dc9ad2fb9d17fec183d75de19ca91f623cbd2eaaf634e8d7cb4b5ca101220b5c5c20442f608e151ca702e0c4f51341a338c5979c0547dfcc80f911061ca99', action: getDsoSetConfigAction({ new: '2200', base: '100' }), }, completedAt: '2024-10-01T22:10:01.253341Z', @@ -388,3 +389,8 @@ if (!result.ok) { } export const svPartyId = dsoInfo.sv_party_id; + +export const activeProposalCid = + '10f1a2cbcd5a2dc9ad2fb9d17fec183d75de19ca91f623cbd2eaaf634e8d7cb4b5ca101220b5c5c20442f608e151ca702e0c4f51341a338c5979c0547dfcc80f911061ca91'; +export const closedVoteCid = + '99f1a2cbcd5a2dc9ad2fb9d17fec183d75de19ca91f623cbd2eaaf634e8d7cb4b5ca101220b5c5c20442f608e151ca702e0c4f51341a338c5979c0547dfcc80f911061ca99'; diff --git a/apps/sv/frontend/src/__tests__/mocks/handlers/sv-api.ts b/apps/sv/frontend/src/__tests__/mocks/handlers/sv-api.ts index c81c8f072c..e335af9bf9 100644 --- a/apps/sv/frontend/src/__tests__/mocks/handlers/sv-api.ts +++ b/apps/sv/frontend/src/__tests__/mocks/handlers/sv-api.ts @@ -8,6 +8,8 @@ import dayjs from 'dayjs'; import { http, HttpHandler, HttpResponse, PathParams } from 'msw'; import { FeatureSupportResponse, SuccessStatusResponse } from '@canton-network/scan-openapi'; import { + CountVoteResultsRequest, + CountVoteResultsResponse, ErrorResponse, ListDsoRulesVoteRequestsResponse, ListDsoRulesVoteResultsResponse, @@ -159,6 +161,28 @@ export const buildSvMock = (svUrl: string): HttpHandler[] => [ } ), + http.post( + `${svUrl}/v0/admin/sv/voteresults/count`, + ({ request }) => { + return request.json().then(data => { + const count = voteResultsAmuletRules.dso_rules_vote_results + .concat(voteResultsDsoRules.dso_rules_vote_results) + .filter(r => { + const isAccepted = r.outcome.tag === 'VRO_Accepted'; + const acceptedMatch = + data.accepted === undefined || data.accepted === null + ? true + : data.accepted === isAccepted; + const effectiveToMatch = data.effectiveTo + ? isAccepted && dayjs(r.outcome.value.effectiveAt).isBefore(dayjs(data.effectiveTo)) + : true; + return acceptedMatch && effectiveToMatch; + }).length; + return HttpResponse.json({ count }); + }); + } + ), + http.post(`${svUrl}/v0/admin/sv/votes`, () => { return new HttpResponse(null, { status: 201 }); }), @@ -249,7 +273,7 @@ export const buildSvMock = (svUrl: string): HttpHandler[] => [ { template_id: 'featured-app-right-template-id', contract_id: 'rightCid123', - payload: {}, + payload: { activityWeight: '1.0' }, created_event_blob: '', created_at: '2026-02-26T13:00:00.000000Z', }, @@ -269,7 +293,7 @@ export const buildSvMock = (svUrl: string): HttpHandler[] => [ ? { template_id: 'featured-app-right-template-id', contract_id: 'rightCid123', - payload: { provider: 'a-party-id::1014912492' }, + payload: { provider: 'a-party-id::1014912492', activityWeight: '1.0' }, created_event_blob: '', created_at: '2026-02-26T13:00:00.000000Z', } diff --git a/apps/sv/frontend/src/__tests__/sv.test.tsx b/apps/sv/frontend/src/__tests__/sv.test.tsx index 99dc546dce..a687d97714 100644 --- a/apps/sv/frontend/src/__tests__/sv.test.tsx +++ b/apps/sv/frontend/src/__tests__/sv.test.tsx @@ -52,8 +52,8 @@ describe('SV user can', () => { const user = userEvent.setup(); render(); - expect(await screen.findByText('Validator Onboarding')).toBeDefined(); - await user.click(screen.getByText('Validator Onboarding')); + expect(await screen.findByText('Validators')).toBeDefined(); + await user.click(screen.getByText('Validators')); expect(await screen.findByText('Validator Onboarding Secrets')).toBeDefined(); }); @@ -62,8 +62,8 @@ describe('SV user can', () => { const user = userEvent.setup(); render(); - expect(await screen.findByText('Validator Onboarding')).toBeDefined(); - await user.click(screen.getByText('Validator Onboarding')); + expect(await screen.findByText('Validators')).toBeDefined(); + await user.click(screen.getByText('Validators')); const partyHintInput = screen.getByTestId('create-party-hint'); await user.type(partyHintInput, 'wrong-input'); @@ -209,7 +209,7 @@ describe('An SetConfig request', () => { ); const button = screen.getByRole('button', { name: 'Send Request to Super Validators' }); - expect(button.getAttribute('disabled')).toBeDefined(); + expect(button.getAttribute('disabled')).not.toBeNull(); }); test('displays a warning when an SV tries to modify an AmuletRules field already changed by another request', async () => { @@ -240,7 +240,7 @@ describe('An SetConfig request', () => { 'You are therefore not allowed to modify the fields: transferConfig.createFee.fee' ); const button = screen.getByTestId('create-voterequest-submit-button'); - expect(button.getAttribute('disabled')).toBeDefined(); + expect(button.getAttribute('disabled')).not.toBeNull(); }); test('disables the Proceed button in the confirmation dialog if a conflict arises after request creation', async () => { @@ -281,7 +281,10 @@ describe('An SetConfig request', () => { ); const button = screen.getByRole('button', { name: 'Proceed' }); - expect(button.getAttribute('disabled')).toBeDefined(); + // the conflict is only detected once the vote requests query re-polls (1s interval) + await waitFor(() => expect(button.getAttribute('disabled')).not.toBeNull(), { + timeout: 5000, + }); }); }); @@ -303,8 +306,8 @@ describe('An AddFutureAmuletConfigSchedule request', () => { const user = userEvent.setup(); render(); - expect(await screen.findByText('Validator Onboarding')).toBeDefined(); - await user.click(screen.getByText('Validator Onboarding')); + expect(await screen.findByText('Validators')).toBeDefined(); + await user.click(screen.getByText('Validators')); expect(await screen.findByText('Validator Licenses')).toBeDefined(); @@ -315,8 +318,8 @@ describe('An AddFutureAmuletConfigSchedule request', () => { expect(await screen.findByDisplayValue('validator::15')).toBeDefined(); // secrets - expect(await screen.queryByText('encoded_secret')).toBeDefined(); - expect(await screen.queryByText('candidate_secret')).toBeNull(); + expect(screen.queryByText('encoded_secret')).not.toBeNull(); + expect(screen.queryByText('candidate_secret')).toBeNull(); }); }); @@ -372,7 +375,7 @@ describe('SetAmuletRules', () => { (calledWithBody.action as any).value.amuletRulesAction.value.newConfig.transferConfig .transferFee.steps // the second element is gone - ).toStrictEqual(initialSteps.filter((_, i) => i !== 1)); + ).toStrictEqual(initialSteps.filter((_: unknown, i: number) => i !== 1)); }, { timeout: 20000 } ); diff --git a/apps/sv/frontend/src/__tests__/utils/buildAmuletRulesConfigFromChanges.test.ts b/apps/sv/frontend/src/__tests__/utils/buildAmuletRulesConfigFromChanges.test.ts index a0bea6e3d9..1035962057 100644 --- a/apps/sv/frontend/src/__tests__/utils/buildAmuletRulesConfigFromChanges.test.ts +++ b/apps/sv/frontend/src/__tests__/utils/buildAmuletRulesConfigFromChanges.test.ts @@ -220,19 +220,19 @@ describe('buildAmuletRulesConfigFromChanges', () => { }, { fieldName: 'rewardConfigMintingVersion', - label: 'Reward config: Minting version', + label: 'Reward config: Minting scheme', currentValue: 'RewardVersion_FeaturedAppMarkers', newValue: 'RewardVersion_TrafficBasedAppRewards', }, { fieldName: 'rewardConfigDryRunVersion', - label: 'Reward config: Dry-run version', + label: 'Reward config: Dry-run minting scheme', currentValue: '', newValue: 'RewardVersion_TrafficBasedAppRewards', }, { fieldName: 'rewardConfigBatchSize', - label: 'Reward config: Batch size', + label: 'Reward config: Merkle tree batch size', currentValue: '100', newValue: '200', }, diff --git a/apps/sv/frontend/src/__tests__/utils/getRequesterPartyId.test.ts b/apps/sv/frontend/src/__tests__/utils/getRequesterPartyId.test.ts new file mode 100644 index 0000000000..aed68de29d --- /dev/null +++ b/apps/sv/frontend/src/__tests__/utils/getRequesterPartyId.test.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import type { SvInfo } from '@daml.js/splice-dso-governance/lib/Splice/DsoRules'; +import { getRequesterPartyId } from '../../utils/governance'; + +const partyId = 'digital-asset-2::1220abc'; +const svName = 'Digital-Asset-2'; + +const svs = { + entriesArray: () => [[partyId, { name: svName } as SvInfo] as [string, SvInfo]], +}; + +describe('getRequesterPartyId', () => { + test('returns requester unchanged when it is already a party id', () => { + expect(getRequesterPartyId(partyId, svs)).toBe(partyId); + }); + + test('resolves sv name to party id', () => { + expect(getRequesterPartyId(svName, svs)).toBe(partyId); + }); + + test('returns requester when svs is undefined', () => { + expect(getRequesterPartyId(svName, undefined)).toBe(svName); + }); + + test('returns requester when sv is not in svs (e.g. offboarded)', () => { + expect(getRequesterPartyId('Offboarded-SV', svs)).toBe('Offboarded-SV'); + }); +}); diff --git a/apps/sv/frontend/src/components/Layout.tsx b/apps/sv/frontend/src/components/Layout.tsx index 8c69b6b673..c119706a6f 100644 --- a/apps/sv/frontend/src/components/Layout.tsx +++ b/apps/sv/frontend/src/components/Layout.tsx @@ -1,31 +1,49 @@ // Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 import * as React from 'react'; -import { - Header, - Loading, - useUserState, - useVotesHooks, -} from '@canton-network/splice-common-frontend'; +import { Loading, useUserState, useVotesHooks } from '@canton-network/splice-common-frontend'; -import { Logout } from '@mui/icons-material'; -import { Box, Button, Divider, Stack, Typography } from '@mui/material'; -import Container from '@mui/material/Container'; -import Link from '@mui/material/Link'; +import { Box, Container, GlobalStyles } from '@mui/material'; +import { useLocation } from 'react-router'; import { useFeatureSupport } from '../contexts/SvContext'; -import { useNetworkInstanceName } from '../hooks/index'; import { useSvConfig } from '../utils'; +import { partyIdScrollGlobalStyles } from './beta/identifierStyles'; +import PartyIdScrollTracks from './PartyIdScrollTracks'; +import SvNavigationShell from './layout/SvNavigationShell'; +import { SvNavLinkItem } from './layout/SvNavLink'; +import { CONTENT_MAX_WIDTH, layoutTokens, PAGE_PX } from '../theme/tokens'; +import NetworkBanner from './layout/NetworkBanner'; interface LayoutProps { children: React.ReactNode; } -const Layout: React.FC = (props: LayoutProps) => { +const pathnameToPageName = (pathname: string, amuletName: string): string => { + if (pathname.startsWith('/governance')) { + return 'Governance'; + } + if (pathname.startsWith('/validator-onboarding')) { + return 'Validators'; + } + if (pathname.startsWith('/amulet-price')) { + return `${amuletName} Price`; + } + return 'Global Synchronizer Information'; +}; + +/** Figma content-width 1583px centered — nav uses full width inside Navigation shell */ +const contentShellSx = { + maxWidth: CONTENT_MAX_WIDTH, + mx: 'auto', + px: PAGE_PX, + width: '100%', +}; + +const Layout: React.FC = ({ children }) => { const config = useSvConfig(); const { logout } = useUserState(); - const networkInstanceName = useNetworkInstanceName(); - const networkInstanceNameColor = `colors.${networkInstanceName?.toLowerCase()}`; + const location = useLocation(); const featureSupport = useFeatureSupport(); const votesHooks = useVotesHooks(); @@ -40,55 +58,31 @@ const Layout: React.FC = (props: LayoutProps) => { return ; } - const navLinks = [ - { name: 'Information', path: 'dso' }, - { name: 'Validator Onboarding', path: 'validator-onboarding' }, - { name: `${config.spliceInstanceNames.amuletName} Price`, path: 'amulet-price' }, - { name: 'Governance', path: 'governance', badgeCount: actionsPending?.length }, + const navLinks: SvNavLinkItem[] = [ + { name: 'Global Synchronizer Information', path: '/dso', alsoActiveFor: ['/'] }, + { + name: 'Governance', + path: '/governance', + end: false, + badgeCount: actionsPending?.length, + }, + { name: `${config.spliceInstanceNames.amuletName} Price`, path: '/amulet-price' }, + { name: 'Validators', path: '/validator-onboarding' }, ]; + const pageName = pathnameToPageName(location.pathname, config.spliceInstanceNames.amuletName); + return ( - - {networkInstanceName === undefined ? ( - <> - ) : ( - - - You are on {networkInstanceName} - - - )} - -
- - - - -
-
+ + + + + - - {props.children} + + + {children} + ); diff --git a/apps/sv/frontend/src/components/PartyIdScrollTracks.tsx b/apps/sv/frontend/src/components/PartyIdScrollTracks.tsx new file mode 100644 index 0000000000..0a074f93c1 --- /dev/null +++ b/apps/sv/frontend/src/components/PartyIdScrollTracks.tsx @@ -0,0 +1,107 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useLayoutEffect } from 'react'; + +import { computeScrollMetrics } from '../hooks/useHorizontalScrollMetrics'; + +const nodeContainsPartyId = (node: Node): boolean => + node instanceof HTMLElement && + (node.classList.contains('party-id') || node.querySelector('.party-id') !== null); + +const usePartyIdScrollTracks = (): void => { + useLayoutEffect(() => { + const cleanups = new Map void>(); + let scanScheduled = false; + + const detachTrack = (partyIdRoot: HTMLElement) => { + const cleanup = cleanups.get(partyIdRoot); + if (!cleanup) return; + cleanup(); + cleanups.delete(partyIdRoot); + }; + + const attachTrack = (partyIdRoot: HTMLElement) => { + if (cleanups.has(partyIdRoot)) return; + + const input = partyIdRoot.querySelector('.MuiInputBase-input.Mui-disabled'); + if (!input) return; + + partyIdRoot.classList.add('identifier-scroll-area'); + + const track = document.createElement('div'); + track.className = 'party-id-scroll-track'; + track.setAttribute('aria-hidden', 'true'); + + const thumb = document.createElement('div'); + thumb.className = 'party-id-scroll-thumb'; + track.appendChild(thumb); + partyIdRoot.appendChild(track); + + const update = () => { + const metrics = computeScrollMetrics(input); + track.style.display = metrics.canScroll ? 'block' : 'none'; + if (!metrics.canScroll) return; + + thumb.style.transform = `translateX(${metrics.thumbLeftPercent}%) scaleX(${metrics.thumbWidthPercent / 100})`; + }; + + update(); + + input.addEventListener('scroll', update, { passive: true }); + const resizeObserver = new ResizeObserver(update); + resizeObserver.observe(input); + resizeObserver.observe(partyIdRoot); + + cleanups.set(partyIdRoot, () => { + input.removeEventListener('scroll', update); + resizeObserver.disconnect(); + track.remove(); + partyIdRoot.classList.remove('identifier-scroll-area'); + }); + }; + + const scan = () => { + for (const partyIdRoot of cleanups.keys()) { + if (!document.body.contains(partyIdRoot)) { + detachTrack(partyIdRoot); + } + } + document.querySelectorAll('.party-id').forEach(attachTrack); + }; + + const scheduleScan = () => { + if (scanScheduled) return; + scanScheduled = true; + requestAnimationFrame(() => { + scanScheduled = false; + scan(); + }); + }; + + scan(); + + const mutationObserver = new MutationObserver(mutations => { + const shouldScan = mutations.some( + mutation => + mutation.removedNodes.length > 0 || + Array.from(mutation.addedNodes).some(nodeContainsPartyId) + ); + if (shouldScan) scheduleScan(); + }); + + mutationObserver.observe(document.body, { childList: true, subtree: true }); + + return () => { + mutationObserver.disconnect(); + [...cleanups.keys()].forEach(detachTrack); + }; + }, []); +}; + +const PartyIdScrollTracks: React.FC = () => { + usePartyIdScrollTracks(); + return null; +}; + +export default PartyIdScrollTracks; diff --git a/apps/sv/frontend/src/components/beta/CopyableIdentifier.tsx b/apps/sv/frontend/src/components/beta/CopyableIdentifier.tsx index c4a7cff7b1..a8b60c012a 100644 --- a/apps/sv/frontend/src/components/beta/CopyableIdentifier.tsx +++ b/apps/sv/frontend/src/components/beta/CopyableIdentifier.tsx @@ -2,51 +2,159 @@ // SPDX-License-Identifier: Apache-2.0 import { ContentCopy } from '@mui/icons-material'; import { Box, Chip, IconButton, Typography } from '@mui/material'; +import { useRef } from 'react'; + +import { useHorizontalScrollMetrics } from '../../hooks/useHorizontalScrollMetrics'; +import { + ellipsisContainerSx, + ellipsisTextSx, + IDENTIFIER_COMPACT_MAX_WIDTH_PX, + scrollContainerSx, + scrollTextSx, + scrollThumbSx, + scrollTrackSx, +} from './identifierStyles'; export type CopyableIdentifierSize = 'small' | 'large'; +export type CopyableIdentifierOverflow = 'scroll' | 'ellipsis'; interface CopyableIdentifierProps { value: string; copyValue?: string; badge?: string; size: CopyableIdentifierSize; + overflow?: CopyableIdentifierOverflow; + /** + * Caps the text slot (Figma ~270px). With `overflow="scroll"`, the ID stays + * horizontally scrollable inside the cap (#1785 + Figma width). With + * `overflow="ellipsis"`, CSS ellipsis is used instead. + */ + maxWidth?: number; + /** + * Fill the parent width: party-ID text flexes/scrolls; copy + badge stay fixed. + * Used by Votes rows (ID + reason shrink; status stays right-aligned). + */ + fullWidth?: boolean; + /** When true, only the (scrollable) value is rendered — caller places copy / badge. */ + hideCopy?: boolean; 'data-testid': string; } +/** Gap between party-ID text and copy / You accessories. */ +const IDENTIFIER_ACCESSORY_GAP = '8px'; + const CopyableIdentifier: React.FC = ({ value, copyValue, badge, size, + overflow = 'scroll', + maxWidth, + fullWidth = false, + hideCopy = false, 'data-testid': testId, -}) => ( - - - {value} - - { - e.stopPropagation(); - e.preventDefault(); - navigator.clipboard.writeText(copyValue ?? value); +}) => { + const scrollRef = useRef(null); + const metrics = useHorizontalScrollMetrics(scrollRef, [value, maxWidth, fullWidth]); + const fontSize = size === 'small' ? '14px' : '16px'; + const isEllipsis = overflow === 'ellipsis'; + const compactMaxWidth = maxWidth ?? (isEllipsis ? IDENTIFIER_COMPACT_MAX_WIDTH_PX : undefined); + const showAccessories = !hideCopy; + + return ( + - - - {badge !== undefined && } - -); + + + + {value} + + + {!isEllipsis && metrics.canScroll && ( + + + + )} + + {showAccessories && ( + <> + { + e.stopPropagation(); + e.preventDefault(); + navigator.clipboard.writeText(copyValue ?? value); + }} + > + + + {badge !== undefined && ( + + )} + + )} + + ); +}; export default CopyableIdentifier; diff --git a/apps/sv/frontend/src/components/beta/CopyableUrl.tsx b/apps/sv/frontend/src/components/beta/CopyableUrl.tsx index 827bfa9a7f..10be7a6680 100644 --- a/apps/sv/frontend/src/components/beta/CopyableUrl.tsx +++ b/apps/sv/frontend/src/components/beta/CopyableUrl.tsx @@ -4,50 +4,107 @@ import { ContentCopy } from '@mui/icons-material'; import { Box, IconButton, Link } from '@mui/material'; import { sanitizeUrl } from '@canton-network/splice-common-frontend-utils'; +import { useRef } from 'react'; +import { useHorizontalScrollMetrics } from '../../hooks/useHorizontalScrollMetrics'; import type { CopyableIdentifierSize } from './CopyableIdentifier'; +import { + scrollContainerSx, + scrollThumbSx, + scrollTrackSx, + URL_COMPACT_MAX_WIDTH_PX, +} from './identifierStyles'; interface CopyableUrlProps { url: string; size: CopyableIdentifierSize; + /** + * Fill the parent width (proposal-details section). Default keeps the compact + * Supporting URL slot used elsewhere (~346px). + */ + fullWidth?: boolean; 'data-testid': string; } -function abbreviateUrl(url: string, maxLength = 50): string { - if (url.length <= maxLength) { - return url; - } - return `${url.slice(0, maxLength)}...`; -} - -const CopyableUrl: React.FC = ({ url, size, 'data-testid': testId }) => { +const CopyableUrl: React.FC = ({ + url, + size, + fullWidth = false, + 'data-testid': testId, +}) => { const sanitizedUrl = sanitizeUrl(url); + const fontSize = size === 'small' ? '14px' : '16px'; + const scrollRef = useRef(null); + const metrics = useHorizontalScrollMetrics(scrollRef, [sanitizedUrl, fullWidth]); + const textMaxWidth = fullWidth ? '100%' : URL_COMPACT_MAX_WIDTH_PX; return ( - - + - {abbreviateUrl(sanitizedUrl)} - + + + {sanitizedUrl} + + + {metrics.canScroll && ( + + + + )} + navigator.clipboard.writeText(sanitizedUrl)} > - + ); diff --git a/apps/sv/frontend/src/components/beta/MemberIdentifier.tsx b/apps/sv/frontend/src/components/beta/MemberIdentifier.tsx index bda90aa44c..b51cbe8cba 100644 --- a/apps/sv/frontend/src/components/beta/MemberIdentifier.tsx +++ b/apps/sv/frontend/src/components/beta/MemberIdentifier.tsx @@ -2,37 +2,35 @@ // SPDX-License-Identifier: Apache-2.0 import CopyableIdentifier from './CopyableIdentifier'; -import type { CopyableIdentifierSize } from './CopyableIdentifier'; +import type { CopyableIdentifierOverflow, CopyableIdentifierSize } from './CopyableIdentifier'; interface MemberIdentifierProps { partyId: string; isYou: boolean; size: CopyableIdentifierSize; + overflow?: CopyableIdentifierOverflow; + maxWidth?: number; + fullWidth?: boolean; 'data-testid': string; } -function abbreviatePartyId(partyId: string, length = 10): string { - const [partyHint, hash] = partyId.split('::'); - if (hash === undefined) { - return partyHint; - } - - const partOfHash = hash.slice(0, length); - - return `${partyHint}::${partOfHash}...`; -} - const MemberIdentifier: React.FC = ({ partyId, isYou, size, + overflow, + maxWidth, + fullWidth, 'data-testid': testId, }) => ( ); diff --git a/apps/sv/frontend/src/components/beta/PageSectionHeader.tsx b/apps/sv/frontend/src/components/beta/PageSectionHeader.tsx index 2f84fafd2f..188ce98ae1 100644 --- a/apps/sv/frontend/src/components/beta/PageSectionHeader.tsx +++ b/apps/sv/frontend/src/components/beta/PageSectionHeader.tsx @@ -5,12 +5,14 @@ import { Badge, Box, Typography } from '@mui/material'; interface PageSectionHeaderProps { title: string; badgeCount?: number; + badgeColor?: 'warning' | 'neutral'; 'data-testid': string; } const PageSectionHeader: React.FC = ({ title, badgeCount, + badgeColor = 'neutral', 'data-testid': testId, }) => ( @@ -19,8 +21,16 @@ const PageSectionHeader: React.FC = ({ diff --git a/apps/sv/frontend/src/components/beta/identifierStyles.ts b/apps/sv/frontend/src/components/beta/identifierStyles.ts new file mode 100644 index 0000000000..ec59d01c9d --- /dev/null +++ b/apps/sv/frontend/src/components/beta/identifierStyles.ts @@ -0,0 +1,171 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SxProps, Theme } from '@mui/material'; + +const hiddenScrollbarSx = { + scrollbarWidth: 'none', + msOverflowStyle: 'none', + '&::-webkit-scrollbar': { + display: 'none', + }, +} as const; + +export const scrollContainerSx: SxProps = { + minWidth: 0, + overflowX: 'auto', + overflowY: 'hidden', + ...hiddenScrollbarSx, +}; + +export const scrollTextSx: SxProps = { + display: 'inline-block', + whiteSpace: 'nowrap', + textOverflow: 'clip', + // Intrinsic width for overflow scroll; parent must clip (minmax(0,1fr) / overflow). + width: 'max-content', +}; + +export const ellipsisContainerSx: SxProps = { + minWidth: 0, + // Figma truncated ID text slot (e.g. Vote proposal contract id Group 461): 270px + maxWidth: 270, + width: '100%', + overflow: 'hidden', +}; + +/** Figma Group 461 truncated ID text width — also used to cap scrollable compact IDs. */ +export const IDENTIFIER_COMPACT_MAX_WIDTH_PX = 270; + +/** Figma Supporting URL value slot (`552:960`). */ +export const URL_COMPACT_MAX_WIDTH_PX = 346; + +export const ellipsisTextSx: SxProps = { + display: 'block', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + maxWidth: '100%', +}; + +export const scrollableIdentifierFieldSx: SxProps = { + fontFamily: 'Source Code Pro, monospace', + display: 'inline-block', + width: 'max-content', + minWidth: '100%', + maxWidth: '100%', + whiteSpace: 'nowrap', + textOverflow: 'clip', +}; + +const scrollableInputTextSx = { + fontFamily: 'Source Code Pro, monospace', + overflowX: 'auto', + textOverflow: 'clip', + whiteSpace: 'nowrap', + ...hiddenScrollbarSx, +} as const; + +export const scrollableTextFieldSx: SxProps = { + '& .MuiOutlinedInput-input': scrollableInputTextSx, +}; + +export const scrollableSelectFieldSx: SxProps = { + '& .MuiSelect-select': { + ...scrollableInputTextSx, + display: 'block', + width: '100%', + maxWidth: '100%', + }, +}; + +export const scrollTrackSx: SxProps = { + height: 0, + opacity: 0, + overflow: 'hidden', + mt: 0, + borderRadius: 1, + bgcolor: 'rgba(255, 255, 255, 0.12)', + position: 'relative', + flexShrink: 0, + transition: 'opacity 0.15s ease, height 0.15s ease, margin-top 0.15s ease', + '.identifier-scroll-area:hover &': { + height: 4, + opacity: 1, + mt: 0.5, + bgcolor: 'rgba(255, 255, 255, 0.18)', + }, +}; + +export const scrollThumbSx = ( + thumbLeftPercent: number, + thumbWidthPercent: number +): SxProps => ({ + position: 'absolute', + top: 0, + bottom: 0, + left: `${thumbLeftPercent}%`, + width: `${thumbWidthPercent}%`, + borderRadius: 1, + bgcolor: 'rgba(255, 255, 255, 0.35)', + transition: 'background-color 0.15s ease', + '.identifier-scroll-area:hover &': { + bgcolor: 'rgba(255, 255, 255, 0.72)', + }, +}); + +export const partyIdScrollGlobalStyles = { + '.party-id': { + position: 'relative', + minWidth: 0, + maxWidth: '100%', + }, + '.party-id .MuiInputBase-root': { + overflow: 'visible !important', + minWidth: 0, + maxWidth: '100%', + width: '100%', + }, + '.party-id .MuiInputBase-input.Mui-disabled': { + overflowX: 'auto', + textOverflow: 'clip !important', + whiteSpace: 'nowrap', + scrollbarWidth: 'none', + msOverflowStyle: 'none', + }, + '.party-id .MuiInputBase-input.Mui-disabled::-webkit-scrollbar': { + display: 'none', + }, + '.party-id-scroll-track': { + position: 'absolute', + left: 0, + right: '40px', + bottom: 0, + height: 0, + opacity: 0, + overflow: 'hidden', + borderRadius: '4px', + backgroundColor: 'rgba(255, 255, 255, 0.12)', + pointerEvents: 'none', + transition: 'opacity 0.15s ease, height 0.15s ease', + }, + '.party-id.identifier-scroll-area:hover .party-id-scroll-track': { + height: '4px', + opacity: 1, + backgroundColor: 'rgba(255, 255, 255, 0.18)', + }, + '.party-id-scroll-thumb': { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + width: '100%', + transformOrigin: 'left center', + borderRadius: '4px', + backgroundColor: 'rgba(255, 255, 255, 0.35)', + transition: 'background-color 0.15s ease', + }, + '.party-id.identifier-scroll-area:hover .party-id-scroll-thumb': { + backgroundColor: 'rgba(255, 255, 255, 0.72)', + }, +} as const; diff --git a/apps/sv/frontend/src/components/form-components/ConfigField.tsx b/apps/sv/frontend/src/components/form-components/ConfigField.tsx index 61a0216eaa..ea6dc53a57 100644 --- a/apps/sv/frontend/src/components/form-components/ConfigField.tsx +++ b/apps/sv/frontend/src/components/form-components/ConfigField.tsx @@ -2,12 +2,25 @@ // SPDX-License-Identifier: Apache-2.0 import { Link as RouterLink } from 'react-router'; -import { Box, Divider, TextField as MuiTextField, Typography } from '@mui/material'; +import { + Box, + Divider, + FormControl, + MenuItem, + Select, + TextField as MuiTextField, + Typography, +} from '@mui/material'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; import { useFieldContext } from '../../hooks/formContext'; import type { ConfigChange, PendingConfigFieldInfo } from '../../utils/types'; import { nextScheduledSynchronizerUpgradeFormat } from '@canton-network/splice-common-frontend-utils'; +import { configFieldFieldSx, configFieldInputSx } from '../../themes/fieldStyles'; +import { + CREATE_PROPOSAL_CONFIG_INPUT_WIDTH, + CREATE_PROPOSAL_FIELD_BODY_SX, +} from '../../constants/createProposalLayout'; dayjs.extend(relativeTime); @@ -55,12 +68,16 @@ export const ConfigField: React.FC = props => { const textFieldProps = { variant: 'outlined' as const, - size: 'small' as const, color: field.state.meta.isDefaultValue ? ('primary' as const) : ('secondary' as const), focused: !field.state.meta.isDefaultValue, autoComplete: 'off' as const, + sx: configFieldFieldSx, + slotProps: { + input: { + sx: configFieldInputSx, + }, + }, inputProps: { - sx: { textAlign: 'right' }, 'data-testid': `config-field-${configChange.fieldName}`, }, disabled: isDisabled, @@ -70,44 +87,113 @@ export const ConfigField: React.FC = props => { <> - - + + {configChange.label} {configChange.fieldName} + + {configChange.options ? ( + + + + ) : ( + + field.handleChange({ + fieldName: configChange.fieldName, + value: e.target.value, + }) + } + /> + )} - - - field.handleChange({ - fieldName: configChange.fieldName, - value: e.target.value, - }) - } - /> + {configChange.description && ( + + {configChange.description} + + )} {!field.state.meta.isDefaultValue && ( Current Configuration: {configChange.currentValue} @@ -140,24 +226,46 @@ export const PendingConfigDisplay: React.FC = ({ pend effectiveDate === 'Threshold' ? 'at Threshold' : dayjs(effectiveDate).fromNow(); return ( - - Pending Configuration: {pendingValue}
- This{' '} - - pending configuration - {' '} - will go into effect {effectiveText} -
+ Pending Configuration:{' '} + + {pendingValue} + +
+ + This{' '} + + pending configuration + {' '} + will go into effect {effectiveText} + +
); }; @@ -189,7 +297,7 @@ export const SynchronizerUpgradeTimeDisplay: React.FC< {`Default: ${defaultMigrationTime}`} diff --git a/apps/sv/frontend/src/components/form-components/DateField.tsx b/apps/sv/frontend/src/components/form-components/DateField.tsx index 0e0bd7ba1f..3246b66d5b 100644 --- a/apps/sv/frontend/src/components/form-components/DateField.tsx +++ b/apps/sv/frontend/src/components/form-components/DateField.tsx @@ -2,12 +2,19 @@ // SPDX-License-Identifier: Apache-2.0 import { useMemo } from 'react'; +import { KeyboardArrowDown } from '@mui/icons-material'; import { Box, Typography } from '@mui/material'; import { DesktopDateTimePicker, LocalizationProvider } from '@mui/x-date-pickers'; import dayjs, { Dayjs } from 'dayjs'; import { dateTimeFormatISO } from '@canton-network/splice-common-frontend-utils'; +import { + CREATE_PROPOSAL_FIELD_HELPER_SX, + CREATE_PROPOSAL_FIELD_LABEL_SX, +} from '../../constants/createProposalLayout'; import { useFieldContext } from '../../hooks/formContext'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { datePickerFieldSx } from '../../themes/fieldStyles'; +import { DATE_TIME_PLACEHOLDER } from '../../utils/constants'; export interface DateFieldProps { title?: string; @@ -25,13 +32,13 @@ export const DateField: React.FC = props => { return ( {title && ( - + {title} )} {description && ( - + {description} )} @@ -42,17 +49,35 @@ export const DateField: React.FC = props => { format={dateTimeFormatISO} minDateTime={minDate || dayjs()} ampm={false} + onClose={() => field.handleBlur()} onChange={newDate => field.handleChange(newDate?.format(dateTimeFormatISO)!)} enableAccessibleFieldDOMStructure={false} + slots={{ + openPickerIcon: KeyboardArrowDown, + }} slotProps={{ textField: { fullWidth: true, variant: 'outlined', id: `${id}-field`, + error: !field.state.meta.isValid, helperText: field.state.meta.errors?.[0], onBlur: field.handleBlur, + sx: datePickerFieldSx, inputProps: { 'data-testid': `${id}-field`, + placeholder: DATE_TIME_PLACEHOLDER, + }, + }, + openPickerButton: { + sx: { + color: 'text.light', + marginRight: 0, + padding: 0, + cursor: 'pointer', + '& .MuiSvgIcon-root': { + fontSize: 16, + }, }, }, }} diff --git a/apps/sv/frontend/src/components/form-components/EffectiveDateField.tsx b/apps/sv/frontend/src/components/form-components/EffectiveDateField.tsx index da838653f5..5ab4d82a41 100644 --- a/apps/sv/frontend/src/components/form-components/EffectiveDateField.tsx +++ b/apps/sv/frontend/src/components/form-components/EffectiveDateField.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { Box, FormControlLabel, Radio, RadioGroup, Typography } from '@mui/material'; +import { KeyboardArrowDown } from '@mui/icons-material'; import { useFieldContext } from '../../hooks/formContext'; import { DesktopDateTimePicker, LocalizationProvider } from '@mui/x-date-pickers'; import { dateTimeFormatISO } from '@canton-network/splice-common-frontend-utils'; @@ -9,6 +9,11 @@ import dayjs from 'dayjs'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { EffectivityType } from '../../utils/types'; import React, { useMemo } from 'react'; +import { RadioSelector } from './RadioSelector'; +import { datePickerFieldSx } from '../../themes/fieldStyles'; +import { DATE_TIME_PLACEHOLDER } from '../../utils/constants'; + +const effectiveAtDisplayFormat = 'YYYY-MM-DD HH:mm'; export interface EffectiveDateFieldProps { title?: string; @@ -19,10 +24,9 @@ export interface EffectiveDateFieldProps { export const EffectiveDateField: React.FC = props => { const { initialEffectiveDate, id } = props; - const title = props.title ? props.title : 'Vote Proposal Effectivity'; - const description = props.description - ? props.description - : 'Select the date and time the proposal will take effect'; + const title = props.title ?? 'Effective At'; + const dateDescription = + props.description ?? 'Select the block at which the proposal will take effect'; const field = useFieldContext<{ type: EffectivityType; @@ -55,72 +59,75 @@ export const EffectiveDateField: React.FC = props => { }; return ( - - - {title} - - - handleTypeChange(e.target.value as EffectivityType)} - > - } - label={Date} - /> - - {currentType === 'custom' && ( - <> - - {description} - - - - { - field.handleChange({ - type: 'custom', - effectiveDate: newDate?.format(dateTimeFormatISO) || undefined, - }); - }} - enableAccessibleFieldDOMStructure={false} - slotProps={{ - textField: { - fullWidth: true, - variant: 'outlined', - id: `${id}-field`, - className: 'effective-date-field', - onBlur: field.handleBlur, - error: !field.state.meta.isValid, - helperText: field.state.meta.errors?.[0], - inputProps: { - 'data-testid': `${id}-field`, + handleTypeChange(value as EffectivityType)} + options={[ + { + value: 'custom', + label: 'Date', + description: dateDescription, + extension: + currentType === 'custom' ? ( + + field.handleBlur()} + onChange={newDate => { + field.handleChange({ + type: 'custom', + effectiveDate: newDate?.format(dateTimeFormatISO) || undefined, + }); + }} + enableAccessibleFieldDOMStructure={false} + slots={{ + openPickerIcon: KeyboardArrowDown, + }} + slotProps={{ + openPickerButton: { + sx: { + color: 'text.light', + marginRight: 0, + p: 0, + cursor: 'pointer', + '& .MuiSvgIcon-root': { + fontSize: 16, + }, + }, }, - }, - }} - /> - - - )} - - } - label={ - - Make effective at threshold - - Allow the vote proposal to take effect immediately when 2/3 vote in favor - - - } - sx={{ mt: 2 }} - /> - - + textField: { + fullWidth: true, + variant: 'outlined', + id: `${id}-field`, + className: 'effective-date-field', + error: !field.state.meta.isValid, + helperText: field.state.meta.errors?.[0], + onBlur: field.handleBlur, + sx: datePickerFieldSx, + inputProps: { + 'data-testid': `${id}-field`, + placeholder: DATE_TIME_PLACEHOLDER, + }, + }, + }} + /> + + ) : null, + }, + { + value: 'threshold', + label: 'Make effective at threshold', + description: + 'This will allow the vote proposal to take effect immediately when 2/3 vote in favor', + radioId: 'effective-at-threshold-radio', + testId: 'effective-at-threshold-radio', + }, + ]} + /> ); }; diff --git a/apps/sv/frontend/src/components/form-components/FormControls.tsx b/apps/sv/frontend/src/components/form-components/FormControls.tsx index e661a7ba86..f409fdcf3d 100644 --- a/apps/sv/frontend/src/components/form-components/FormControls.tsx +++ b/apps/sv/frontend/src/components/form-components/FormControls.tsx @@ -2,8 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import { Box, Button } from '@mui/material'; +import { useState } from 'react'; +import { + createProposalCancelButtonSx, + createProposalSubmitButtonSx, +} from '../../constants/formButtonStyles'; import { useFormContext } from '../../hooks/formContext'; import { useNavigate } from 'react-router'; +import { CancelProposalDialog } from '../governance/CancelProposalDialog'; export interface FormControlsProps { showConfirmation?: boolean; @@ -14,51 +20,68 @@ export const FormControls: React.FC = props => { const { showConfirmation, onEdit } = props; const form = useFormContext(); const navigate = useNavigate(); + const [cancelDialogOpen, setCancelDialogOpen] = useState(false); + const submitTitle = showConfirmation ? 'Submit Proposal' : 'Review Proposal'; const cancelTitle = showConfirmation ? 'Edit Proposal' : 'Cancel'; const handleCancel = () => { if (showConfirmation) { onEdit(); - } else { - navigate('/governance/proposals/create'); + return; } + setCancelDialogOpen(true); + }; + + const handleConfirmCancel = () => { + setCancelDialogOpen(false); + navigate('/governance/proposals'); }; return ( - - + + + [state.canSubmit, state.isSubmitting]} + children={([canSubmit, isSubmitting]) => ( + + )} + /> + - [state.canSubmit, state.isSubmitting]} - children={([canSubmit, isSubmitting]) => ( - - )} + setCancelDialogOpen(false)} + onConfirm={handleConfirmCancel} /> - + ); }; diff --git a/apps/sv/frontend/src/components/form-components/ProposalSummaryField.tsx b/apps/sv/frontend/src/components/form-components/ProposalSummaryField.tsx index f6a7a83e53..e9e0f5b873 100644 --- a/apps/sv/frontend/src/components/form-components/ProposalSummaryField.tsx +++ b/apps/sv/frontend/src/components/form-components/ProposalSummaryField.tsx @@ -2,13 +2,19 @@ // SPDX-License-Identifier: Apache-2.0 import { Box, TextField as MuiTextField, Typography } from '@mui/material'; +import { + CREATE_PROPOSAL_FIELD_HELPER_SX, + CREATE_PROPOSAL_FIELD_LABEL_SX, +} from '../../constants/createProposalLayout'; import { useFieldContext } from '../../hooks/formContext'; import { useDsoInfos } from '../../contexts/SvContext'; import { DEFAULT_PROPOSAL_SUMMARY_MAX_LENGTH, + PROPOSAL_SUMMARY_PLACEHOLDER, PROPOSAL_SUMMARY_SUBTITLE, PROPOSAL_SUMMARY_TITLE, } from '../../utils/constants'; +import { proposalSummaryFieldSx } from '../../themes/fieldStyles'; export interface ProposalSummaryFieldProps { id: string; @@ -28,10 +34,10 @@ export const ProposalSummaryField: React.FC = props = return ( - + {title || PROPOSAL_SUMMARY_TITLE} {optional && ( - + optional )} @@ -39,34 +45,37 @@ export const ProposalSummaryField: React.FC = props = field.handleChange(e.target.value)} error={!field.state.meta.isValid} helperText={field.state.meta.errors?.[0]} + placeholder={PROPOSAL_SUMMARY_PLACEHOLDER} inputProps={{ 'data-testid': id, maxLength }} - id={id} /> - + {subtitle || PROPOSAL_SUMMARY_SUBTITLE} {currentLength}/{maxLength} diff --git a/apps/sv/frontend/src/components/form-components/ProposalTypeField.tsx b/apps/sv/frontend/src/components/form-components/ProposalTypeField.tsx index d7c0c06dd0..3b27f3fb61 100644 --- a/apps/sv/frontend/src/components/form-components/ProposalTypeField.tsx +++ b/apps/sv/frontend/src/components/form-components/ProposalTypeField.tsx @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { Box, Typography } from '@mui/material'; +import { + CREATE_PROPOSAL_FIELD_BODY_SX, + CREATE_PROPOSAL_FIELD_LABEL_SX, +} from '../../constants/createProposalLayout'; import { useFieldContext } from '../../hooks/formContext'; export interface ProposalTypeFieldProps { @@ -15,11 +19,16 @@ export const ProposalTypeField: React.FC = props => { return ( - + {title} - + {field.state.value} diff --git a/apps/sv/frontend/src/components/form-components/RadioSelector.tsx b/apps/sv/frontend/src/components/form-components/RadioSelector.tsx new file mode 100644 index 0000000000..d08ef4c5c0 --- /dev/null +++ b/apps/sv/frontend/src/components/form-components/RadioSelector.tsx @@ -0,0 +1,144 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Box, FormControlLabel, Radio, RadioGroup, SvgIcon, Typography } from '@mui/material'; +import type { SvgIconProps } from '@mui/material'; +import React from 'react'; +import type { Theme } from '@mui/material/styles'; +import { theme } from '@canton-network/splice-common-frontend'; + +export interface RadioSelectorOption { + value: string; + label: string; + description?: string; + radioId?: string; + testId?: string; + extension?: React.ReactNode; +} + +export interface RadioSelectorProps { + title: string; + value: string; + onChange: (value: string) => void; + options: RadioSelectorOption[]; + id: string; +} + +const sectionSx = (theme: Theme) => ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + alignSelf: 'stretch', + gap: theme.spacing(3), + width: '100%', +}); + +const optionLabelColumnSx = (theme: Theme) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), + width: '100%', +}); + +const optionRowSx = (theme: Theme) => ({ + alignItems: 'flex-start', + gap: theme.spacing(1), + m: 0, + ml: 0, + mr: 0, + width: '100%', + '& .MuiFormControlLabel-label': { + flex: 1, + minWidth: 0, + }, +}); + +const radioSx = { + alignSelf: 'flex-start', + p: 0, + pt: '3px', + '& .MuiSvgIcon-root': { + fontSize: 16, + }, +} as const; + +const RADIO_RING_PATH = + 'M8.00004 1.33301C4.32004 1.33301 1.33337 4.31967 1.33337 7.99967C1.33337 11.6797 4.32004 14.6663 8.00004 14.6663C11.68 14.6663 14.6667 11.6797 14.6667 7.99967C14.6667 4.31967 11.68 1.33301 8.00004 1.33301ZM8.00004 13.333C5.05337 13.333 2.66671 10.9463 2.66671 7.99967C2.66671 5.05301 5.05337 2.66634 8.00004 2.66634C10.9467 2.66634 13.3334 5.05301 13.3334 7.99967C13.3334 10.9463 10.9467 13.333 8.00004 13.333Z'; +const RADIO_DOT_PATH = + 'M7.99996 11.3337C9.84091 11.3337 11.3333 9.84127 11.3333 8.00033C11.3333 6.15938 9.84091 4.66699 7.99996 4.66699C6.15901 4.66699 4.66663 6.15938 4.66663 8.00033C4.66663 9.84127 6.15901 11.3337 7.99996 11.3337Z'; + +const RadioIcon: React.FC = ({ checked, ...props }) => ( + + + {checked && } + +); + +export const RadioSelector: React.FC = props => { + const { title, value, onChange, options, id } = props; + + return ( + + + {title} + + + onChange(e.target.value)} + sx={theme => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(3), + m: 0, + width: '100%', + })} + > + {options.map(option => ( + } + checkedIcon={} + sx={radioSx} + /> + } + label={ + + + {option.label} + + {option.description && ( + + {option.description} + + )} + {option.extension && ( + e.stopPropagation()} + onMouseDown={e => e.stopPropagation()} + > + {option.extension} + + )} + + } + sx={optionRowSx} + /> + ))} + + + ); +}; diff --git a/apps/sv/frontend/src/components/form-components/SelectField.tsx b/apps/sv/frontend/src/components/form-components/SelectField.tsx index a6513bc4d4..7585cfa542 100644 --- a/apps/sv/frontend/src/components/form-components/SelectField.tsx +++ b/apps/sv/frontend/src/components/form-components/SelectField.tsx @@ -1,6 +1,7 @@ // Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { KeyboardArrowDown } from '@mui/icons-material'; import { Box, FormControl, @@ -10,8 +11,11 @@ import { SelectChangeEvent, Typography, } from '@mui/material'; +import { CREATE_PROPOSAL_FIELD_LABEL_SX } from '../../constants/createProposalLayout'; import type { FormEvent } from 'react'; import { useFieldContext } from '../../hooks/formContext'; +import { scrollableSelectFieldSx } from '../beta/identifierStyles'; +import { selectFieldSx } from '../../themes/fieldStyles'; export type Option = { key: string; value: string }; export interface SelectFieldProps { @@ -21,10 +25,11 @@ export interface SelectFieldProps { onChange?: () => void; disabled?: boolean; placeholder?: string; + scrollableIdentifier?: boolean; } export const SelectField: React.FC = props => { - const { title, options, id, disabled = false, placeholder } = props; + const { title, options, id, disabled = false, placeholder, scrollableIdentifier = false } = props; const externalOnChange = props.onChange ?? (() => {}); const field = useFieldContext(); const handleSelectValueChange = (value: string) => { @@ -32,24 +37,32 @@ export const SelectField: React.FC = props => { externalOnChange(); }; - const showPlaceholder = !!placeholder && !field.state.value; - const isError = !field.state.meta.isValid && !showPlaceholder; + const article = /^[aeiou]/i.test(title) ? 'an' : 'a'; + const resolvedPlaceholder = placeholder ?? `Select ${article} ${title.toLowerCase()}`; + const showPlaceholder = !field.state.value; + const isError = !field.state.meta.isValid && !(placeholder && showPlaceholder); return ( - + {title} - + - field.handleChange(e.target.value as string) - } - onBlur={field.handleBlur} + state.canSubmit} + children={canSubmit => ( + <> + + + + )} /> - - - state.canSubmit} - children={canSubmit => ( - <> - - - - - )} - /> - - - - + + +
); }; diff --git a/apps/sv/frontend/src/components/forms/SetAmuletConfigRulesForm.tsx b/apps/sv/frontend/src/components/forms/SetAmuletConfigRulesForm.tsx index 2cf14577aa..b41b3c73f4 100644 --- a/apps/sv/frontend/src/components/forms/SetAmuletConfigRulesForm.tsx +++ b/apps/sv/frontend/src/components/forms/SetAmuletConfigRulesForm.tsx @@ -5,7 +5,21 @@ import { ActionRequiringConfirmation, AmuletRules_ActionRequiringConfirmation, } from '@daml.js/splice-dso-governance/lib/Splice/DsoRules'; -import { THRESHOLD_DEADLINE_SUBTITLE } from '../../utils/constants'; +import { + CREATE_PROPOSAL_CONFIG_ROW_DIVIDER_GAP, + CREATE_PROPOSAL_CONFIG_ROW_GAP, + CREATE_PROPOSAL_FIELD_LABEL_SX, +} from '../../constants/createProposalLayout'; +import { + CREATE_PROPOSAL_LABEL_CONFIGURATION, + CREATE_PROPOSAL_LABEL_EFFECTIVE_AT, + CREATE_PROPOSAL_LABEL_PROPOSAL_SUMMARY, + CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + CREATE_PROPOSAL_LABEL_SUPPORTING_URL, + CREATE_PROPOSAL_LABEL_THRESHOLD_DEADLINE, + SUPPORTING_URL_PLACEHOLDER, + THRESHOLD_DEADLINE_SUBTITLE, +} from '../../utils/constants'; import { buildAmuletRulesPendingConfigFields, configFormDataToConfigChanges, @@ -192,8 +206,24 @@ export const SetAmuletConfigRulesForm: () => JSX.Element = () => { dsoInfoQuery ); + const jsonDiffContent = + amuletConfigToCompareWith && amuletConfigToCompareWith[1] ? ( + + ) : null; + return ( - + {showConfirmation ? ( JSX.Element = () => { form.state.values.config, allAmuletConfigChanges )} + jsonDiff={{jsonDiffContent}} onEdit={() => setShowConfirmation(false)} onSubmit={() => {}} /> @@ -218,27 +249,43 @@ export const SetAmuletConfigRulesForm: () => JSX.Element = () => { )} - {field => } + {field => ( + + )} - - - Configuration + + + {CREATE_PROPOSAL_LABEL_CONFIGURATION} - {allAmuletConfigChanges.map((change, index) => ( - + {allAmuletConfigChanges.map(change => ( + {field => ( - f.fieldName === change.fieldName - )} - /> + + f.fieldName === change.fieldName + )} + /> + )} ))} + + {jsonDiffContent} JSX.Element = () => { > {field => ( @@ -265,6 +312,7 @@ export const SetAmuletConfigRulesForm: () => JSX.Element = () => { }} children={_ => ( @@ -278,7 +326,12 @@ export const SetAmuletConfigRulesForm: () => JSX.Element = () => { onChange: ({ value }) => validateSummary(value), }} > - {field => } + {field => ( + + )} JSX.Element = () => { onChange: ({ value }) => validateUrl(value), }} > - {field => } + {field => ( + + )} )} - - {amuletConfigToCompareWith && amuletConfigToCompareWith[1] ? ( - - ) : null} - - diff --git a/apps/sv/frontend/src/components/forms/SetDsoConfigRulesForm.tsx b/apps/sv/frontend/src/components/forms/SetDsoConfigRulesForm.tsx index 86f4307ec3..2b3ab969aa 100644 --- a/apps/sv/frontend/src/components/forms/SetDsoConfigRulesForm.tsx +++ b/apps/sv/frontend/src/components/forms/SetDsoConfigRulesForm.tsx @@ -20,7 +20,21 @@ import { useAppForm } from '../../hooks/form'; import { useProposalMutation } from '../../hooks/useProposalMutation'; import { buildDsoConfigChanges } from '../../utils/buildDsoConfigChanges'; import { buildDsoRulesConfigFromChanges } from '../../utils/buildDsoRulesConfigFromChanges'; -import { THRESHOLD_DEADLINE_SUBTITLE } from '../../utils/constants'; +import { + CREATE_PROPOSAL_CONFIG_ROW_DIVIDER_GAP, + CREATE_PROPOSAL_CONFIG_ROW_GAP, + CREATE_PROPOSAL_FIELD_LABEL_SX, +} from '../../constants/createProposalLayout'; +import { + CREATE_PROPOSAL_LABEL_CONFIGURATION, + CREATE_PROPOSAL_LABEL_EFFECTIVE_AT, + CREATE_PROPOSAL_LABEL_PROPOSAL_SUMMARY, + CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + CREATE_PROPOSAL_LABEL_SUPPORTING_URL, + CREATE_PROPOSAL_LABEL_THRESHOLD_DEADLINE, + SUPPORTING_URL_PLACEHOLDER, + THRESHOLD_DEADLINE_SUBTITLE, +} from '../../utils/constants'; import { buildPendingConfigFields, configFormDataToConfigChanges, @@ -210,8 +224,23 @@ export const SetDsoConfigRulesForm: () => JSX.Element = () => { dsoInfoQuery ); + const jsonDiffContent = dsoConfigToCompareWith[1] ? ( + + ) : null; + return ( - + {showConfirmation ? ( JSX.Element = () => { effectiveDate={form.state.values.common.effectiveDate.effectiveDate} formType="config-change" configFormData={changedFields} + jsonDiff={{jsonDiffContent}} onEdit={() => setShowConfirmation(false)} onSubmit={() => {}} /> @@ -233,28 +263,44 @@ export const SetDsoConfigRulesForm: () => JSX.Element = () => { )} - {field => } + {field => ( + + )} - - - Configuration + + + {CREATE_PROPOSAL_LABEL_CONFIGURATION} - {dsoConfigChanges.map((change, index) => ( - + {dsoConfigChanges.map(change => ( + {field => ( - f.fieldName === change.fieldName - )} - effectiveDate={form.state.values.common.effectiveDate.effectiveDate} - /> + + f.fieldName === change.fieldName + )} + effectiveDate={form.state.values.common.effectiveDate.effectiveDate} + /> + )} ))} + + {jsonDiffContent} JSX.Element = () => { > {field => ( @@ -281,6 +327,7 @@ export const SetDsoConfigRulesForm: () => JSX.Element = () => { }} children={_ => ( @@ -294,7 +341,12 @@ export const SetDsoConfigRulesForm: () => JSX.Element = () => { onChange: ({ value }) => validateSummary(value), }} > - {field => } + {field => ( + + )} JSX.Element = () => { onChange: ({ value }) => validateUrl(value), }} > - {field => } + {field => ( + + )} )} - - {dsoConfigToCompareWith[1] ? ( - - ) : null} - - diff --git a/apps/sv/frontend/src/components/forms/UpdateFeaturedAppForm.tsx b/apps/sv/frontend/src/components/forms/UpdateFeaturedAppForm.tsx new file mode 100644 index 0000000000..23401daa92 --- /dev/null +++ b/apps/sv/frontend/src/components/forms/UpdateFeaturedAppForm.tsx @@ -0,0 +1,277 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useState, useEffect } from 'react'; +import { useSvAdminClient } from '../../contexts/SvAdminServiceContext'; +import { useDsoInfos } from '../../contexts/SvContext'; +import { useFeaturedAppRightPicker } from '../../hooks/useFeaturedAppRightPicker'; +import { useProposalMutation } from '../../hooks/useProposalMutation'; +import { UpdateFeatureAppFormData } from '../../utils/types'; +import { createProposalActions, getInitialExpiration } from '../../utils/governance'; +import { dateTimeFormatISO } from '@canton-network/splice-common-frontend-utils'; +import dayjs from 'dayjs'; +import { useAppForm } from '../../hooks/form'; +import { ActionRequiringConfirmation } from '@daml.js/splice-dso-governance/lib/Splice/DsoRules'; +import { ContractId } from '@daml/types'; +import { FeaturedAppRight } from '@daml.js/splice-amulet/lib/Splice/Amulet'; +import { + validateEffectiveDate, + validateExpiration, + validateExpiryEffectiveDate, + validatePartyId, + validateRequiredActivityWeight, + validateSummary, + validateUrl, +} from './formValidators'; +import { FormLayout } from './FormLayout'; +import { ProposalSummary } from '../governance/ProposalSummary'; +import { useStore } from '@tanstack/react-form'; +import { + CREATE_PROPOSAL_LABEL_THRESHOLD_DEADLINE, + DEFAULT_APP_ACTIVITY_WEIGHT, + SUPPORTING_URL_PLACEHOLDER, + THRESHOLD_DEADLINE_SUBTITLE, +} from '../../utils/constants'; +import { EffectiveDateField } from '../form-components/EffectiveDateField'; +import { ProposalSubmissionError } from '../form-components/ProposalSubmissionError'; + +export const UpdateFeaturedAppForm: React.FC = () => { + const svAdminClient = useSvAdminClient(); + const dsoInfosQuery = useDsoInfos(); + const initialExpiration = getInitialExpiration(dsoInfosQuery.data); + const initialEffectiveDate = dayjs(initialExpiration).add(1, 'day'); + const picker = useFeaturedAppRightPicker(svAdminClient); + const [showConfirmation, setShowConfirmation] = useState(false); + const mutation = useProposalMutation(); + const idPrefix = 'update-featured-app'; + + const createProposalAction = createProposalActions.find( + a => a.value === 'SRARC_UpdateFeaturedAppRight' + ); + + const defaultValues: UpdateFeatureAppFormData = { + action: createProposalAction?.name || '', + expiryDate: initialExpiration.format(dateTimeFormatISO), + effectiveDate: { + type: 'custom', + effectiveDate: initialEffectiveDate.format(dateTimeFormatISO), + }, + url: '', + summary: '', + partyId: '', + rightCid: '', + newActivityWeight: '', + }; + + const form = useAppForm({ + defaultValues, + onSubmit: async ({ value }) => { + const action: ActionRequiringConfirmation = { + tag: 'ARC_DsoRules', + value: { + dsoAction: { + tag: 'SRARC_UpdateFeaturedAppRight', + value: { + rightCid: value.rightCid as ContractId, + update: { reason: '', newActivityWeight: value.newActivityWeight }, + }, + }, + }, + }; + if (!showConfirmation) setShowConfirmation(true); + else + await mutation.mutateAsync({ formData: value, action }).catch(e => { + console.error('Failed to submit proposal', e); + }); + }, + validators: { + onChange: ({ value }) => + validateExpiryEffectiveDate({ + expiration: value.expiryDate, + effectiveDate: value.effectiveDate.effectiveDate, + }), + }, + }); + + useEffect(() => { + const currentRightCid = form.state.values.rightCid; + const hasSelectedOption = picker.rightOptions.some(o => o.value === currentRightCid); + if (hasSelectedOption) return; + + const nextRightCid = picker.rightOptions.length === 1 ? picker.rightOptions[0].value : ''; + form.setFieldValue('rightCid', nextRightCid); + }, [form, picker.rightOptions]); + + const partyId = useStore(form.store, state => state.values.partyId); + const rightCid = useStore(form.store, state => state.values.rightCid); + const currentWeight = picker.currentWeights[rightCid] ?? DEFAULT_APP_ACTIVITY_WEIGHT; + + const providerHasNoRights = + picker.providerSearched && picker.rightOptions.length === 0 && !validatePartyId(partyId); + + const requiredActivityWeightSubtitle = + "Required. Scales the app's share of traffic-based rewards"; + + return ( + <> + + {showConfirmation ? ( + setShowConfirmation(false)} + onSubmit={() => {}} + /> + ) : ( + <> + + {field => } + + + validatePartyId(value), + onChangeAsyncDebounceMs: 500, + onChangeAsync: ({ value }) => picker.loadFeaturedAppRightsAndValidate(value), + }} + > + {field => ( + { + picker.resetOptions(); + }} + /> + )} + + + picker.validateRightSelection(value), + onChange: ({ value }) => picker.validateRightSelection(value), + }} + > + {field => ( + + )} + + + validateRequiredActivityWeight(value), + onChange: ({ value }) => validateRequiredActivityWeight(value), + }} + > + {field => ( + + )} + + + validateExpiration(value), + onBlur: ({ value }) => validateExpiration(value), + }} + > + {field => ( + + )} + + + validateEffectiveDate(value), + onBlur: ({ value }) => validateEffectiveDate(value), + }} + children={_ => ( + + )} + /> + + validateSummary(value), + onChange: ({ value }) => validateSummary(value), + }} + > + {field => } + + + validateUrl(value), + onChange: ({ value }) => validateUrl(value), + }} + > + {field => ( + + )} + + + )} + + + + + setShowConfirmation(false)} + /> + + + + ); +}; diff --git a/apps/sv/frontend/src/components/forms/UpdateSvRewardWeightForm.tsx b/apps/sv/frontend/src/components/forms/UpdateSvRewardWeightForm.tsx index 8a535d255a..0667f05c20 100644 --- a/apps/sv/frontend/src/components/forms/UpdateSvRewardWeightForm.tsx +++ b/apps/sv/frontend/src/components/forms/UpdateSvRewardWeightForm.tsx @@ -17,7 +17,17 @@ import { validateUrl, validateWeight, } from './formValidators'; -import { THRESHOLD_DEADLINE_SUBTITLE } from '../../utils/constants'; +import { + CREATE_PROPOSAL_LABEL_EFFECTIVE_AT, + CREATE_PROPOSAL_LABEL_MEMBER, + CREATE_PROPOSAL_LABEL_PROPOSAL_SUMMARY, + CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + CREATE_PROPOSAL_LABEL_SUPPORTING_URL, + CREATE_PROPOSAL_LABEL_THRESHOLD_DEADLINE, + CREATE_PROPOSAL_LABEL_WEIGHT, + SUPPORTING_URL_PLACEHOLDER, + THRESHOLD_DEADLINE_SUBTITLE, +} from '../../utils/constants'; import { createProposalActions, formatBasisPoints, @@ -117,7 +127,12 @@ export const UpdateSvRewardWeightForm: React.FC = _ => { return ( <> - + {showConfirmation ? ( { ) : ( <> - {field => } + {field => ( + + )} { > {field => ( form.resetField('weight')} @@ -166,7 +186,7 @@ export const UpdateSvRewardWeightForm: React.FC = _ => { > {field => ( @@ -182,7 +202,7 @@ export const UpdateSvRewardWeightForm: React.FC = _ => { > {field => ( @@ -197,6 +217,7 @@ export const UpdateSvRewardWeightForm: React.FC = _ => { }} children={_ => ( @@ -210,7 +231,12 @@ export const UpdateSvRewardWeightForm: React.FC = _ => { onChange: ({ value }) => validateSummary(value), }} > - {field => } + {field => ( + + )} { onChange: ({ value }) => validateUrl(value), }} > - {field => } + {field => ( + + )} )} diff --git a/apps/sv/frontend/src/components/forms/formValidators.ts b/apps/sv/frontend/src/components/forms/formValidators.ts index bb6b9557f2..ed7954ceba 100644 --- a/apps/sv/frontend/src/components/forms/formValidators.ts +++ b/apps/sv/frontend/src/components/forms/formValidators.ts @@ -12,6 +12,8 @@ export const urlSchema = z.string().refine(url => isValidUrl(url), { export const summarySchema = z.string().min(1, { message: 'Summary is required' }); +export const reasonSchema = z.string().min(1, { message: 'Reason is required' }); + export const svSelectionSchema = z.string().min(1, { message: 'SV is required' }); const getExpirationSchema = (errMessage: string) => { @@ -66,6 +68,31 @@ export const rewardAmountSchema = z { message: 'Amount can have at most 10 decimal places' } ); +export const requiredActivityWeightSchema = z + .string() + .min(1, { message: 'Weight is required' }) + .regex(/^\d+(\.\d+)?$/, { message: 'Weight must be a valid non-negative number' }) + .refine( + v => { + const i = v.indexOf('.'); + return i === -1 || v.length - i - 1 <= 10; + }, + { message: 'Weight can have at most 10 decimal places' } + ); + +export const activityWeightSchema = z + .string() + .refine(v => v === '' || /^\d+(\.\d+)?$/.test(v), { + message: 'Weight must be a valid non-negative number', + }) + .refine( + v => { + const dotIndex = v.indexOf('.'); + return dotIndex === -1 || v.length - dotIndex - 1 <= 10; + }, + { message: 'Weight can have at most 10 decimal places' } + ); + export const validateWeight = (value: string): string | false => { const result = svWeightSchema.safeParse(value); return result.success ? false : result.error.issues[0].message; @@ -76,6 +103,16 @@ export const validateRewardAmount = (value: string): string | false => { return result.success ? false : result.error.issues[0].message; }; +export const validateActivityWeight = (value: string): string | false => { + const result = activityWeightSchema.safeParse(value); + return result.success ? false : result.error.issues[0].message; +}; + +export const validateRequiredActivityWeight = (value: string): string | false => { + const result = requiredActivityWeightSchema.safeParse(value); + return result.success ? false : result.error.issues[0].message; +}; + export const validateSvSelection = (value: string): string | false => { const result = svSelectionSchema.safeParse(value); return result.success ? false : result.error.issues[0].message; @@ -148,6 +185,11 @@ export const validateSummary = (value: string): string | false => { return result.success ? false : result.error.issues[0].message; }; +export const validateReason = (value: string): string | false => { + const result = reasonSchema.safeParse(value); + return result.success ? false : result.error.issues[0].message; +}; + export const validateUrl = (value: string): string | false => { const result = urlSchema.safeParse(value); return result.success ? false : result.error.issues[0].message; diff --git a/apps/sv/frontend/src/components/governance/ActionRequiredSection.tsx b/apps/sv/frontend/src/components/governance/ActionRequiredSection.tsx index 89d0ff1d4b..17adc8abd5 100644 --- a/apps/sv/frontend/src/components/governance/ActionRequiredSection.tsx +++ b/apps/sv/frontend/src/components/governance/ActionRequiredSection.tsx @@ -5,7 +5,11 @@ import { ContractId } from '@daml/types'; import { East } from '@mui/icons-material'; import { Alert, Box, Stack, Typography } from '@mui/material'; import { Link as RouterLink } from 'react-router'; -import { CopyableIdentifier, MemberIdentifier, PageSectionHeader } from '../../components/beta'; +import { CopyableIdentifier, PageSectionHeader } from '../../components/beta'; +import { + CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + VOTE_PROPOSAL_CONTRACT_ID_LABEL, +} from '../../utils/constants'; import React from 'react'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; @@ -19,18 +23,19 @@ export interface ActionRequiredData { votingCloses: string; createdAt: string; requester: string; - isYou?: boolean; } export interface ActionRequiredProps { actionRequiredRequests: ActionRequiredData[]; + noDataMessage?: string; } -export const ActionRequiredSection: React.FC = ( - props: ActionRequiredProps -) => { - const { actionRequiredRequests } = props; +const DEFAULT_NO_DATA_MESSAGE = 'No Action Required items available'; +export const ActionRequiredSection: React.FC = ({ + actionRequiredRequests, + noDataMessage = DEFAULT_NO_DATA_MESSAGE, +}) => { // Sort by voting closes date ascending (closest deadline first) const sortedRequests = actionRequiredRequests.toSorted((a, b) => dayjs(a.votingCloses).isBefore(dayjs(b.votingCloses)) ? -1 : 1 @@ -41,13 +46,14 @@ export const ActionRequiredSection: React.FC = ( {sortedRequests.length === 0 ? ( - No Action Required items available + {noDataMessage} ) : ( sortedRequests.map((ar, index) => ( @@ -59,7 +65,6 @@ export const ActionRequiredSection: React.FC = ( contractId={ar.contractId} votingEnds={ar.votingCloses} requester={ar.requester} - isYou={ar.isYou} /> )) )} @@ -75,11 +80,13 @@ interface ActionCardProps { contractId: ContractId; votingEnds: string; requester: string; - isYou?: boolean; } +const actionRequiredGridTemplate = + 'minmax(0, 0.85fr) minmax(0, 1fr) minmax(0, 1.15fr) minmax(0, 0.75fr) minmax(0, 0.85fr) 270px auto'; + const ActionCard = (props: ActionCardProps) => { - const { action, description, createdAt, contractId, votingEnds, requester, isYou } = props; + const { action, description, createdAt, contractId, votingEnds, requester } = props; const remainingTime = dayjs(votingEnds).fromNow(true); return ( @@ -91,99 +98,87 @@ const ActionCard = (props: ActionCardProps) => { - - - - - - - {description} - - } - data-testid="action-required-description" - /> - - - - } - data-testid="action-required-contract-id-segment" - /> - - - - - - + + {description} + + } + data-testid="action-required-description" + /> + - - - - } - data-testid="action-required-requester" + } + data-testid="action-required-contract-id-segment" + /> + + + - - - - View Details - - - + } + data-testid="action-required-submitted-by" + /> + + + View Details + +
@@ -201,14 +196,25 @@ const ActionCardSegment: React.FC = ({ content, 'data-testid': testId, }) => ( - + {title} @@ -219,13 +225,13 @@ const ActionCardSegment: React.FC = ({ color="text.light" fontWeight="medium" fontSize={14} - lineHeight={2} + lineHeight="26px" data-testid={`${testId}-content`} > {content} ) : ( - content + {content} )} ); diff --git a/apps/sv/frontend/src/components/governance/CancelProposalDialog.tsx b/apps/sv/frontend/src/components/governance/CancelProposalDialog.tsx new file mode 100644 index 0000000000..60b567aac1 --- /dev/null +++ b/apps/sv/frontend/src/components/governance/CancelProposalDialog.tsx @@ -0,0 +1,96 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import { Box, Button, Dialog, Typography } from '@mui/material'; +import React from 'react'; +import { + CREATE_PROPOSAL_CARD_BG, + CREATE_PROPOSAL_DISCARD_CTA, +} from '../../constants/createProposalLayout'; +import { + createProposalCancelButtonSx, + createProposalDiscardButtonSx, +} from '../../constants/formButtonStyles'; + +export interface CancelProposalDialogProps { + open: boolean; + onClose: () => void; + onConfirm: () => void; +} + +export const CancelProposalDialog: React.FC = ({ + open, + onClose, + onConfirm, +}) => ( + + + + + + + + Are you sure you want to cancel this form? + + + + Any information you have entered on this vote will be lost and cannot be recovered. + + + + + + + + + + +); diff --git a/apps/sv/frontend/src/components/governance/ConfigValuesChanges.tsx b/apps/sv/frontend/src/components/governance/ConfigValuesChanges.tsx index 530e0d4b1c..d1964b8435 100644 --- a/apps/sv/frontend/src/components/governance/ConfigValuesChanges.tsx +++ b/apps/sv/frontend/src/components/governance/ConfigValuesChanges.tsx @@ -2,8 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { Box, Typography } from '@mui/material'; -import { ConfigChange } from '../../utils/types'; import { PartyId } from '@canton-network/splice-common-frontend'; +import { CREATE_PROPOSAL_FIELD_BODY_SX } from '../../constants/createProposalLayout'; +import { ConfigChange } from '../../utils/types'; interface ConfigValuesChangesProps { changes: ConfigChange[]; @@ -12,7 +13,8 @@ interface ConfigValuesChangesProps { export const ConfigValuesChanges: React.FC = props => { const { changes, isSummaryView } = props; - const textColor = isSummaryView ? 'text.secondary' : 'text.primary'; + const textColor = isSummaryView ? undefined : 'text.primary'; + const summaryLabelSx = isSummaryView ? CREATE_PROPOSAL_FIELD_BODY_SX : undefined; return ( = props => {changes.length === 0 && ( - + No changes found. @@ -31,17 +37,41 @@ export const ConfigValuesChanges: React.FC = props => {changes.map((change, index) => ( - - {change.label} - + + + {change.label} + + {change.disabled && ( + + Disabled field + + )} + {change.currentValue && ( <> diff --git a/apps/sv/frontend/src/components/governance/InitiateProposalHeader.tsx b/apps/sv/frontend/src/components/governance/InitiateProposalHeader.tsx new file mode 100644 index 0000000000..bb5e6b1f36 --- /dev/null +++ b/apps/sv/frontend/src/components/governance/InitiateProposalHeader.tsx @@ -0,0 +1,43 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Box, Typography } from '@mui/material'; +import React from 'react'; +import { CREATE_PROPOSAL_FIELD_HELPER_SX } from '../../constants/createProposalLayout'; + +export interface InitiateProposalHeaderProps { + actionName: string; + isReviewStep?: boolean; +} + +export const InitiateProposalHeader: React.FC = ({ + actionName, + isReviewStep = false, +}) => { + if (!isReviewStep) { + return null; + } + + return ( + + + {actionName} + + + Review your proposal before submitting + + + ); +}; diff --git a/apps/sv/frontend/src/components/governance/InitiateProposalLayout.tsx b/apps/sv/frontend/src/components/governance/InitiateProposalLayout.tsx new file mode 100644 index 0000000000..68b3db78a9 --- /dev/null +++ b/apps/sv/frontend/src/components/governance/InitiateProposalLayout.tsx @@ -0,0 +1,24 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Box } from '@mui/material'; +import React from 'react'; +import { CONTENT_MAX_WIDTH } from '../../theme/tokens'; + +export interface InitiateProposalLayoutProps { + children: React.ReactNode; +} + +export const InitiateProposalLayout: React.FC = ({ children }) => ( + + {children} + +); diff --git a/apps/sv/frontend/src/components/governance/JsonDiffAccordion.tsx b/apps/sv/frontend/src/components/governance/JsonDiffAccordion.tsx index 94127165a9..0a09fcaf81 100644 --- a/apps/sv/frontend/src/components/governance/JsonDiffAccordion.tsx +++ b/apps/sv/frontend/src/components/governance/JsonDiffAccordion.tsx @@ -1,22 +1,269 @@ // Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { useState } from 'react'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from '@mui/material'; +import { Box, Collapse, Typography } from '@mui/material'; + +const JSON_DIFF_FRAME_BACKGROUND = '#363636'; + +const JSON_DIFF_VIEWPORT_MAX_HEIGHT = '320px'; + +const jsonDiffMonoSx = { + fontFamily: '"Source Code Pro", monospace', + fontSize: '14px', + fontWeight: 400, + lineHeight: '26px', + fontFeatureSettings: "'liga' off, 'clig' off", +} as const; + +const DIFF = '& [data-testid="config-diffs-display"]'; + +const jsonSectionTitleSx = { + color: 'common.white', + fontFamily: 'Inter, sans-serif', + fontSize: 12, + fontWeight: 600, + lineHeight: '22px', + textTransform: 'uppercase', + margin: 0, + display: 'block', +} as const; + +const toggleSx = { + display: 'inline-flex', + boxSizing: 'border-box', + height: '28px', + minWidth: '108px', + padding: '2px 8px', + alignItems: 'center', + justifyContent: 'center', + gap: '4px', + borderRadius: '2px', + border: '1px solid', + borderColor: 'secondary.main', + bgcolor: 'transparent', + cursor: 'pointer', + margin: 0, + minHeight: 0, + lineHeight: 0, + flexShrink: 0, +}; + +const toggleLabelSx = { + color: 'common.white', + fontFamily: 'Inter, sans-serif', + fontSize: 14, + fontWeight: 400, + lineHeight: '22px', + margin: 0, + display: 'block', +}; + +const jsonDiffFrameSx = { + display: 'flex', + padding: '12px 24px', + justifyContent: 'flex-start', + alignItems: 'stretch', + gap: '10px', + alignSelf: 'stretch', + width: '100%', + minWidth: 0, + maxWidth: '100%', + boxSizing: 'border-box', + backgroundColor: JSON_DIFF_FRAME_BACKGROUND, +} as const; + +const collapseSx = { + width: '100%', + minWidth: 0, + maxWidth: '100%', + alignSelf: 'stretch', + overflow: 'hidden', + '& .MuiCollapse-wrapperInner': { + width: '100%', + minWidth: 0, + maxWidth: '100%', + }, +} as const; + +const jsonDiffHeaderRowSx = { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + alignSelf: 'stretch', + width: '100%', +} as const; + +const jsonDiffRootSx = { + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + alignSelf: 'stretch', + width: '100%', + minWidth: 0, + maxWidth: '100%', + overflow: 'hidden', +} as const; + +const jsonDiffViewportSx = { + width: '100%', + minWidth: 0, + maxHeight: JSON_DIFF_VIEWPORT_MAX_HEIGHT, + overflowY: 'auto', + overflowX: 'hidden', + + '& > div': { + width: '100%', + minWidth: 0, + maxWidth: '100%', + }, + + '& [data-testid="stringify-display"], & [data-testid="config-diffs-display"]': { + width: '100%', + }, + + '& [data-testid="stringify-display"]': { + ...jsonDiffMonoSx, + color: 'common.white', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + overflowWrap: 'break-word', + }, + + [`${DIFF} .jsondiffpatch-delta`]: { + ...jsonDiffMonoSx, + color: 'common.white', + display: 'block', + maxWidth: '100%', + boxSizing: 'border-box', + }, + + [`${DIFF} > .jsondiffpatch-delta`]: { + padding: 0, + }, + + [`${DIFF} .jsondiffpatch-delta pre`]: { + ...jsonDiffMonoSx, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + overflowWrap: 'break-word', + }, + + // Unchanged rows inherit grey onto keys and values (do not set color on pre/property-name directly). + [`${DIFF} .jsondiffpatch-unchanged, ${DIFF} .jsondiffpatch-movedestination`]: { + color: 'gray', + }, + + [`${DIFF} .jsondiffpatch-delta ul, ${DIFF} ul.jsondiffpatch-delta`]: { + listStyleType: 'none', + padding: '0 0 0 20px', + margin: 0, + }, + + [`${DIFF} li`]: { + display: 'block', + }, + + [`${DIFF} .jsondiffpatch-added .jsondiffpatch-value pre::after, ${DIFF} .jsondiffpatch-modified .jsondiffpatch-right-value pre::after, ${DIFF} .jsondiffpatch-deleted .jsondiffpatch-value pre::after`]: + { + content: '""', + padding: 0, + }, + + [`${DIFF} li.jsondiffpatch-added:not(:last-child) > .jsondiffpatch-value::after, ${DIFF} li.jsondiffpatch-deleted:not(:last-child) > .jsondiffpatch-value::after, ${DIFF} li.jsondiffpatch-modified:not(:last-child) > .jsondiffpatch-right-value::after`]: + { + content: '","', + color: 'common.white', + padding: 0, + }, + + [`${DIFF} .jsondiffpatch-modified .jsondiffpatch-right-value`]: { + marginLeft: 0, + }, + [`${DIFF} .jsondiffpatch-modified .jsondiffpatch-right-value::before`]: { + content: '" -> "', + }, +} as const; + +interface JsonToggleButtonProps { + expanded: boolean; + onClick: () => void; +} + +const JsonToggleButton: React.FC = ({ expanded, onClick }) => ( + + + {expanded ? 'Hide JSON' : 'Show JSON'} + + + +); + +export interface JsonDiffAccordionProps { + children: React.ReactNode; + /** `form` — JSON label left, toggle right; `review` — toggle only. */ + variant?: 'form' | 'review'; +} + +export const JsonDiffAccordion: React.FC = ({ + children, + variant = 'review', +}) => { + const [expanded, setExpanded] = useState(false); + const toggle = ( + setExpanded(current => !current)} /> + ); + + const content = ( + + + {children} + + + ); -export const JsonDiffAccordion = ({ children }: { children: React.ReactNode }): JSX.Element => { return ( - - - } - aria-controls="json-diff-content" - id="json-diff-header" - > - JSON Diffs - - {children} - + + {variant === 'form' ? ( + + + JSON + + {toggle} + + ) : ( + {toggle} + )} + {content} ); }; diff --git a/apps/sv/frontend/src/components/governance/ProposalDetailsContent.tsx b/apps/sv/frontend/src/components/governance/ProposalDetailsContent.tsx index e12d28c38b..a0a1c3ff2b 100644 --- a/apps/sv/frontend/src/components/governance/ProposalDetailsContent.tsx +++ b/apps/sv/frontend/src/components/governance/ProposalDetailsContent.tsx @@ -7,8 +7,19 @@ import { VoteRequest, } from '@daml.js/splice-dso-governance/lib/Splice/DsoRules'; import { ContractId } from '@daml/types'; -import { ChevronLeft, Edit } from '@mui/icons-material'; -import { Box, Button, Divider, Stack, Tab, Tabs, Typography } from '@mui/material'; +import { ChevronLeft, ContentCopy, Edit } from '@mui/icons-material'; +import { + Alert, + Box, + Button, + Chip, + Divider, + IconButton, + Stack, + Tab, + Tabs, + Typography, +} from '@mui/material'; import React, { PropsWithChildren, useEffect, useMemo, useRef, useState } from 'react'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; @@ -20,6 +31,7 @@ import { } from '@canton-network/splice-common-frontend'; import { Link as RouterLink } from 'react-router'; import { + ConfigChange, ProposalDetails, ProposalVote, ProposalVotingInformation, @@ -34,6 +46,22 @@ import { CreateUnallocatedUnclaimedActivityRecordSection } from './proposal-deta import { CopyableIdentifier, CopyableUrl, MemberIdentifier, VoteStats } from '../beta'; import { useQuery } from '@tanstack/react-query'; import { useSvAdminClient } from '../../contexts/SvAdminServiceContext'; +import { + DEFAULT_APP_ACTIVITY_WEIGHT, + EFFECTIVE_AT_LABEL, + PROPOSAL_CREATED_LABEL, + PROPOSAL_SUMMARY_TITLE, + SUPPORTING_URL_LABEL, + THRESHOLD_DEADLINE_LABEL, + VOTE_PROPOSAL_CONTRACT_ID_LABEL, + VOTE_REASON_SUMMARY_LABEL, + VOTE_REASON_URL_LABEL, +} from '../../utils/constants'; + +/** True when a proposal changed fields that are locked/disabled in the create UI (e.g. emergency API). */ +export function hasAlteredDisabledFields(changes: ConfigChange[]): boolean { + return changes.some(c => c.disabled && c.currentValue !== c.newValue); +} dayjs.extend(relativeTime); @@ -47,18 +75,13 @@ export interface ProposalDetailsContentProps { type VoteTab = Extract | 'all'; -const now = () => dayjs(); - export const ProposalDetailsContent: React.FC = props => { const { contractId, proposalDetails, votingInformation, votes, currentSvPartyId } = props; const votesHooks = useVotesHooks(); const dsoInfoQuery = useDsoInfos(); - const isEffective = - votingInformation.voteTakesEffect && dayjs(votingInformation.voteTakesEffect).isBefore(now()); - const isClosed = - !proposalDetails.isVoteRequest || isEffective || votingInformation.status === 'Rejected'; + const isClosed = !proposalDetails.isVoteRequest || votingInformation.status === 'Rejected'; const dsoConfigToCompareWith = useMemo(() => { if (proposalDetails.action === 'SRARC_SetConfig') { @@ -187,13 +210,15 @@ export const ProposalDetailsContent: React.FC = pro size="small" color="secondary" startIcon={} + data-testid="proposal-details-back-to-all-votes" > Back to all votes - + {/* Figma details content starts at Action — no inner section title. */} + = pro valueId="proposal-details-action-value" /> - - } - labelId="proposal-details-contractid-label" - /> - {proposalDetails.action === 'SRARC_OffboardSv' && ( )} {proposalDetails.action === 'SRARC_GrantFeaturedAppRight' && ( - + )} {proposalDetails.action === 'SRARC_RevokeFeaturedAppRight' && ( )} + {proposalDetails.action === 'SRARC_UpdateFeaturedAppRight' && ( + + )} + {proposalDetails.action === 'SRARC_UpdateSvRewardWeight' && ( = pro {proposalDetails.action === 'CRARC_SetConfig' && ( <> + {hasAlteredDisabledFields(proposalDetails.proposal.configChanges) && ( + + Disabled fields have been altered in this vote proposal. + + )} } /> - + {amuletConfigToCompareWith ? ( = pro {proposalDetails.action === 'SRARC_SetConfig' && ( <> + {hasAlteredDisabledFields(proposalDetails.proposal.configChanges) && ( + + Disabled fields have been altered in this vote proposal. + + )} } /> - + {dsoConfigToCompareWith?.[1] ? ( = pro )} } labelId="proposal-details-url-label" /> + + + } + labelId="proposal-details-contractid-label" + /> - + = pro partyId={votingInformation.requester} isYou={false} size="large" + fullWidth data-testid="proposal-details-requester-party-id" /> } /> + + @@ -331,7 +395,8 @@ export const ProposalDetailsContent: React.FC = pro /> @@ -397,7 +462,7 @@ export const ProposalDetailsContent: React.FC = pro {getFilteredVotes().map((vote, index) => ( @@ -447,7 +512,8 @@ export const ProposalDetailsContent: React.FC = pro }; interface VoteSectionProps extends PropsWithChildren { - title: string; + /** Section heading (e.g. Proposal Information). Omit when Figma has no heading above the fields. */ + title?: string; 'data-testid': string; bordered?: boolean; centered?: boolean; @@ -456,9 +522,11 @@ interface VoteSectionProps extends PropsWithChildren { const VoteSection = React.forwardRef( ({ title, children, 'data-testid': testId, bordered = false, centered = false }, ref) => ( - - {title} - + {title !== undefined && ( + + {title} + + )} void; } +/** Gap between party-ID / You and the copy icon. */ +const VOTE_ROW_ACCESSORY_GAP_PX = 8; +/** Gap between copy icon and status column. */ +const VOTE_ROW_STATUS_GAP_PX = 40; +/** Fixed copy column so every row’s copy icon shares one vertical edge. */ +const VOTE_ROW_COPY_COL_WIDTH_PX = 40; +/** Fixed status column so Accepted / Awaiting Response share the right edge. */ +const VOTE_ROW_STATUS_COL_WIDTH_PX = 170; +/** Trailing tracks (8 + copy + 40 + status) — party-ID (+ You) width is calc(100% − this). */ +const VOTE_ROW_FIXED_TRAILING_PX = + VOTE_ROW_ACCESSORY_GAP_PX + + VOTE_ROW_COPY_COL_WIDTH_PX + + VOTE_ROW_STATUS_GAP_PX + + VOTE_ROW_STATUS_COL_WIDTH_PX; + const VoteItem: React.FC = ({ voter, url, @@ -501,29 +584,104 @@ const VoteItem: React.FC = ({ <> - - - - + + + + + + {isYou && ( + + )} + {comment && ( - - {comment} - + + + {VOTE_REASON_SUMMARY_LABEL} + + + {comment} + + + )} + {url && ( + + + {VOTE_REASON_URL_LABEL} + + + )} - {url && } - + + + { + e.stopPropagation(); + e.preventDefault(); + navigator.clipboard.writeText(voter); + }} + > + + + + + = ({ data-testid="your-vote-edit-button" sx={{ fontSize: 16, + minWidth: 0, + px: 0, }} > Edit @@ -566,6 +726,7 @@ const OffboardMemberSection = ({ memberPartyId }: OffboardMemberSectionProps) => partyId={memberPartyId} isYou={false} size="large" + fullWidth data-testid="proposal-details-member-party-id" /> } @@ -576,9 +737,10 @@ const OffboardMemberSection = ({ memberPartyId }: OffboardMemberSectionProps) => interface FeatureAppSectionProps { provider: string; + activityWeight: string; } -const FeatureAppSection = ({ provider }: FeatureAppSectionProps) => { +const FeatureAppSection = ({ provider, activityWeight }: FeatureAppSectionProps) => { return ( { } labelId="proposal-details-feature-app-label" /> + ); }; @@ -649,6 +817,79 @@ const UnfeatureAppSection = ({ rightContractId }: UnfeatureAppSectionProps) => { ); }; +interface UpdateFeatureAppSectionProps { + rightContractId: string; + newActivityWeight: string; +} + +const UpdateFeatureAppSection = ({ + rightContractId, + newActivityWeight, +}: UpdateFeatureAppSectionProps) => { + const svAdminClient = useSvAdminClient(); + const providerQuery = useQuery({ + queryKey: ['featuredAppRightProviderAndWeight', rightContractId], + queryFn: async () => { + const response = await svAdminClient.lookupFeaturedAppRightByContractId(rightContractId); + const contract = response.featured_app_right; + const payload = contract?.payload as + | { provider?: string; activityWeight?: string | null } + | undefined; + return { + provider: payload?.provider ?? null, + currentWeight: contract ? (payload?.activityWeight ?? DEFAULT_APP_ACTIVITY_WEIGHT) : '', + }; + }, + }); + return ( + + {providerQuery?.data?.provider && ( + + } + labelId="proposal-details-update-feature-label" + /> + )} + + } + labelId="proposal-details-update-feature-app-label" + /> + + } + /> + + ); +}; + interface UpdateSvRewardWeightSectionProps { svToUpdate: string; currentWeight: string; @@ -673,6 +914,7 @@ const UpdateSvRewardWeightSection = ({ partyId={svToUpdate} isYou={false} size="large" + fullWidth data-testid="proposal-details-member-party-id" /> } diff --git a/apps/sv/frontend/src/components/governance/ProposalListingSection.tsx b/apps/sv/frontend/src/components/governance/ProposalListingSection.tsx index a9609f88c6..9e29b47fbe 100644 --- a/apps/sv/frontend/src/components/governance/ProposalListingSection.tsx +++ b/apps/sv/frontend/src/components/governance/ProposalListingSection.tsx @@ -18,6 +18,11 @@ import { VoteRequest } from '@daml.js/splice-dso-governance/lib/Splice/DsoRules' import { ContractId } from '@daml/types'; import { useNavigate } from 'react-router'; import { CopyableIdentifier, PageSectionHeader, VoteStats } from '../../components/beta'; +import { + CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + THRESHOLD_DEADLINE_LABEL, + VOTE_PROPOSAL_CONTRACT_ID_LABEL, +} from '../../utils/constants'; import { ProposalListingData, ProposalListingStatus, YourVoteStatus } from '../../utils/types'; import { InfoOutlined } from '@mui/icons-material'; import dayjs from 'dayjs'; @@ -31,6 +36,9 @@ interface ProposalListingSectionProps { data: ProposalListingData[]; noDataMessage: string; uniqueId: string; + badgeCount?: number; + isLoading?: boolean; + loadingMessage?: string; showThresholdDeadline?: boolean; showVoteStats?: boolean; showStatus?: boolean; @@ -58,7 +66,7 @@ const sortProposals = ( return data.toSorted((a, b) => dayjs(b.voteTakesEffect).diff(dayjs(a.voteTakesEffect))); } - // For effectiveAtAsc (Inflight Votes): + // For effectiveAtAsc (In-flight Proposals): // Threshold items first (by votes desc, then deadline asc), then dated items (by effective date asc) return data .toSorted((a, b) => dayjs(a.votingThresholdDeadline).diff(dayjs(b.votingThresholdDeadline))) @@ -66,17 +74,94 @@ const sortProposals = ( .toSorted((a, b) => getEffectiveDate(a).diff(getEffectiveDate(b))); }; -const getColumnsCount = (...shown: (boolean | undefined)[]) => 4 + shown.filter(Boolean).length; +const getColumnsCount = (...shown: (boolean | undefined)[]) => 5 + shown.filter(Boolean).length; const getGridTemplate = (columnsCount: number) => `minmax(0, 1fr) minmax(0, 0.7fr) ${'1fr '.repeat(columnsCount - 2).trim()}`; +const governanceTableHeadCellSx = { + py: '10px', + px: '16px', + fontSize: 12, + fontWeight: 600, + textTransform: 'uppercase' as const, + color: 'colors.neutral.80', + borderBottom: 'none', + display: 'flex', + alignItems: 'center', +}; + +const governanceTableBodyCellSx = { + py: '15px', + px: '16px', + borderBottom: 'none', + display: 'flex', + alignItems: 'center', + alignSelf: 'stretch', + minWidth: 0, +}; + +interface SubmittedByCellProps { + requester: string; + uniqueId: string; +} + +const identifierCellSx = { + ...governanceTableBodyCellSx, + overflow: 'visible', +}; + +const SubmittedByCell: React.FC = ({ requester, uniqueId }) => ( + + + +); + +interface TableHeaderProps { + showThresholdDeadline?: boolean; + showStatus?: boolean; + showVoteStats?: boolean; +} + +const TableHeader: React.FC = ({ + showThresholdDeadline, + showStatus, + showVoteStats, +}) => ( + <> + {CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE} + {VOTE_PROPOSAL_CONTRACT_ID_LABEL} + {showThresholdDeadline ? ( + <> + {THRESHOLD_DEADLINE_LABEL} + SUBMITTED BY + EFFECTIVE AT + + ) : ( + <> + EFFECTIVE AT + SUBMITTED BY + {showStatus && STATUS} + + )} + {showVoteStats && VOTES} + YOUR VOTE + +); + export const ProposalListingSection: React.FC = props => { const { sectionTitle, data, noDataMessage, uniqueId, + badgeCount, + isLoading, + loadingMessage = 'Searching…', showThresholdDeadline, showVoteStats, showStatus, @@ -105,23 +190,29 @@ export const ProposalListingSection: React.FC = pro return ( - + {sortedData.length === 0 && !hasNextPage ? ( - + isLoading ? ( + + ) : ( + + ) ) : ( <> - ACTION - VOTE PROPOSAL CONTRACT ID - {showThresholdDeadline && THRESHOLD DEADLINE} - EFFECTIVE AT - {showStatus && STATUS} - {showVoteStats && VOTES} - YOUR VOTE + @@ -131,6 +222,7 @@ export const ProposalListingSection: React.FC = pro actionName={vote.actionName} description={vote.description} contractId={vote.contractId} + requester={vote.requester} uniqueId={uniqueId} votingThresholdDeadline={vote.votingThresholdDeadline} voteTakesEffect={vote.voteTakesEffect} @@ -216,10 +308,33 @@ const InfoBox: React.FC = ({ info, 'data-testid': testId }) => { ); }; +interface LoadingBoxProps { + message: string; + 'data-testid': string; +} + +const LoadingBox: React.FC = ({ message, 'data-testid': testId }) => { + return ( + + + + {message} + + + ); +}; + interface VoteRowProps { actionName: string; description?: string; contractId: ContractId; + requester: string; status: ProposalListingStatus; uniqueId: string; voteStats: Record; @@ -237,6 +352,7 @@ const VoteRow: React.FC = React.memo(props => { actionName, description, contractId, + requester, status, uniqueId, voteStats, @@ -266,10 +382,24 @@ const VoteRow: React.FC = React.memo(props => { }} data-testid={`${uniqueId}-row`} > - + {actionName} @@ -277,6 +407,7 @@ const VoteRow: React.FC = React.memo(props => { = React.memo(props => { WebkitBoxOrient: 'vertical', overflow: 'hidden', textOverflow: 'ellipsis', - lineHeight: 1.4, + lineHeight: '20px', }} > {description} )} - + - {showThresholdDeadline && ( - - {votingThresholdDeadline} - - )} - - {voteTakesEffect} - - - {showStatus && ( - - {status} - + {showThresholdDeadline ? ( + <> + + {votingThresholdDeadline} + + + + {voteTakesEffect} + + + ) : ( + <> + + {voteTakesEffect} + + + {showStatus && ( + + {status} + + )} + )} {showVoteStats && ( - + = React.memo(props => { /> )} - + = ({ + id, + label, + value, + subtitle, +}) => ( + + + {label} + + + {subtitle && ( + + {subtitle} + + )} + + {typeof value === 'string' ? ( + + {value} + + ) : ( + + {value} + + )} + +); diff --git a/apps/sv/frontend/src/components/governance/ProposalSearch.tsx b/apps/sv/frontend/src/components/governance/ProposalSearch.tsx new file mode 100644 index 0000000000..6a7212d096 --- /dev/null +++ b/apps/sv/frontend/src/components/governance/ProposalSearch.tsx @@ -0,0 +1,215 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { memo, useEffect, useRef, useState } from 'react'; +import SearchIcon from '@mui/icons-material/Search'; +import { Box, InputAdornment, Link, TextField, Typography } from '@mui/material'; +import type { Theme } from '@mui/material/styles'; +import { useNavigate, useSearchParams } from 'react-router'; +import { ContractId } from '@daml/types'; +import { VoteRequest } from '@daml.js/splice-dso-governance/lib/Splice/DsoRules'; +import { CONTRACT_ID_VALIDATION_MESSAGE, isValidContractId } from '../../utils/proposalSearch'; +import { + fieldDescriptionSx, + fieldSectionSx, + fieldSectionTitleSx, + singleLineFieldSx, +} from '../../themes/fieldStyles'; +import { scrollableTextFieldSx } from '../beta/identifierStyles'; + +const searchTextFieldSx = (theme: Theme) => ({ + ...(typeof singleLineFieldSx === 'function' ? singleLineFieldSx(theme) : singleLineFieldSx), + ...scrollableTextFieldSx, +}); + +function getEffectiveSearchQuery(value: string): string { + const trimmed = value.trim(); + return isValidContractId(trimmed) ? trimmed : ''; +} + +function syncSearchQuery( + value: string, + lastSyncedSearchRef: React.MutableRefObject, + onSearchChange: (query: string) => void +): void { + const effective = getEffectiveSearchQuery(value); + if (effective === lastSyncedSearchRef.current) { + return; + } + lastSyncedSearchRef.current = effective; + onSearchChange(effective); +} + +export interface ProposalSearchProps { + onSearchChange: (query: string) => void; +} + +export const ProposalSearch: React.FC = memo(function ProposalSearch({ + onSearchChange, +}) { + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const urlQuery = searchParams.get('q') ?? ''; + const [inputValue, setInputValue] = useState(urlQuery); + /** Blocks stale URL from overwriting local input while a pending write is in flight. */ + const pendingUrlValueRef = useRef(null); + const lastSyncedSearchRef = useRef(''); + const onSearchChangeRef = useRef(onSearchChange); + onSearchChangeRef.current = onSearchChange; + + useEffect(() => { + setInputValue(prev => { + if (prev === urlQuery) { + if (pendingUrlValueRef.current === urlQuery) { + pendingUrlValueRef.current = null; + } + return prev; + } + + if (pendingUrlValueRef.current !== null) { + if (urlQuery === pendingUrlValueRef.current) { + pendingUrlValueRef.current = null; + } + return prev; + } + + syncSearchQuery(urlQuery, lastSyncedSearchRef, query => { + onSearchChangeRef.current(query); + }); + return urlQuery; + }); + }, [urlQuery]); + + const syncUrl = (value: string) => { + const trimmed = value.trim(); + if (!trimmed || !isValidContractId(trimmed)) { + setSearchParams( + prev => { + if (!prev.has('q')) { + return prev; + } + const next = new URLSearchParams(prev); + next.delete('q'); + return next; + }, + { replace: true } + ); + return; + } + + pendingUrlValueRef.current = trimmed; + setSearchParams( + prev => { + if (prev.get('q') === trimmed) { + return prev; + } + const next = new URLSearchParams(prev); + next.set('q', trimmed); + return next; + }, + { replace: true } + ); + }; + + const handleChange = (value: string) => { + pendingUrlValueRef.current = value; + setInputValue(value); + syncSearchQuery(value, lastSyncedSearchRef, query => { + onSearchChangeRef.current(query); + }); + syncUrl(value); + }; + + const handleClear = () => { + pendingUrlValueRef.current = ''; + lastSyncedSearchRef.current = ''; + setInputValue(''); + onSearchChangeRef.current(''); + syncUrl(''); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'Enter') { + return; + } + + const trimmed = inputValue.trim(); + if (isValidContractId(trimmed)) { + event.preventDefault(); + syncSearchQuery(trimmed, lastSyncedSearchRef, query => { + onSearchChangeRef.current(query); + }); + syncUrl(trimmed); + navigate(`/governance/proposals/${trimmed as ContractId}`); + } + }; + + const showValidationError = inputValue.trim().length > 0 && !isValidContractId(inputValue); + + return ( + + + Search Proposals + + + handleChange(event.target.value)} + error={showValidationError} + helperText={showValidationError ? CONTRACT_ID_VALIDATION_MESSAGE : undefined} + sx={searchTextFieldSx} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + htmlInput: { + 'data-testid': 'proposal-search-input', + onKeyDown: handleKeyDown, + }, + }} + /> + + {isValidContractId(inputValue) && ( + + Clear search + + )} + + ); +}); + +export default ProposalSearch; diff --git a/apps/sv/frontend/src/components/governance/ProposalSummary.tsx b/apps/sv/frontend/src/components/governance/ProposalSummary.tsx index f3e8c525ae..6561204b48 100644 --- a/apps/sv/frontend/src/components/governance/ProposalSummary.tsx +++ b/apps/sv/frontend/src/components/governance/ProposalSummary.tsx @@ -2,9 +2,33 @@ // SPDX-License-Identifier: Apache-2.0 import { Box, Typography } from '@mui/material'; -import { THRESHOLD_DEADLINE_SUBTITLE } from '../../utils/constants'; +import type { ReactNode } from 'react'; +import { MemberIdentifier } from '../beta'; +import { IDENTIFIER_COMPACT_MAX_WIDTH_PX } from '../beta/identifierStyles'; +import { + EFFECTIVE_AT_LABEL, + CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE, + PROPOSAL_REVIEW_TITLE, + THRESHOLD_DEADLINE_LABEL, + THRESHOLD_DEADLINE_SUBTITLE, +} from '../../utils/constants'; import type { ConfigChange } from '../../utils/types'; import { ConfigValuesChanges } from './ConfigValuesChanges'; +import { ProposalReviewField } from './ProposalReviewField'; + +/** Figma review party IDs: Source Code Pro 14px + copy (node 4832:4323). */ +const ReviewPartyId: React.FC<{ partyId: string; 'data-testid': string }> = ({ + partyId, + 'data-testid': testId, +}) => ( + +); interface BaseProposalSummaryProps { actionName: string; @@ -31,6 +55,7 @@ type ProposalSummaryProps = BaseProposalSummaryProps & | { formType: 'grant-right'; grantRight: string; + activityWeight: string; } | { formType: 'revoke-right'; @@ -40,6 +65,8 @@ type ProposalSummaryProps = BaseProposalSummaryProps & | { formType: 'config-change'; configFormData: ConfigChange[]; + /** Rendered under Proposed Configuration Changes (e.g. Show JSON). */ + jsonDiff?: ReactNode; } | { formType: 'create-unallocated-unclaimed-activity-record'; @@ -47,49 +74,73 @@ type ProposalSummaryProps = BaseProposalSummaryProps & amount: string; expiresAt: string; } + | { + formType: 'update-right-weight'; + providerPartyId: string; + rightCid: string; + currentActivityWeight: string; + newActivityWeight: string; + } ); export const ProposalSummary: React.FC = props => { const { formType, actionName, url, summary, expiryDate, effectiveDate } = props; return ( - - - Proposal Summary + + + {PROPOSAL_REVIEW_TITLE} - - - - - - - - + - + {/* Action-specific fields follow Action (Figma: config/member before threshold). */} + {formType === 'config-change' && ( + + } + /> + {props.jsonDiff} + + )} {formType === 'sv-reward-weight' && ( <> - + } /> - = props => { )} {formType === 'grant-right' && ( - + <> + } + /> + + )} {formType === 'revoke-right' && ( <> - + } /> - )} + {formType === 'update-right-weight' && ( + <> + + } + /> + + + } + /> + + )} + {formType === 'offboard' && ( - + + } + /> )} {formType === 'create-unallocated-unclaimed-activity-record' && ( <> - - - - - + + } + /> + + )} - - {formType === 'config-change' && ( - } - /> - )} - - - - ); -}; - -interface ProposalFieldProps { - id: string; - title: string; - subtitle?: string; - value: React.ReactNode; -} + -const ProposalField: React.FC = props => { - const { id, title, subtitle, value } = props; - return ( - - - {title} - + - - {subtitle && ( - - {subtitle} - - )} + - {typeof value === 'string' ? ( - - {value} - - ) : ( - value - )} + ); diff --git a/apps/sv/frontend/src/components/governance/ProposalVoteForm.tsx b/apps/sv/frontend/src/components/governance/ProposalVoteForm.tsx index bce3e45608..8642da06bc 100644 --- a/apps/sv/frontend/src/components/governance/ProposalVoteForm.tsx +++ b/apps/sv/frontend/src/components/governance/ProposalVoteForm.tsx @@ -9,7 +9,15 @@ import { isValidUrl } from '../../utils/validations'; import { ContractId } from '@daml/types'; import { VoteRequest } from '@daml.js/splice-dso-governance/lib/Splice/DsoRules'; import { ProposalVote } from '../../utils/types'; -import { Alert, Box, Button, Stack, TextField, Typography } from '@mui/material'; +import { Alert, Box, Button, TextField, Typography } from '@mui/material'; +import { CREATE_PROPOSAL_FIELD_LABEL_SX } from '../../constants/createProposalLayout'; +import { proposalSummaryFieldSx, singleLineFieldSx } from '../../themes/fieldStyles'; +import { + VOTE_REASON_PLACEHOLDER, + VOTE_REASON_SUMMARY_LABEL, + VOTE_REASON_URL_LABEL, + VOTE_REASON_URL_PLACEHOLDER, +} from '../../utils/constants'; interface CastVoteArgs { accepted: boolean; url: string; @@ -101,39 +109,26 @@ export const ProposalVoteForm: React.FC = props => { }} children={field => { return ( - - - Reason + + + {VOTE_REASON_SUMMARY_LABEL} field.handleChange(e.target.value)} error={!field.state.meta.isValid} helperText={field.state.meta.errors?.[0]} + placeholder={VOTE_REASON_PLACEHOLDER} inputProps={{ 'data-testid': 'your-vote-reason-input' }} - sx={{ - '& .MuiFilledInput-root': { - borderRadius: 1, - paddingTop: 1, - fontFamily: 'Lato', - '&:before, &:after': { - display: 'none', - }, - }, - }} + sx={proposalSummaryFieldSx} /> - + ); }} /> @@ -154,18 +149,14 @@ export const ProposalVoteForm: React.FC = props => { }} children={field => { return ( - - - Vote Reason URL + + + {VOTE_REASON_URL_LABEL} = props => { {field.state.meta.errors?.[0]} } + placeholder={VOTE_REASON_URL_PLACEHOLDER} inputProps={{ 'data-testid': 'your-vote-url-input' }} - sx={{ - '& .MuiFilledInput-root': { - borderRadius: 1, - fontFamily: 'Lato', - '&:before, &:after': { - display: 'none', - }, - }, - '& .MuiFilledInput-input': { - paddingTop: 1.5, - paddingBottom: 1.5, - }, - }} + sx={singleLineFieldSx} /> - + ); }} /> @@ -216,27 +196,27 @@ export const ProposalVoteForm: React.FC = props => { <> )} diff --git a/apps/sv/frontend/src/components/governance/proposal-details/DetailItem.tsx b/apps/sv/frontend/src/components/governance/proposal-details/DetailItem.tsx index 25d39367f2..c49c3acb60 100644 --- a/apps/sv/frontend/src/components/governance/proposal-details/DetailItem.tsx +++ b/apps/sv/frontend/src/components/governance/proposal-details/DetailItem.tsx @@ -3,6 +3,8 @@ import { Divider, Stack, Typography } from '@mui/material'; +import { CREATE_PROPOSAL_FIELD_LABEL_SX } from '../../../constants/createProposalLayout'; + interface DetailItemProps { label: string; value: React.ReactNode; @@ -16,11 +18,8 @@ export const DetailItem: React.FC = props => { return ( diff --git a/apps/sv/frontend/src/components/layout/LogoutButton.tsx b/apps/sv/frontend/src/components/layout/LogoutButton.tsx new file mode 100644 index 0000000000..fe4912914a --- /dev/null +++ b/apps/sv/frontend/src/components/layout/LogoutButton.tsx @@ -0,0 +1,68 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; + +import { Box, Typography } from '@mui/material'; + +import { layoutTokens, navItemTypography, NAV_PILL_PX } from '../../theme/tokens'; +import LogoutIcon from './LogoutIcon'; + +interface LogoutButtonProps { + onLogout: () => void; +} + +/** + * Figma Dev Mode — content box 66x17, 10px padding on each side, gap-2.5 (10px) + * between icon and label. Plain `Box component="button"` (matching `SvNavLink`'s + * pattern) instead of MUI `Button` — MUI's own min-height/padding/ripple defaults + * previously inflated this to ~89.78x38 despite the padding value being correct. + */ +const LogoutButton: React.FC = ({ onLogout }) => ( + + + + Logout + + +); + +export default LogoutButton; diff --git a/apps/sv/frontend/src/components/layout/LogoutIcon.tsx b/apps/sv/frontend/src/components/layout/LogoutIcon.tsx new file mode 100644 index 0000000000..d979051d7c --- /dev/null +++ b/apps/sv/frontend/src/components/layout/LogoutIcon.tsx @@ -0,0 +1,33 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; + +import { Box } from '@mui/material'; + +/** + * Figma logout glyph — door bracket (open right) with an arrow exiting through it. + * Path data reconstructed from the true Figma vector export (CF-design-system/svgs), + * not the lossy Tailwind HTML export — the div-based export flattens this icon into + * two filled bars that don't resemble a logout glyph at all. + */ +const LogoutIcon: React.FC = () => ( + + + + + + + +); + +export default LogoutIcon; diff --git a/apps/sv/frontend/src/components/layout/NavAttentionIcon.tsx b/apps/sv/frontend/src/components/layout/NavAttentionIcon.tsx new file mode 100644 index 0000000000..960fce26b7 --- /dev/null +++ b/apps/sv/frontend/src/components/layout/NavAttentionIcon.tsx @@ -0,0 +1,31 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; + +import { Box } from '@mui/material'; + +/** Figma nav warning icon for Delegate Election (yellow triangle + exclamation cutout). */ +const NavAttentionIcon: React.FC = () => ( + + + + + +); + +export default NavAttentionIcon; diff --git a/apps/sv/frontend/src/components/layout/NavCountBadge.tsx b/apps/sv/frontend/src/components/layout/NavCountBadge.tsx new file mode 100644 index 0000000000..9581cb8fb6 --- /dev/null +++ b/apps/sv/frontend/src/components/layout/NavCountBadge.tsx @@ -0,0 +1,46 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; + +import { Box } from '@mui/material'; + +import { layoutTokens } from '../../theme/tokens'; + +interface NavCountBadgeProps { + count: number; + id?: string; +} + +/** Figma nav notification badge — size-5 bg-red-400 rounded-3xl, text-xs Inter. */ +const NavCountBadge: React.FC = ({ count, id }) => { + if (count <= 0) { + return null; + } + + return ( + 9 ? 0.5 : 0, + borderRadius: '24px', + bgcolor: layoutTokens.notificationBadge, + color: 'common.white', + fontFamily: '"Inter", sans-serif', + fontSize: '0.75rem', + fontWeight: 400, + lineHeight: 1, + }} + > + {count} + + ); +}; + +export default NavCountBadge; diff --git a/apps/sv/frontend/src/components/layout/NetworkBanner.tsx b/apps/sv/frontend/src/components/layout/NetworkBanner.tsx new file mode 100644 index 0000000000..9564059df7 --- /dev/null +++ b/apps/sv/frontend/src/components/layout/NetworkBanner.tsx @@ -0,0 +1,34 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; + +import { Stack, Typography } from '@mui/material'; + +import { useNetworkInstanceName } from '../../hooks'; + +const NetworkBanner: React.FC = () => { + const networkInstanceName = useNetworkInstanceName(); + const knownColors = ['mainnet', 'testnet', 'devnet', 'scratchnet', 'localnet']; + const networkInstanceNameColor = knownColors.includes(networkInstanceName.toLowerCase()) + ? `colors.${networkInstanceName.toLowerCase()}` + : 'colors.neutral.30'; + return ( + + + You are on {networkInstanceName} + + + ); +}; + +export default NetworkBanner; diff --git a/apps/sv/frontend/src/components/layout/SvNavLink.tsx b/apps/sv/frontend/src/components/layout/SvNavLink.tsx new file mode 100644 index 0000000000..05ea8f83c4 --- /dev/null +++ b/apps/sv/frontend/src/components/layout/SvNavLink.tsx @@ -0,0 +1,82 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { NavLink, useLocation } from 'react-router'; + +import { Box } from '@mui/material'; + +import { layoutTokens, navItemTypography, NAV_PILL_PX } from '../../theme/tokens'; +import NavAttentionIcon from './NavAttentionIcon'; +import NavCountBadge from './NavCountBadge'; + +export interface SvNavLinkItem { + name: string; + path: string; + badgeCount?: number; + hasAlert?: boolean; + /** When false, nav stays active on nested paths (e.g. /governance/proposals). */ + end?: boolean; + /** Extra pathnames that should show this link as active (e.g. `/` for GSI). */ + alsoActiveFor?: string[]; +} + +interface SvNavLinkProps { + link: SvNavLinkItem; +} + +/** Figma: badge accessory uses gap-1.5 (6px), alert-icon accessory uses gap-2.5 (10px). */ +const navLinkSx = (isActive: boolean, accessoryGap: string) => ({ + display: 'inline-flex', + alignItems: 'center', + gap: accessoryGap, + p: NAV_PILL_PX, + borderRadius: '20px', + textDecoration: 'none', + whiteSpace: 'nowrap', + color: layoutTokens.lightText, + fontFamily: layoutTokens.fontUi, + fontSize: '0.875rem', + fontWeight: 700, + ...navItemTypography, + border: '2px solid transparent', + boxSizing: 'border-box', + ...(isActive && { borderColor: layoutTokens.navActiveOutline }), + '&:focus': { outline: 'none' }, + '&:focus-visible': { + outline: '2px solid', + outlineColor: layoutTokens.navActiveOutline, + outlineOffset: '2px', + }, +}); + +const SvNavLink: React.FC = ({ link }) => { + const location = useLocation(); + + return ( + + {({ isActive }) => { + const active = isActive || (link.alsoActiveFor?.includes(location.pathname) ?? false); + return ( + + {link.name} + {link.badgeCount !== undefined && link.badgeCount > 0 ? ( + + ) : null} + {link.hasAlert ? : null} + + ); + }} + + ); +}; + +export default SvNavLink; diff --git a/apps/sv/frontend/src/components/layout/SvNavigationShell.tsx b/apps/sv/frontend/src/components/layout/SvNavigationShell.tsx new file mode 100644 index 0000000000..d2a6c3974a --- /dev/null +++ b/apps/sv/frontend/src/components/layout/SvNavigationShell.tsx @@ -0,0 +1,42 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; + +import { Box } from '@mui/material'; + +import { HEADER_PB, HEADER_PT, layoutTokens, PAGE_PX } from '../../theme/tokens'; +import SvTopNav from './SvTopNav'; +import { SvNavLinkItem } from './SvNavLink'; + +interface SvNavigationShellProps { + navLinks: SvNavLinkItem[]; + onLogout: () => void; + pageName: string; +} + +/** + * Figma "Navigation" component — network banner above the nav row. + * Dev Mode: padding-bottom 64px, background #272727. + */ +const SvNavigationShell: React.FC = ({ navLinks, onLogout, pageName }) => { + return ( + + + + + + ); +}; + +export default SvNavigationShell; diff --git a/apps/sv/frontend/src/components/layout/SvTopNav.tsx b/apps/sv/frontend/src/components/layout/SvTopNav.tsx new file mode 100644 index 0000000000..e9d5e73a7e --- /dev/null +++ b/apps/sv/frontend/src/components/layout/SvTopNav.tsx @@ -0,0 +1,120 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; + +import { Box, Stack, Typography } from '@mui/material'; + +import { + BRAND_TITLE, + layoutTokens, + NAV_GAP, + NAV_PILL_PX, + NAV_ROW_MIN_HEIGHT, +} from '../../theme/tokens'; +import LogoutButton from './LogoutButton'; +import SvNavLink, { SvNavLinkItem } from './SvNavLink'; + +interface SvTopNavProps { + navLinks: SvNavLinkItem[]; + onLogout: () => void; +} + +/** + * Nav row: brand (left, intrinsic width) · flex spacer · nav cluster · + * flex spacer · logout (right, intrinsic width). Equal spacers center the pills + * in the gap between brand and logout — not in the full viewport. + */ +const SvTopNav: React.FC = ({ navLinks, onLogout }) => ( + + + + {BRAND_TITLE} + + + + + + + {navLinks.map(link => ( + + ))} + + + + + + + + +); + +export default SvTopNav; diff --git a/apps/sv/frontend/src/components/ui/Dropdown.tsx b/apps/sv/frontend/src/components/ui/Dropdown.tsx new file mode 100644 index 0000000000..4fb48c3521 --- /dev/null +++ b/apps/sv/frontend/src/components/ui/Dropdown.tsx @@ -0,0 +1,211 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + Box, + FormControl, + FormHelperText, + MenuItem, + Select, + SelectChangeEvent, + SxProps, + Theme, + Typography, +} from '@mui/material'; + +/** + * Source of truth: Figma Dev Mode node `3870:3442` ("Dropdown fields") and + * [#2652](https://github.com/canton-network/splice/issues/2652) reference. + * Prop surface mirrors `components.md` Input/Select: label, required, + * placeholder, helperText, hasDropdown (always true here), state. + */ + +export type DropdownState = 'default' | 'disabled' | 'error'; + +export interface DropdownOption { + value: string; + label: string; + /** Optional per-option test id (defaults to value). */ + testId?: string; +} + +export interface DropdownProps { + options: DropdownOption[]; + value: string; + onChange: (value: string) => void; + onBlur?: () => void; + label?: string; + required?: boolean; + placeholder?: string; + helperText?: string; + state?: DropdownState; + id?: string; + labelId?: string; + testId?: string; + disabled?: boolean; + error?: boolean; + fullWidth?: boolean; + renderValue?: (selected: string, options: DropdownOption[]) => React.ReactNode; + sx?: SxProps; +} + +/** Dev Mode: `background: var(--grey54, #363636)` */ +const FIELD_BG = 'var(--grey54, #363636)'; + +/** Figma "Body M" on nodes `3870:3442` / `1724:3506`: Inter 14px/400/22px. */ +const valueTextSx = { + fontFamily: "'Inter', sans-serif", + fontSize: '14px', + fontWeight: 400, + lineHeight: '22px', + color: '#E2E2E2', + fontFeatureSettings: "'liga' off, 'clig' off", +}; + +/** Empty-state placeholder node `I3870:3442;120:415`: Inter 14px, grey105. */ +const placeholderTextSx = { + ...valueTextSx, + color: '#696969', +}; + +/** Figma "FIELD H": Inter Semi Bold 12px uppercase, grey226. */ +const labelSx = { + fontFamily: "'Inter', sans-serif", + fontSize: '12px', + fontWeight: 600, + lineHeight: '22px', + textTransform: 'uppercase' as const, + color: '#E2E2E2', + mb: '8px', +}; + +const ChevronDownIcon: React.FC> = props => ( + + + +); + +const resolveLabelId = ( + labelId: string | undefined, + id: string | undefined, + label: string | undefined +): string | undefined => labelId ?? (label ? `${id ?? 'dropdown'}-label` : undefined); + +export const Dropdown: React.FC = ({ + options, + value, + onChange, + onBlur, + label, + required = false, + placeholder, + helperText, + state = 'default', + id, + labelId: labelIdProp, + testId, + disabled: disabledProp, + error: errorProp, + fullWidth = true, + renderValue, + sx, +}) => { + const isDisabled = disabledProp ?? state === 'disabled'; + const isError = errorProp ?? state === 'error'; + const resolvedId = id ?? testId ?? 'dropdown'; + const resolvedLabelId = resolveLabelId(labelIdProp, resolvedId, label); + const showLabel = Boolean(label); + + const defaultRenderValue = (selected: string) => { + if (!selected) { + return placeholder ? ( + + {placeholder} + + ) : null; + } + const option = options.find(o => o.value === selected); + return ( + + {option?.label ?? selected} + + ); + }; + + return ( + + {showLabel && ( + + {label} + {required && ( + + )} + + )} + + + + {helperText && ( + {helperText} + )} + + ); +}; diff --git a/apps/sv/frontend/src/components/votes/actions/GrantFeaturedAppRight.tsx b/apps/sv/frontend/src/components/votes/actions/GrantFeaturedAppRight.tsx index d0a65f0a15..6a8bdee801 100644 --- a/apps/sv/frontend/src/components/votes/actions/GrantFeaturedAppRight.tsx +++ b/apps/sv/frontend/src/components/votes/actions/GrantFeaturedAppRight.tsx @@ -8,12 +8,14 @@ import { FormControl, Stack, TextField, Typography } from '@mui/material'; import { ActionRequiringConfirmation } from '@daml.js/splice-dso-governance/lib/Splice/DsoRules/module'; import { useDsoInfos } from '../../../contexts/SvContext'; +import { activityWeightToOptional } from '../../../utils/governance'; const GrantFeaturedAppRight: React.FC<{ chooseAction: (action: ActionRequiringConfirmation) => void; }> = ({ chooseAction }) => { const dsoInfosQuery = useDsoInfos(); const [provider, setProvider] = useState(''); + const [activityWeight, setActivityWeight] = useState(''); if (dsoInfosQuery.isLoading) { return ; @@ -23,19 +25,28 @@ const GrantFeaturedAppRight: React.FC<{ return

Error: {JSON.stringify(dsoInfosQuery.error)}

; } - function setProviderAction(provider: string) { - setProvider(provider); + function chooseGrantAction(provider: string, activityWeight: string) { chooseAction({ tag: 'ARC_DsoRules', value: { dsoAction: { tag: 'SRARC_GrantFeaturedAppRight', - value: { provider: provider, activityWeight: null }, + value: { provider: provider, activityWeight: activityWeightToOptional(activityWeight) }, }, }, }); } + function setProviderAction(provider: string) { + setProvider(provider); + chooseGrantAction(provider, activityWeight); + } + + function setActivityWeightAction(activityWeight: string) { + setActivityWeight(activityWeight); + chooseGrantAction(provider, activityWeight); + } + return ( Provider @@ -46,6 +57,14 @@ const GrantFeaturedAppRight: React.FC<{ value={provider} /> + Activity Weight + + setActivityWeightAction(e.target.value)} + value={activityWeight} + /> + ); }; diff --git a/apps/sv/frontend/src/constants/createProposalLayout.ts b/apps/sv/frontend/src/constants/createProposalLayout.ts new file mode 100644 index 0000000000..fcce6a2169 --- /dev/null +++ b/apps/sv/frontend/src/constants/createProposalLayout.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Figma field column width inside the card. */ +export const CREATE_PROPOSAL_FIELD_MAX_WIDTH = 832; + +export const CREATE_PROPOSAL_CARD_BG = '#181818'; +export const CREATE_PROPOSAL_CARD_BORDER_RADIUS = '4px'; +export const CREATE_PROPOSAL_CARD_PADDING_Y = '60px'; + +/** Vertical gap between main form sections (Figma). */ +export const CREATE_PROPOSAL_SECTION_GAP = '32px'; + +/** Vertical gap between configuration rows (Figma Frame 535). */ +export const CREATE_PROPOSAL_CONFIG_ROW_GAP = '24px'; + +/** Gap from a configuration row to its divider (Figma Frame 533). */ +export const CREATE_PROPOSAL_CONFIG_ROW_DIVIDER_GAP = '14px'; + +/** Configuration value input width (Figma Frame 531). */ +export const CREATE_PROPOSAL_CONFIG_INPUT_WIDTH = '238px'; + +/** Figma Blue (Primary CTA) — enabled Review/Submit Proposal. */ +export const CREATE_PROPOSAL_PRIMARY_CTA = '#96E4FD'; + +/** Figma Yellow (Secondary CTA) — Cancel outline and JSON toggle. */ +export const CREATE_PROPOSAL_SECONDARY_CTA = '#F3FF97'; + +/** Figma disabled primary CTA surface (stone-500). */ +export const CREATE_PROPOSAL_DISABLED_CTA_BG = '#78716C'; + +/** Figma disabled primary CTA label (neutral 25%). */ +export const CREATE_PROPOSAL_DISABLED_CTA_TEXT = '#404040'; + +/** Figma coral (Warning Button / destructive) — Discard & Exit outline, error icon. */ +export const CREATE_PROPOSAL_DISCARD_CTA = '#FD8575'; + +/** Figma FIELD H — 12px Inter semibold uppercase field labels. */ +export const CREATE_PROPOSAL_FIELD_LABEL_SX = { + fontFamily: "'Inter', sans-serif", + fontSize: '12px', + fontWeight: 600, + lineHeight: '22px', + letterSpacing: 0, + textTransform: 'uppercase' as const, + color: '#E2E2E2', +}; + +/** Figma Body M — 14px field values and radio option labels. */ +export const CREATE_PROPOSAL_FIELD_BODY_SX = { + fontFamily: "'Inter', sans-serif", + fontSize: '14px', + fontWeight: 400, + lineHeight: '22px', + letterSpacing: 0, + color: '#E2E2E2', +}; + +/** Figma Body S — 12px helper / subtitle text. */ +export const CREATE_PROPOSAL_FIELD_HELPER_SX = { + fontFamily: "'Inter', sans-serif", + fontSize: '12px', + fontWeight: 400, + lineHeight: '22px', + letterSpacing: 0, + color: '#E2E2E2', +}; diff --git a/apps/sv/frontend/src/constants/formButtonStyles.ts b/apps/sv/frontend/src/constants/formButtonStyles.ts new file mode 100644 index 0000000000..d0a8c16a9e --- /dev/null +++ b/apps/sv/frontend/src/constants/formButtonStyles.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + CREATE_PROPOSAL_DISABLED_CTA_BG, + CREATE_PROPOSAL_DISABLED_CTA_TEXT, + CREATE_PROPOSAL_DISCARD_CTA, + CREATE_PROPOSAL_PRIMARY_CTA, + CREATE_PROPOSAL_SECONDARY_CTA, +} from './createProposalLayout'; + +const pillButtonBaseSx = { + height: '39px', + px: '16px', + py: '10px', + borderRadius: '20px', + textTransform: 'none' as const, + fontSize: '16px', + fontWeight: 500, + fontFamily: "'Inter', sans-serif", + lineHeight: 'normal', + boxShadow: 'none', + minWidth: 'unset', +}; + +/** Figma Warning/Secondary button — transparent fill, yellow outline, white label. */ +export const createProposalCancelButtonSx = { + ...pillButtonBaseSx, + bgcolor: 'transparent', + border: `1px solid ${CREATE_PROPOSAL_SECONDARY_CTA}`, + color: '#FFFFFF', + '&:hover': { + bgcolor: 'transparent', + border: `1px solid ${CREATE_PROPOSAL_SECONDARY_CTA}`, + color: CREATE_PROPOSAL_SECONDARY_CTA, + boxShadow: 'none', + }, +}; + +/** Figma Warning Button (destructive) — transparent fill, coral outline, white label. */ +export const createProposalDiscardButtonSx = { + ...pillButtonBaseSx, + bgcolor: 'transparent', + border: `1px solid ${CREATE_PROPOSAL_DISCARD_CTA}`, + color: '#FFFFFF', + '&:hover': { + bgcolor: 'transparent', + border: `1px solid ${CREATE_PROPOSAL_DISCARD_CTA}`, + color: CREATE_PROPOSAL_DISCARD_CTA, + boxShadow: 'none', + }, +}; + +/** Figma Primary button — cyan fill, black label; stone disabled state. */ +export const createProposalSubmitButtonSx = { + ...pillButtonBaseSx, + bgcolor: CREATE_PROPOSAL_PRIMARY_CTA, + color: '#000000', + border: 'none', + '&:hover': { + bgcolor: CREATE_PROPOSAL_PRIMARY_CTA, + color: '#000000', + boxShadow: 'none', + }, + '&:disabled': { + bgcolor: CREATE_PROPOSAL_DISABLED_CTA_BG, + color: CREATE_PROPOSAL_DISABLED_CTA_TEXT, + border: 'none', + }, +}; diff --git a/apps/sv/frontend/src/contexts/SvAdminServiceContext.tsx b/apps/sv/frontend/src/contexts/SvAdminServiceContext.tsx index 06934b8e27..6b4e1b8de8 100644 --- a/apps/sv/frontend/src/contexts/SvAdminServiceContext.tsx +++ b/apps/sv/frontend/src/contexts/SvAdminServiceContext.tsx @@ -10,6 +10,7 @@ import BigNumber from 'bignumber.js'; import React, { useContext, useMemo } from 'react'; import { CastVoteRequest, + CountVoteResultsResponse, createConfiguration, CreateVoteRequest, GetPartyToParticipantResponseV1, @@ -63,6 +64,10 @@ export interface SvAdminClient { accepted?: boolean, pageToken?: number ) => Promise; + countVoteRequestResults: ( + accepted?: boolean, + effectiveTo?: string + ) => Promise; getPreviousSvRewardWeight: ( svParty: string, effectiveBefore?: string @@ -163,6 +168,12 @@ export const SvAdminClientProvider: React.FC => { + return await svAdminClient.countVoteRequestResults({ accepted, effectiveTo }); + }, getPreviousSvRewardWeight: async ( svParty: string, effectiveBefore?: string diff --git a/apps/sv/frontend/src/hooks/index.ts b/apps/sv/frontend/src/hooks/index.ts index 44494fdcfd..086047c7dc 100644 --- a/apps/sv/frontend/src/hooks/index.ts +++ b/apps/sv/frontend/src/hooks/index.ts @@ -3,7 +3,7 @@ import { useAmuletPriceVotes } from './useAmuletPriceVotes'; import { useCometBftDebug } from './useCometBftDebug'; import { useInfiniteVoteRequestResults } from './useInfiniteVoteRequestResults'; -import { useListVoteRequestResult, useListDsoRulesVoteRequests } from './useListVoteRequests'; +import { useListDsoRulesVoteRequests, useListVoteRequestResult } from './useListVoteRequests'; import { useListVotes } from './useListVotes'; import { useMediatorStatus } from './useMediatorStatus'; import { useNetworkInstanceName } from './useNetworkInstanceName'; @@ -12,6 +12,7 @@ import { useSequencerStatus } from './useSequencerStatus'; import { useValidatorLicenses } from './useValidatorLicenses'; import { useValidatorOnboardings } from './useValidatorOnboardings'; import { useVoteRequest } from './useVoteRequest'; +import { useVoteRequestResultsCount } from './useVoteRequestResultsCount'; export { useAmuletPriceVotes, @@ -27,4 +28,5 @@ export { useValidatorLicenses, useValidatorOnboardings, useVoteRequest, + useVoteRequestResultsCount, }; diff --git a/apps/sv/frontend/src/hooks/useFeaturedAppRightPicker.ts b/apps/sv/frontend/src/hooks/useFeaturedAppRightPicker.ts new file mode 100644 index 0000000000..f562bcb9b1 --- /dev/null +++ b/apps/sv/frontend/src/hooks/useFeaturedAppRightPicker.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useState } from 'react'; +import { Option } from '../components/form-components/SelectField'; +import { + validatePartyId, + validateRevokeFeaturedAppRight, +} from '../components/forms/formValidators'; +import { useSvAdminClient } from '../contexts/SvAdminServiceContext'; +import { DEFAULT_APP_ACTIVITY_WEIGHT } from '../utils/constants'; + +interface FeatureAppRightPicker { + rightOptions: Option[]; + currentWeights: Record; + providerSearched: boolean; + loadFeaturedAppRightsAndValidate: (value: string) => Promise; + validateRightSelection: (value: string) => string | false; + resetOptions: () => void; +} + +export const useFeaturedAppRightPicker = ( + svAdminClient: ReturnType +): FeatureAppRightPicker => { + const [rightOptions, setRightOptions] = useState([]); + const [providerSearched, setProviderSearched] = useState(false); + const [currentWeights, setCurrentWeights] = useState>({}); + + const loadFeaturedAppRightsAndValidate = async (value: string) => { + if (validatePartyId(value)) return undefined; + + try { + const response = await svAdminClient.listFeaturedAppRightsByProvider(value); + const options = response.featured_app_rights.map((contract: { contract_id: string }) => ({ + key: contract.contract_id, + value: contract.contract_id, + })); + const weights = Object.fromEntries( + response.featured_app_rights.map(c => { + const aw = (c.payload as { activityWeight?: string | null }).activityWeight; + return [c.contract_id, aw ?? DEFAULT_APP_ACTIVITY_WEIGHT]; + }) + ); + setRightOptions(options); + setCurrentWeights(weights); + setProviderSearched(true); + return undefined; + } catch { + setRightOptions([]); + setCurrentWeights({}); + setProviderSearched(false); + return 'Could not load Featured Application Contract IDs for this provider'; + } + }; + + const validateRightSelection = (value: string): string | false => { + const requiredError = validateRevokeFeaturedAppRight(value); + if (requiredError) return requiredError; + + return rightOptions.some(option => option.value === value) + ? false + : 'Select a valid contract id'; + }; + + const resetOptions = () => { + setRightOptions([]); + setCurrentWeights({}); + setProviderSearched(false); + }; + + return { + rightOptions, + currentWeights, + providerSearched, + loadFeaturedAppRightsAndValidate, + validateRightSelection, + resetOptions, + }; +}; diff --git a/apps/sv/frontend/src/hooks/useHorizontalScrollMetrics.ts b/apps/sv/frontend/src/hooks/useHorizontalScrollMetrics.ts new file mode 100644 index 0000000000..7d285d3bb6 --- /dev/null +++ b/apps/sv/frontend/src/hooks/useHorizontalScrollMetrics.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { RefObject, useLayoutEffect, useState } from 'react'; + +export interface ScrollMetrics { + canScroll: boolean; + thumbWidthPercent: number; + thumbLeftPercent: number; +} + +const emptyMetrics: ScrollMetrics = { + canScroll: false, + thumbWidthPercent: 100, + thumbLeftPercent: 0, +}; + +export const computeScrollMetrics = (el: HTMLElement): ScrollMetrics => { + const { scrollLeft, scrollWidth, clientWidth } = el; + if (scrollWidth <= clientWidth + 1) { + return emptyMetrics; + } + + const thumbWidthPercent = Math.max((clientWidth / scrollWidth) * 100, 8); + const maxLeft = 100 - thumbWidthPercent; + const scrollableDistance = scrollWidth - clientWidth; + const thumbLeftPercent = scrollableDistance > 0 ? (scrollLeft / scrollableDistance) * maxLeft : 0; + + return { + canScroll: true, + thumbWidthPercent, + thumbLeftPercent, + }; +}; + +export const useHorizontalScrollMetrics = ( + scrollRef: RefObject, + deps: unknown[] = [] +): ScrollMetrics => { + const [metrics, setMetrics] = useState(emptyMetrics); + + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el) return; + + const update = () => setMetrics(computeScrollMetrics(el)); + + update(); + const observer = new ResizeObserver(update); + observer.observe(el); + el.addEventListener('scroll', update, { passive: true }); + + return () => { + observer.disconnect(); + el.removeEventListener('scroll', update); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + return metrics; +}; diff --git a/apps/sv/frontend/src/hooks/useListVoteRequests.tsx b/apps/sv/frontend/src/hooks/useListVoteRequests.tsx index bf48c8d821..b3b38f3a44 100644 --- a/apps/sv/frontend/src/hooks/useListVoteRequests.tsx +++ b/apps/sv/frontend/src/hooks/useListVoteRequests.tsx @@ -6,11 +6,16 @@ import { DsoRules_CloseVoteRequestResult, VoteRequest, } from '@daml.js/splice-dso-governance/lib/Splice/DsoRules/module'; -import { Contract } from '@canton-network/splice-common-frontend-utils'; -import { type UseQueryResult, useQuery } from '@tanstack/react-query'; +import { Contract, PollingStrategy } from '@canton-network/splice-common-frontend-utils'; +import { type UseQueryResult, useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { useEffect, useRef } from 'react'; import { useSvAdminClient } from '../contexts/SvAdminServiceContext'; import { useConfigPollInterval } from '../utils'; +import { shouldContinueVoteHistorySearch } from '../utils/proposalSearch'; + +const PAGINATED_VOTE_RESULTS_QUERY_KEY = 'paginatedVoteRequestResults'; +const PAGINATED_VOTE_RESULTS_PAGE_SIZE = 500; export type ListVoteRequestResultParams = { actionName?: string; @@ -67,3 +72,136 @@ export const useListVoteRequestResult = ( retry, }); }; + +function usePaginatedVoteRequestResultsBucket( + contractId: string, + accepted: boolean, + enabled: boolean, + shouldContinueRef: React.MutableRefObject<() => boolean> +) { + const { listVoteRequestResults } = useSvAdminClient(); + const queryKey = [ + PAGINATED_VOTE_RESULTS_QUERY_KEY, + contractId, + accepted, + PAGINATED_VOTE_RESULTS_PAGE_SIZE, + ] as const; + + const { + hasNextPage, + isFetchingNextPage, + isPending, + dataUpdatedAt, + fetchNextPage, + data, + isSuccess, + } = useInfiniteQuery({ + queryKey, + queryFn: async ({ pageParam }) => { + const response = await listVoteRequestResults( + PAGINATED_VOTE_RESULTS_PAGE_SIZE, + undefined, + undefined, + undefined, + undefined, + accepted, + pageParam ?? undefined + ); + + return { + results: List(DsoRules_CloseVoteRequestResult).decoder.runWithException( + response.dso_rules_vote_results + ), + nextPageToken: response.next_page_token, + }; + }, + initialPageParam: null as number | null, + getNextPageParam: lastPage => lastPage?.nextPageToken ?? null, + enabled, + refetchInterval: PollingStrategy.NONE, + refetchOnWindowFocus: false, + }); + + useEffect(() => { + if ( + !enabled || + !shouldContinueRef.current() || + !hasNextPage || + isFetchingNextPage || + isPending + ) { + return; + } + void fetchNextPage(); + }, [ + enabled, + hasNextPage, + isFetchingNextPage, + isPending, + dataUpdatedAt, + fetchNextPage, + shouldContinueRef, + ]); + + const results = data?.pages.flatMap(page => page.results) ?? []; + + return { + results, + isNaturallyComplete: isSuccess && !hasNextPage, + hasFirstPage: (data?.pages.length ?? 0) > 0, + }; +} + +export function usePaginatedVoteRequestResultsByContractId( + enabled: boolean, + { contractId = '' }: { contractId?: string } = {} +): { + results: DsoRules_CloseVoteRequestResult[]; + isComplete: boolean; +} { + const shouldContinueRef = useRef<() => boolean>(() => false); + const fetchDisabledRef = useRef(false); + const previousLookupRef = useRef({ enabled, contractId }); + + if ( + previousLookupRef.current.enabled !== enabled || + previousLookupRef.current.contractId !== contractId + ) { + fetchDisabledRef.current = false; + previousLookupRef.current = { enabled, contractId }; + } + + const bucketEnabled = enabled && !fetchDisabledRef.current; + + const accepted = usePaginatedVoteRequestResultsBucket( + contractId, + true, + bucketEnabled, + shouldContinueRef + ); + const rejected = usePaginatedVoteRequestResultsBucket( + contractId, + false, + bucketEnabled, + shouldContinueRef + ); + const results = enabled ? [...accepted.results, ...rejected.results] : []; + + const shouldContinue = + bucketEnabled && + shouldContinueVoteHistorySearch(contractId, results, result => result.request.trackingCid); + shouldContinueRef.current = () => shouldContinue; + + const isComplete = + (accepted.isNaturallyComplete && rejected.isNaturallyComplete) || + (!shouldContinue && accepted.hasFirstPage && rejected.hasFirstPage); + + if (isComplete) { + fetchDisabledRef.current = true; + } + + return { + results, + isComplete, + }; +} diff --git a/apps/sv/frontend/src/hooks/useNetworkInstanceName.ts b/apps/sv/frontend/src/hooks/useNetworkInstanceName.ts index 8a8294b71a..06dd7c795d 100644 --- a/apps/sv/frontend/src/hooks/useNetworkInstanceName.ts +++ b/apps/sv/frontend/src/hooks/useNetworkInstanceName.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { useDsoInfos } from '../contexts/SvContext'; -export const useNetworkInstanceName: () => string | undefined = () => { +export const useNetworkInstanceName: () => string = () => { const dsoInfosQuery = useDsoInfos(); const scanUrls = dsoInfosQuery.data?.nodeStates.flatMap(nsContract => { @@ -13,11 +13,15 @@ export const useNetworkInstanceName: () => string | undefined = () => { }) as string[]; if (scanUrls === undefined) { - return undefined; + return 'Unknown Network'; } const instances = scanUrls .map(url => { + if (/\/\/localhost(?::\d+)?(?:\/|$)/.test(url)) { + return 'local'; + } + const regex = /(?<=\/\/(?:scan\.)sv-\d+\.)([a-zA-Z0-9-]+)/; return url.match(regex)?.[1]; @@ -28,22 +32,21 @@ export const useNetworkInstanceName: () => string | undefined = () => { return getNetworkName(instances[0]); } - return undefined; + return 'Unknown Network'; }; const getNetworkName = (network: string) => { - let networkName; - // NOTE: mainnet does not have the network/cluster name in the url. if (network === 'global') { - networkName = 'MainNet'; + return 'MainNet'; } else if (network === 'test') { - networkName = 'TestNet'; + return 'TestNet'; } else if (network === 'dev') { - networkName = 'DevNet'; - } else if (network?.startsWith('scratch')) { - networkName = 'ScratchNet'; + return 'DevNet'; + } else if (network === 'local') { + return 'LocalNet'; + } else if (network.startsWith('scratch')) { + return 'ScratchNet'; } - - return networkName; + return network.charAt(0).toUpperCase() + network.slice(1); }; diff --git a/apps/sv/frontend/src/hooks/useVoteRequest.tsx b/apps/sv/frontend/src/hooks/useVoteRequest.tsx index b8ccc90762..e5358a00bf 100644 --- a/apps/sv/frontend/src/hooks/useVoteRequest.tsx +++ b/apps/sv/frontend/src/hooks/useVoteRequest.tsx @@ -1,18 +1,22 @@ // Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { Contract } from '@canton-network/splice-common-frontend-utils'; +import { Contract, PollingStrategy } from '@canton-network/splice-common-frontend-utils'; import { useQuery, UseQueryResult } from '@tanstack/react-query'; import { VoteRequest } from '@daml.js/splice-dso-governance/lib/Splice/DsoRules/module'; import { ContractId } from '@daml/types'; import { useSvAdminClient } from '../contexts/SvAdminServiceContext'; +import { useConfigPollInterval } from '../utils'; export const useVoteRequest = ( contractId: ContractId, - retry: boolean = true + retry: boolean = true, + poll: boolean = true ): UseQueryResult> => { const { lookupDsoRulesVoteRequest } = useSvAdminClient(); + const pollInterval = useConfigPollInterval(); + return useQuery({ queryKey: ['listDsoRulesVoteRequests', contractId], queryFn: async () => { @@ -20,5 +24,6 @@ export const useVoteRequest = ( return Contract.decodeOpenAPI(request.dso_rules_vote_request, VoteRequest); }, retry, + refetchInterval: poll ? pollInterval : PollingStrategy.NONE, }); }; diff --git a/apps/sv/frontend/src/hooks/useVoteRequestResultByCid.tsx b/apps/sv/frontend/src/hooks/useVoteRequestResultByCid.tsx index 48bffd44e5..98b9a6706c 100644 --- a/apps/sv/frontend/src/hooks/useVoteRequestResultByCid.tsx +++ b/apps/sv/frontend/src/hooks/useVoteRequestResultByCid.tsx @@ -2,15 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 import { ContractId } from '@daml/types'; -import { useVoteRequest } from './useVoteRequest'; import { DsoRules_CloseVoteRequestResult, VoteRequest, } from '@daml.js/splice-dso-governance/lib/Splice/DsoRules'; -import { useVotesHooks } from '@canton-network/splice-common-frontend'; import { Contract } from '@canton-network/splice-common-frontend-utils'; -const QUERY_LIMIT = 500; +import { usePaginatedVoteRequestResultsByContractId } from './useListVoteRequests'; +import { useVoteRequest } from './useVoteRequest'; +import { findByContractId } from '../utils/proposalSearch'; interface UseVoteRequestResultByCidResult { voteRequest: Contract | undefined; @@ -25,54 +25,37 @@ interface UseVoteRequestResultByCidResult { export function useVoteRequestResultByCid( contractId: ContractId ): UseVoteRequestResultByCidResult { - const votesHooks = useVotesHooks(); - const voteRequestQuery = useVoteRequest(contractId, false); + const voteRequestQuery = useVoteRequest(contractId, false, false); - const voteResultsWithAcceptedQuery = (accepted: boolean) => - votesHooks.useListVoteRequestResult( - QUERY_LIMIT, - undefined, - undefined, - undefined, - undefined, - accepted, - false - ); - const acceptedResultsQuery = voteResultsWithAcceptedQuery(true); - const notAcceptedResultsQuery = voteResultsWithAcceptedQuery(false); + const hasVoteRequest = voteRequestQuery.isSuccess && voteRequestQuery.data != null; - const acceptedResult = acceptedResultsQuery.data?.find( - vr => vr.request.trackingCid === contractId - ); - const notAcceptedResult = notAcceptedResultsQuery.data?.find( - vr => vr.request.trackingCid === contractId - ); + const needsClosedVoteFetch = + (voteRequestQuery.isSuccess || voteRequestQuery.isError) && !hasVoteRequest; - const hasVoteRequest = - voteRequestQuery.isSuccess && - voteRequestQuery.data != null && - voteRequestQuery.data != undefined; + const closedVoteResults = usePaginatedVoteRequestResultsByContractId(needsClosedVoteFetch, { + contractId, + }); - const hasVoteResult = - (acceptedResultsQuery.isSuccess && acceptedResult != undefined) || - (notAcceptedResultsQuery.isSuccess && notAcceptedResult != undefined); + const voteResult = findByContractId( + closedVoteResults.results, + contractId, + result => result.request.trackingCid + ); + const hasVoteResult = voteResult !== undefined; const isPending = voteRequestQuery.isPending || - acceptedResultsQuery.isPending || - notAcceptedResultsQuery.isPending; + (needsClosedVoteFetch && !closedVoteResults.isComplete && !hasVoteResult); const isComplete = (voteRequestQuery.isSuccess || voteRequestQuery.isError) && - (acceptedResultsQuery.isSuccess || acceptedResultsQuery.isError) && - (notAcceptedResultsQuery.isSuccess || notAcceptedResultsQuery.isError); + (!needsClosedVoteFetch || closedVoteResults.isComplete || hasVoteResult); const voteRequest = voteRequestQuery.data; - const voteResult = acceptedResult || notAcceptedResult; return { - voteRequest: voteRequest, - voteResult: voteResult, + voteRequest, + voteResult, hasVoteRequest, hasVoteResult, isPending, diff --git a/apps/sv/frontend/src/hooks/useVoteRequestResultsCount.ts b/apps/sv/frontend/src/hooks/useVoteRequestResultsCount.ts new file mode 100644 index 0000000000..ca27a768fe --- /dev/null +++ b/apps/sv/frontend/src/hooks/useVoteRequestResultsCount.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useQuery, UseQueryResult } from '@tanstack/react-query'; + +import { useSvAdminClient } from '../contexts/SvAdminServiceContext'; + +export const useVoteRequestResultsCount = (): UseQueryResult => { + const { countVoteRequestResults } = useSvAdminClient(); + return useQuery({ + queryKey: ['voteRequestResultsCount'], + queryFn: async () => { + const [effective, notAccepted] = await Promise.all([ + countVoteRequestResults(true, new Date().toISOString()), + countVoteRequestResults(false), + ]); + return effective.count + notAccepted.count; + }, + }); +}; diff --git a/apps/sv/frontend/src/routes/createProposal.tsx b/apps/sv/frontend/src/routes/createProposal.tsx index b8a6c4a9c0..0bf5d364e5 100644 --- a/apps/sv/frontend/src/routes/createProposal.tsx +++ b/apps/sv/frontend/src/routes/createProposal.tsx @@ -9,11 +9,12 @@ import { OffboardSvForm } from '../components/forms/OffboardSvForm'; import { SelectAction } from '../components/forms/SelectAction'; import { SetAmuletConfigRulesForm } from '../components/forms/SetAmuletConfigRulesForm'; import { SetDsoConfigRulesForm } from '../components/forms/SetDsoConfigRulesForm'; +import { UpdateFeaturedAppForm } from '../components/forms/UpdateFeaturedAppForm'; import { UpdateSvRewardWeightForm } from '../components/forms/UpdateSvRewardWeightForm'; +import { InitiateProposalLayout } from '../components/governance/InitiateProposalLayout'; import { useDsoInfos } from '../contexts/SvContext'; import { createProposalActions } from '../utils/governance'; import type { SupportedActionTag } from '../utils/types'; -import { Box } from '@mui/material'; const ProposalForm: React.FC<{ action: SupportedActionTag }> = ({ action }) => { const dsoInfosQuery = useDsoInfos(); @@ -35,6 +36,8 @@ const ProposalForm: React.FC<{ action: SupportedActionTag }> = ({ action }) => { return ; case 'CRARC_SetConfig': return ; + case 'SRARC_UpdateFeaturedAppRight': + return ; } }; @@ -44,12 +47,12 @@ export const CreateProposal: React.FC = () => { const selectedAction = createProposalActions.find(a => a.value === action); return ( - + {selectedAction ? ( ) : ( )} - + ); }; diff --git a/apps/sv/frontend/src/routes/delegateElection.tsx b/apps/sv/frontend/src/routes/delegateElection.tsx new file mode 100644 index 0000000000..5b1e584b2a --- /dev/null +++ b/apps/sv/frontend/src/routes/delegateElection.tsx @@ -0,0 +1,14 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; + +import { Typography } from '@mui/material'; + +/** Placeholder for #2594 layout nav — page content is a separate issue. */ +const DelegateElection: React.FC = () => ( + + Delegate Election + +); + +export default DelegateElection; diff --git a/apps/sv/frontend/src/routes/governance.tsx b/apps/sv/frontend/src/routes/governance.tsx index 0d4086d2d9..08ead0e5ee 100644 --- a/apps/sv/frontend/src/routes/governance.tsx +++ b/apps/sv/frontend/src/routes/governance.tsx @@ -2,52 +2,49 @@ // SPDX-License-Identifier: Apache-2.0 import * as React from 'react'; import { Box, Button, Stack, Typography } from '@mui/material'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { ActionRequiredSection, ActionRequiredData, } from '../components/governance/ActionRequiredSection'; +import { ProposalListingSection } from '../components/governance/ProposalListingSection'; +import ProposalSearch from '../components/governance/ProposalSearch'; import { Loading, useVotesHooks } from '@canton-network/splice-common-frontend'; import { dateTimeFormatISO } from '@canton-network/splice-common-frontend-utils'; import dayjs from 'dayjs'; import { ContractId } from '@daml/types'; -import { - ActionRequiringConfirmation, - VoteRequest, -} from '@daml.js/splice-dso-governance/lib/Splice/DsoRules'; +import { VoteRequest } from '@daml.js/splice-dso-governance/lib/Splice/DsoRules'; import { useSvConfig } from '../utils'; import { PageHeader } from '../components/beta'; -import { ProposalListingSection } from '../components/governance/ProposalListingSection'; import { actionTagToTitle, + buildVoteHistoryData, computeVoteStats, computeYourVote, - getVoteResultStatus, + getGovernanceActionTag, + getRequesterPartyId, } from '../utils/governance'; +import { filterByContractId, isValidContractId } from '../utils/proposalSearch'; import { SupportedActionTag, ProposalListingData } from '../utils/types'; -import { Link as RouterLink } from 'react-router'; +import { Link as RouterLink, useSearchParams } from 'react-router'; import { InfoOutlined, WarningAmberOutlined } from '@mui/icons-material'; -import { useInfiniteVoteRequestResults } from '../hooks'; - -function getAction(action: ActionRequiringConfirmation): string { - switch (action.tag) { - case 'ARC_AmuletRules': - return action.value.amuletRulesAction.tag; - case 'ARC_DsoRules': - return action.value.dsoAction.tag; - default: - return 'Action tag not defined.'; - } -} +import { useInfiniteVoteRequestResults, useVoteRequestResultsCount } from '../hooks'; +import { usePaginatedVoteRequestResultsByContractId } from '../hooks/useListVoteRequests'; export const Governance: React.FC = () => { const svConfig = useSvConfig(); const amuletName = svConfig.spliceInstanceNames.amuletName; + const [searchParams] = useSearchParams(); + const initialSearchQuery = searchParams.get('q') ?? ''; + const [searchQuery, setSearchQuery] = useState(() => + isValidContractId(initialSearchQuery) ? initialSearchQuery.trim() : '' + ); const votesHooks = useVotesHooks(); const dsoInfosQuery = votesHooks.useDsoInfos(); const listVoteRequestsQuery = votesHooks.useListDsoRulesVoteRequests(); const voteResultsInfiniteQuery = useInfiniteVoteRequestResults(); + const voteResultsCountQuery = useVoteRequestResultsCount(); const voteRequestIds = listVoteRequestsQuery.data ? listVoteRequestsQuery.data.map(v => v.payload.trackingCid || v.contractId) @@ -56,6 +53,7 @@ export const Governance: React.FC = () => { const svPartyId = dsoInfosQuery.data?.svPartyId; const votingThreshold = dsoInfosQuery.data?.votingThreshold; + const svs = dsoInfosQuery.data?.dsoRules.payload.svs; const alreadyVotedRequestIds: Set> = useMemo(() => { return svPartyId && votesQuery.data ? new Set(votesQuery.data.filter(v => v.voter === svPartyId).map(v => v.requestCid)) @@ -67,42 +65,136 @@ export const Governance: React.FC = () => { if (!pages || !svPartyId || votingThreshold === undefined) return []; const allVoteResults = pages.flatMap(page => page.results); + return buildVoteHistoryData(allVoteResults, amuletName, svPartyId, votingThreshold, svs); + }, [voteResultsInfiniteQuery.data?.pages, amuletName, svPartyId, votingThreshold, svs]); + + const voteRequests = listVoteRequestsQuery.data; - return allVoteResults - .filter( - vr => - (vr.outcome.tag === 'VRO_Accepted' && - dayjs(vr.outcome.value.effectiveAt).isBefore(dayjs())) || - vr.outcome.tag === 'VRO_Expired' || - vr.outcome.tag === 'VRO_Rejected' - ) - .map(vr => { - const votes = vr.request.votes.entriesArray().map(e => e[1]); + const actionRequiredBase = useMemo(() => { + if (!voteRequests) { + return []; + } + + return voteRequests + .filter(v => !alreadyVotedRequestIds.has(v.payload.trackingCid || v.contractId)) + .map(vr => ({ + contractId: vr.payload.trackingCid || vr.contractId, + actionName: + actionTagToTitle(amuletName)[ + getGovernanceActionTag(vr.payload.action) as SupportedActionTag + ], + description: vr.payload.reason.body, + votingCloses: dayjs(vr.payload.voteBefore).format(dateTimeFormatISO), + createdAt: dayjs(vr.createdAt).format(dateTimeFormatISO), + requester: getRequesterPartyId(vr.payload.requester, svs), + })) as ActionRequiredData[]; + }, [voteRequests, alreadyVotedRequestIds, amuletName, svs]); + + const inflightBase = useMemo(() => { + if (!voteRequests || votingThreshold === undefined) { + return []; + } + + return voteRequests + .filter(v => alreadyVotedRequestIds.has(v.payload.trackingCid || v.contractId)) + .map(v => { + const effectiveAt = v.payload.targetEffectiveAt + ? dayjs(v.payload.targetEffectiveAt).format(dateTimeFormatISO) + : 'Threshold'; + const votes = v.payload.votes.entriesArray().map(e => e[1]); return { - contractId: vr.request.trackingCid, + contractId: v.payload.trackingCid || v.contractId, actionName: - actionTagToTitle(amuletName)[getAction(vr.request.action) as SupportedActionTag], - description: vr.request.reason.body, - votingThresholdDeadline: dayjs(vr.request.voteBefore).format(dateTimeFormatISO), - voteTakesEffect: - (vr.outcome.tag === 'VRO_Accepted' && - dayjs(vr.outcome.value.effectiveAt).format(dateTimeFormatISO)) || - dayjs(vr.completedAt).format(dateTimeFormatISO), + actionTagToTitle(amuletName)[ + getGovernanceActionTag(v.payload.action) as SupportedActionTag + ], + description: v.payload.reason.body, + votingThresholdDeadline: dayjs(v.payload.voteBefore).format(dateTimeFormatISO), + voteTakesEffect: effectiveAt, yourVote: computeYourVote(votes, svPartyId), - status: getVoteResultStatus(vr.outcome), + status: 'In Progress', voteStats: computeVoteStats(votes), acceptanceThreshold: votingThreshold, + requester: getRequesterPartyId(v.payload.requester, svs), } as ProposalListingData; }); - }, [voteResultsInfiniteQuery.data?.pages, amuletName, svPartyId, votingThreshold]); + }, [voteRequests, votingThreshold, alreadyVotedRequestIds, amuletName, svPartyId, svs]); - if ( + const isLoading = dsoInfosQuery.isPending || listVoteRequestsQuery.isPending || votesQuery.isPending || - voteResultsInfiniteQuery.isPending - ) { + voteResultsInfiniteQuery.isPending; + + const hasSearch = isValidContractId(searchQuery); + + const actionRequiredRequests = useMemo( + () => (hasSearch ? filterByContractId(actionRequiredBase, searchQuery) : actionRequiredBase), + [hasSearch, actionRequiredBase, searchQuery] + ); + + const inflightRequests = useMemo( + () => (hasSearch ? filterByContractId(inflightBase, searchQuery) : inflightBase), + [hasSearch, inflightBase, searchQuery] + ); + + const loadedVoteHistoryMatches = useMemo( + () => (hasSearch ? filterByContractId(voteHistory, searchQuery) : []), + [hasSearch, voteHistory, searchQuery] + ); + + const needsClosedVoteFetch = + !isLoading && + hasSearch && + actionRequiredRequests.length === 0 && + inflightRequests.length === 0 && + loadedVoteHistoryMatches.length === 0; + + const searchVoteResults = usePaginatedVoteRequestResultsByContractId(needsClosedVoteFetch, { + contractId: searchQuery, + }); + + const searchVoteHistoryBase = useMemo(() => { + if (!svPartyId || votingThreshold === undefined) { + return []; + } + + return buildVoteHistoryData( + searchVoteResults.results, + amuletName, + svPartyId, + votingThreshold, + svs + ); + }, [searchVoteResults.results, amuletName, svPartyId, votingThreshold, svs]); + + const filteredVoteHistory = useMemo(() => { + if (!hasSearch) { + return voteHistory; + } + + if (loadedVoteHistoryMatches.length > 0) { + return loadedVoteHistoryMatches; + } + + return filterByContractId(searchVoteHistoryBase, searchQuery); + }, [hasSearch, voteHistory, searchQuery, loadedVoteHistoryMatches, searchVoteHistoryBase]); + + const showVoteHistorySectionLoading = + needsClosedVoteFetch && !searchVoteResults.isComplete && filteredVoteHistory.length === 0; + + const hasLoadedAllVoteHistoryPages = !voteResultsInfiniteQuery.hasNextPage; + + const showEmptyState = + !isLoading && + !hasSearch && + actionRequiredRequests.length === 0 && + inflightRequests.length === 0 && + filteredVoteHistory.length === 0 && + hasLoadedAllVoteHistoryPages; + + if (isLoading) { return ; } @@ -115,45 +207,6 @@ export const Governance: React.FC = () => { return ; } - const voteRequests = listVoteRequestsQuery.data; - - const actionRequiredRequests = voteRequests - .filter(v => !alreadyVotedRequestIds.has(v.payload.trackingCid || v.contractId)) - .map(vr => { - return { - contractId: vr.payload.trackingCid || vr.contractId, - actionName: - actionTagToTitle(amuletName)[getAction(vr.payload.action) as SupportedActionTag], - description: vr.payload.reason.body, - votingCloses: dayjs(vr.payload.voteBefore).format(dateTimeFormatISO), - createdAt: dayjs(vr.createdAt).format(dateTimeFormatISO), - requester: vr.payload.requester, - isYou: vr.payload.requester === svPartyId, - } as ActionRequiredData; - }); - - const inflightRequests = voteRequests - .filter(v => alreadyVotedRequestIds.has(v.payload.trackingCid || v.contractId)) - .map(v => { - const effectiveAt = v.payload.targetEffectiveAt - ? dayjs(v.payload.targetEffectiveAt).format(dateTimeFormatISO) - : 'Threshold'; - - const votes = v.payload.votes.entriesArray().map(e => e[1]); - - return { - contractId: v.payload.trackingCid || v.contractId, - actionName: actionTagToTitle(amuletName)[getAction(v.payload.action) as SupportedActionTag], - description: v.payload.reason.body, - votingThresholdDeadline: dayjs(v.payload.voteBefore).format(dateTimeFormatISO), - voteTakesEffect: effectiveAt, - yourVote: computeYourVote(votes, svPartyId), - status: 'In Progress', - voteStats: computeVoteStats(votes), - acceptanceThreshold: dsoInfosQuery.data.votingThreshold, - } as ProposalListingData; - }); - return ( { data-testid="governance-page-header" /> - {actionRequiredRequests.length === 0 && - inflightRequests.length === 0 && - voteHistory.length === 0 && - !voteResultsInfiniteQuery.hasNextPage ? ( + + + {showEmptyState ? ( ) : ( <> - + { p.results.length > 0).length + hasSearch + ? undefined + : voteResultsInfiniteQuery.data?.pages.filter(p => p.results.length > 0).length } /> diff --git a/apps/sv/frontend/src/routes/voteRequestDetails.tsx b/apps/sv/frontend/src/routes/voteRequestDetails.tsx index 41ad05195f..5474a7d8c6 100644 --- a/apps/sv/frontend/src/routes/voteRequestDetails.tsx +++ b/apps/sv/frontend/src/routes/voteRequestDetails.tsx @@ -19,6 +19,7 @@ import { buildProposal, formatBasisPoints, getActionValue, + getRequesterPartyId, getVoteResultStatus, } from '../utils/governance'; import { useDsoInfos } from '../contexts/SvContext'; @@ -60,7 +61,7 @@ export const VoteRequestDetails: React.FC = () => { currentEffectiveAt ); - if (dsoInfosQuery.isPending && isPending) { + if (dsoInfosQuery.isPending || isPending) { return ; } @@ -73,7 +74,8 @@ export const VoteRequestDetails: React.FC = () => { } const svPartyId = dsoInfosQuery.data?.svPartyId || ''; - const allSvs = dsoInfosQuery.data?.dsoRules.payload.svs.entriesArray().map(e => e[0]) || []; + const svs = dsoInfosQuery.data?.dsoRules.payload.svs; + const allSvs = svs?.entriesArray().map(e => e[0]) || []; const amuletOrDsoAction = getActionValue(request.action); // check that amuletOrDsoAction is a supported action @@ -111,13 +113,24 @@ export const VoteRequestDetails: React.FC = () => { previousRewardWeight !== undefined ? formatBasisPoints(previousRewardWeight) : ''; } + // For closed votes the outcome carries the actual effective time. Old vote + // requests (created before targetEffectiveAt existed) decode with + // targetEffectiveAt = None, so the request alone can't tell "effective at + // threshold" apart from "effective at expiry". + const voteTakesEffect = hasVoteRequest + ? request.targetEffectiveAt + ? dayjs(request.targetEffectiveAt).format(dateTimeFormatISO) + : 'Threshold' + : voteResult?.outcome.tag === 'VRO_Accepted' + ? dayjs(voteResult.outcome.value.effectiveAt).format(dateTimeFormatISO) + : dayjs(voteResult?.completedAt).format(dateTimeFormatISO); + + const requesterPartyId = getRequesterPartyId(request.requester, svs); const votingInformation: ProposalVotingInformation = { - requester: request.requester, - requesterIsYou: request.requester === svPartyId, + requester: requesterPartyId, + requesterIsYou: requesterPartyId === svPartyId, votingThresholdDeadline: dayjs(request.voteBefore).format(dateTimeFormatISO), - voteTakesEffect: request.targetEffectiveAt - ? dayjs(request.targetEffectiveAt).format(dateTimeFormatISO) - : 'Threshold', + voteTakesEffect, status: hasVoteRequest ? 'In Progress' : getVoteResultStatus(voteResult?.outcome), }; diff --git a/apps/sv/frontend/src/theme/tokens.ts b/apps/sv/frontend/src/theme/tokens.ts new file mode 100644 index 0000000000..63245136a8 --- /dev/null +++ b/apps/sv/frontend/src/theme/tokens.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Figma tokens from CF-design-system (tokens.md + delegate-election-2 Dev Mode). */ +export const layoutTokens = { + /** Figma surface-page — bg-neutral-800 */ + page: '#262626', + /** Figma Dev Mode Background-lighter on Navigation component */ + navBackground: '#272727', + /** Figma Dev Mode --Light-text — brand wordmark and nav labels */ + lightText: '#E2E2E2', + /** Figma red00 — Governance count badge */ + notificationBadge: '#FD8575', + /** Figma Purple (Navigation) — active nav pill border */ + navActiveOutline: '#875CFF', + navAttention: '#F3FF97', + /** Brand wordmark — Inter until a production-licensed Termina is available */ + fontBrand: '"Inter", sans-serif', + fontUi: '"Inter", sans-serif', +} as const; + +/** Figma Dev Mode — nav row horizontal padding 50px */ +export const PAGE_PX = '50px'; + +/** Figma p-2.5 — 10px padding on brand box and nav pills */ +export const NAV_PILL_PX = '10px'; + +/** Figma content max width (nav row is full width; content uses this) */ +export const CONTENT_MAX_WIDTH = 1583; + +/** Figma Dev Mode — 64px space below nav row, present on every page */ +export const HEADER_PB = 8; + +export const HEADER_PT = 3; + +/** Figma Dev Mode — fixed 60px between nav pills (not responsive). */ +export const NAV_GAP = '60px'; + +/** Figma Dev Mode — nav row height 44px */ +export const NAV_ROW_MIN_HEIGHT = 44; + +/** + * Figma Dev Mode — Inter nav/logout typography (letter spacing: 0px, 140% line-height). + * Without an explicit `letterSpacing` reset, these Box/Typography elements inherit + * MUI's default body1 letter-spacing (0.00938em) from an ancestor, rendering as a + * ~0.13-0.15px leak that's invisible in a screenshot but measurable via computed + * styles and doesn't match Figma's 0px spec. `lineHeight: 'normal'` also falls back + * to Inter's own metrics rather than the explicit 140% Figma spec, so it's pinned here. + */ +export const navItemTypography = { + fontFeatureSettings: "'liga' off, 'clig' off", + lineHeight: 1.4, + letterSpacing: 0, +} as const; + +export const BRAND_TITLE = 'Supervalidator Operations'; diff --git a/apps/sv/frontend/src/themes/fieldStyles.ts b/apps/sv/frontend/src/themes/fieldStyles.ts new file mode 100644 index 0000000000..dde77a7564 --- /dev/null +++ b/apps/sv/frontend/src/themes/fieldStyles.ts @@ -0,0 +1,329 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SxProps, Theme } from '@mui/material/styles'; + +const fieldSurfaceBackground = '#363636'; + +export const fieldSectionTitleSx: SxProps = { + fontSize: 12, + lineHeight: '22px', + fontWeight: 600, + textTransform: 'uppercase', + color: 'common.white', +}; + +export const fieldDescriptionSx: SxProps = { + fontSize: 12, + lineHeight: '22px', + color: 'text.light', +}; + +export const fieldSectionSx: SxProps = { + display: 'flex', + flexDirection: 'column', + gap: 1, +}; + +const fieldHelperSx: SxProps = { + '& .MuiFormHelperText-root': { + mx: 0, + mt: 1, + }, +}; + +const inputTypographySx = (theme: Theme) => ({ + fontSize: theme.typography.body2.fontSize, + fontWeight: theme.typography.body2.fontWeight, + fontFamily: theme.typography.body2.fontFamily, + lineHeight: '22px', + letterSpacing: 0, + color: theme.palette.text.light, + backgroundColor: 'transparent', + boxSizing: 'border-box' as const, + WebkitBoxShadow: 'none', +}); + +const fieldOutlineSx = (theme: Theme) => ({ + '& .MuiOutlinedInput-notchedOutline, & fieldset': { + border: 'none', + borderRadius: '4px', + }, + '&.Mui-error .MuiOutlinedInput-notchedOutline, &.Mui-error fieldset': { + border: `1px solid ${theme.palette.error.main}`, + }, +}); + +const fieldAdornmentSx = { + '& .MuiInputAdornment-root': { + margin: 0, + maxHeight: '22px', + height: '22px', + alignSelf: 'center', + }, + '& .MuiInputAdornment-positionEnd': { + marginLeft: 'auto', + }, + '& .MuiInputAdornment-root .MuiIconButton-root': { + color: 'common.white', + padding: 0, + width: 16, + height: 16, + '& svg': { + fontSize: 16, + }, + }, + '& .MuiSelect-icon': { + color: 'common.white', + fontSize: 16, + top: 'calc(50% - 0.5em)', + }, +}; + +const multilineSurfaceSx = (theme: Theme) => ({ + display: 'flex', + padding: '13px 16px', + justifyContent: 'space-between', + alignItems: 'flex-start', + alignContent: 'flex-start', + flexWrap: 'wrap', + rowGap: '10px', + alignSelf: 'stretch', + backgroundColor: fieldSurfaceBackground, + borderRadius: '4px', + overflow: 'hidden', + ...fieldOutlineSx(theme), +}); + +const multilineInputSx = (theme: Theme) => ({ + ...inputTypographySx(theme), + flex: 1, + width: '100%', + minWidth: '100%', + padding: 0, +}); + +export const singleLineInputRootSx: SxProps = theme => ({ + ...inputTypographySx(theme), + display: 'flex', + padding: '13px 16px', + justifyContent: 'space-between', + alignItems: 'center', + alignContent: 'center', + flexWrap: 'wrap', + rowGap: '10px', + alignSelf: 'stretch', + minHeight: 0, + height: 'auto', + backgroundColor: fieldSurfaceBackground, + borderRadius: '4px', + overflow: 'hidden', + ...fieldOutlineSx(theme), + ...fieldAdornmentSx, + '&.MuiInputBase-root, &.MuiOutlinedInput-root, &.MuiInputBase-adornedEnd': { + minHeight: 0, + height: 'auto', + }, + // Override common MuiInputBase global `.MuiOutlinedInput-input` background (neutral[10]). + '& .MuiOutlinedInput-input, & .MuiInputBase-input, & input, & .MuiSelect-select': { + ...inputTypographySx(theme), + flex: 1, + minWidth: 0, + padding: 0, + minHeight: 0, + height: 'auto', + }, + '&.MuiInputBase-sizeSmall': { + minHeight: 0, + }, + '& .MuiOutlinedInput-inputSizeSmall': { + padding: 0, + }, +}); + +const proposalSummaryInputRootSx: SxProps = theme => ({ + ...multilineSurfaceSx(theme), + height: '130px', + '& textarea, & .MuiOutlinedInput-input': { + ...multilineInputSx(theme), + alignSelf: 'stretch', + minHeight: 0, + height: '100%', + resize: 'none', + overflow: 'auto', + }, +}); + +const configFieldInputRootSx: SxProps = theme => ({ + ...inputTypographySx(theme), + display: 'flex', + width: '238px', + maxWidth: '100%', + height: '48px', + padding: '14px 24px', + justifyContent: 'flex-end', + alignItems: 'center', + gap: '10px', + boxSizing: 'border-box', + minHeight: 0, + backgroundColor: fieldSurfaceBackground, + borderRadius: '4px', + overflow: 'hidden', + ...fieldOutlineSx(theme), + // Edited state: ConfigField sets focused when value differs from default (Figma: #F3FF97 border). + '&.Mui-focused .MuiOutlinedInput-notchedOutline, &.Mui-focused fieldset': { + border: `1px solid ${theme.palette.secondary.main}`, + }, + '&.MuiInputBase-root, &.MuiOutlinedInput-root': { + minHeight: 0, + height: '48px', + }, + '& .MuiOutlinedInput-input, & .MuiInputBase-input, & input': { + ...inputTypographySx(theme), + flex: 1, + minWidth: 0, + padding: 0, + minHeight: 0, + height: 'auto', + textAlign: 'right', + }, +}); + +/** Single-line TextField wrapper — overrides common MuiInputBase global styles. */ +export const singleLineFieldSx: SxProps = theme => ({ + ...fieldHelperSx, + '& .MuiOutlinedInput-root': + typeof singleLineInputRootSx === 'function' + ? singleLineInputRootSx(theme) + : singleLineInputRootSx, + // Figma empty-field prompt: Body M grey105 (same as Proposal Summary / Select Action). + '& .MuiOutlinedInput-input::placeholder': { + color: '#696969', + opacity: 1, + }, +}); + +/** Proposal summary — fixed 130px height. */ +export const proposalSummaryFieldSx: SxProps = theme => ({ + ...fieldHelperSx, + '& .MuiOutlinedInput-root': + typeof proposalSummaryInputRootSx === 'function' + ? proposalSummaryInputRootSx(theme) + : proposalSummaryInputRootSx, + // Figma empty-field prompt: Body M grey105 (same as Select Action placeholder). + '& .MuiOutlinedInput-input::placeholder': { + color: '#696969', + opacity: 1, + }, +}); + +/** Date picker OutlinedInput root — single row; 16px inset for chevron via root padding. */ +const datePickerInputRootSx: SxProps = theme => ({ + ...inputTypographySx(theme), + display: 'flex', + flexWrap: 'nowrap', + padding: '13px 16px', + justifyContent: 'space-between', + alignItems: 'center', + alignSelf: 'stretch', + width: '100%', + minHeight: 0, + height: 'auto', + backgroundColor: fieldSurfaceBackground, + borderRadius: '4px', + overflow: 'hidden', + ...fieldOutlineSx(theme), + ...fieldAdornmentSx, + '&.MuiInputBase-root, &.MuiOutlinedInput-root, &.MuiInputBase-adornedEnd': { + minHeight: 0, + height: 'auto', + }, + '& .MuiOutlinedInput-input, & .MuiInputBase-input, & input': { + ...inputTypographySx(theme), + flex: 1, + minWidth: 0, + padding: 0, + minHeight: 0, + height: 'auto', + }, + '& .MuiInputAdornment-positionEnd': { + marginLeft: 'auto', + marginRight: 0, + flexShrink: 0, + }, +}); + +/** Date picker TextField wrapper (helper text spacing + OutlinedInput surface). */ +export const datePickerFieldSx: SxProps = theme => ({ + ...fieldHelperSx, + width: '100%', + '& .MuiOutlinedInput-root': + typeof datePickerInputRootSx === 'function' + ? datePickerInputRootSx(theme) + : datePickerInputRootSx, +}); + +/** Date picker OutlinedInput root — apply via `slotProps.input.sx` when needed. */ +export const datePickerInputSx: SxProps = datePickerInputRootSx; + +/** Select OutlinedInput root — single row; chevron inset matches date picker. */ +const selectInputRootSx: SxProps = theme => ({ + ...inputTypographySx(theme), + display: 'flex', + flexWrap: 'nowrap', + padding: '13px 16px', + justifyContent: 'space-between', + alignItems: 'center', + alignContent: 'center', + alignSelf: 'stretch', + minHeight: 0, + height: 'auto', + backgroundColor: fieldSurfaceBackground, + borderRadius: '4px', + overflow: 'hidden', + ...fieldOutlineSx(theme), + ...fieldAdornmentSx, + '&.MuiInputBase-root, &.MuiOutlinedInput-root, &.MuiInputBase-adornedEnd': { + minHeight: 0, + height: 'auto', + }, + '& .MuiOutlinedInput-input, & .MuiInputBase-input, & input, & .MuiSelect-select': { + ...inputTypographySx(theme), + flex: 1, + minWidth: 0, + padding: 0, + minHeight: 0, + height: 'auto', + }, + '&.MuiSelect-outlined .MuiSelect-select': { + paddingRight: 0, + }, + '& .MuiSelect-icon': { + color: 'common.white', + fontSize: 16, + marginLeft: 'auto', + marginRight: 0, + flexShrink: 0, + position: 'static', + top: 'auto', + right: 'auto', + }, + '&.MuiInputBase-sizeSmall': { + minHeight: 0, + }, + '& .MuiOutlinedInput-inputSizeSmall': { + padding: 0, + }, +}); + +export const selectFieldSx: SxProps = selectInputRootSx; + +/** DSO config table TextField wrapper (helper text spacing). */ +export const configFieldFieldSx: SxProps = { + ...fieldHelperSx, + width: '238px', + maxWidth: '100%', +}; + +/** DSO config table OutlinedInput root — apply via `slotProps.input.sx`. */ +export const configFieldInputSx: SxProps = configFieldInputRootSx; diff --git a/apps/sv/frontend/src/utils/buildAmuletConfigChanges.ts b/apps/sv/frontend/src/utils/buildAmuletConfigChanges.ts index 5eb83314ea..c7b3994d66 100644 --- a/apps/sv/frontend/src/utils/buildAmuletConfigChanges.ts +++ b/apps/sv/frontend/src/utils/buildAmuletConfigChanges.ts @@ -5,6 +5,7 @@ import { AmuletConfig, PackageConfig, RewardConfig, + RewardVersion, } from '@daml.js/splice-amulet/lib/Splice/AmuletConfig'; import { Tuple2 } from '@daml.js/daml-prim-DA-Types-1.0.0/lib/DA/Types'; import { Set as DamlSet } from '@daml.js/daml-stdlib-DA-Set-Types-1.0.0/lib/DA/Set/Types'; @@ -320,6 +321,16 @@ function buildIssuanceCurveChanges( return [...initialValues, ...futureValues]; } +const rewardVersionLabels = { + RewardVersion_FeaturedAppMarkers: 'Featured App Markers (pre CIP-104)', + RewardVersion_TrafficBasedAppRewards: 'Traffic-Based App Rewards (CIP-104)', +} satisfies Record; + +const rewardVersionOptions = RewardVersion.keys.map(value => ({ + value, + label: rewardVersionLabels[value], +})); + function buildRewardConfigChanges( before: RewardConfig | null | undefined, after: RewardConfig | null | undefined @@ -327,33 +338,42 @@ function buildRewardConfigChanges( return [ { fieldName: 'rewardConfigMintingVersion', - label: 'Reward config: Minting version', + label: 'Reward config: Reward scheme', currentValue: before?.mintingVersion || '', newValue: after?.mintingVersion || '', + options: rewardVersionOptions, + description: 'Which reward scheme to use in production.', }, { fieldName: 'rewardConfigDryRunVersion', - label: 'Reward config: Dry-run version', + label: 'Reward config: Dry-run reward scheme', currentValue: before?.dryRunVersion || '', newValue: after?.dryRunVersion || '', + options: [{ value: '', label: 'None (disabled)' }, ...rewardVersionOptions], + description: + 'Which reward scheme to run in dry-run mode. Select "None (disabled)" to turn it off.', }, { fieldName: 'rewardConfigBatchSize', - label: 'Reward config: Batch size', + label: 'Reward config: Merkle tree batch size', currentValue: before?.batchSize || '', newValue: after?.batchSize || '', + description: 'Batch size for building the Merkle tree over minting allowances (default: 100)', }, { fieldName: 'rewardConfigRewardCouponTimeToLive', label: 'Reward config: Reward coupon time to live (microseconds)', currentValue: before?.rewardCouponTimeToLive.microseconds || '', newValue: after?.rewardCouponTimeToLive.microseconds || '', + description: 'Time-to-live for RewardCouponV2 contracts (default: 36 hours)', }, { fieldName: 'rewardConfigAppRewardCouponThreshold', label: 'Reward config: App reward coupon threshold ($)', currentValue: before?.appRewardCouponThreshold || '', newValue: after?.appRewardCouponThreshold || '', + description: + 'Minimum reward amount in USD below which no RewardCouponV2 is created (default: $0.50)', }, ] as ConfigChange[]; } diff --git a/apps/sv/frontend/src/utils/buildDsoConfigChanges.ts b/apps/sv/frontend/src/utils/buildDsoConfigChanges.ts index e1c9893e41..78bbd3c988 100644 --- a/apps/sv/frontend/src/utils/buildDsoConfigChanges.ts +++ b/apps/sv/frontend/src/utils/buildDsoConfigChanges.ts @@ -79,18 +79,18 @@ export function buildDsoConfigChanges( currentValue: before?.numMemberTrafficContractsThreshold || '', newValue: after?.numMemberTrafficContractsThreshold || '', }, - { - fieldName: 'actionConfirmationTimeout', - label: 'Time-To-Live for contracts representing a confirmation of an action', - currentValue: before?.actionConfirmationTimeout.microseconds || '', - newValue: after?.actionConfirmationTimeout.microseconds || '', - }, { fieldName: 'svOnboardingRequestTimeout', label: 'Time-To-Live for contracts representing an incomplete Super Validator onboarding', currentValue: before?.svOnboardingRequestTimeout.microseconds || '', newValue: after?.svOnboardingRequestTimeout.microseconds || '', }, + { + fieldName: 'actionConfirmationTimeout', + label: 'Time-To-Live for contracts representing a confirmation of an action', + currentValue: before?.actionConfirmationTimeout.microseconds || '', + newValue: after?.actionConfirmationTimeout.microseconds || '', + }, { fieldName: 'svOnboardingConfirmedTimeout', label: @@ -98,12 +98,6 @@ export function buildDsoConfigChanges( currentValue: before?.svOnboardingConfirmedTimeout.microseconds || '', newValue: after?.svOnboardingConfirmedTimeout.microseconds || '', }, - { - fieldName: 'maxTextLength', - label: 'Generic upper limit on text fields', - currentValue: before?.maxTextLength || '', - newValue: after?.maxTextLength || '', - }, { fieldName: 'voteRequestTimeout', label: 'Time-To-Live for contracts representing vote requests and votes', @@ -146,6 +140,12 @@ export function buildDsoConfigChanges( currentValue: before?.synchronizerNodeConfigLimits.cometBft.maxPubKeyLength || '', newValue: after?.synchronizerNodeConfigLimits.cometBft.maxPubKeyLength || '', }, + { + fieldName: 'maxTextLength', + label: 'Generic upper limit on text fields', + currentValue: before?.maxTextLength || '', + newValue: after?.maxTextLength || '', + }, ...buildSynchronizerMap(before?.decentralizedSynchronizer, after?.decentralizedSynchronizer), diff --git a/apps/sv/frontend/src/utils/constants.ts b/apps/sv/frontend/src/utils/constants.ts index c0b2907dca..2e96c45db7 100644 --- a/apps/sv/frontend/src/utils/constants.ts +++ b/apps/sv/frontend/src/utils/constants.ts @@ -2,7 +2,42 @@ // SPDX-License-Identifier: Apache-2.0 export const PROPOSAL_SUMMARY_TITLE = 'Proposal Summary'; +export const PROPOSAL_REVIEW_TITLE = 'Proposal Review'; export const PROPOSAL_SUMMARY_SUBTITLE = 'For CIP votes, consider copying the CIP abstract here'; +export const DATE_TIME_PLACEHOLDER = 'YYYY-MM-DD HH:MM'; +export const REASON_PLACEHOLDER = 'Add your reasoning here'; +export const URL_PLACEHOLDER = 'https://example.com'; +/** Figma initiate Proposal Summary empty prompt (Body M grey105 when empty). */ +export const PROPOSAL_SUMMARY_PLACEHOLDER = REASON_PLACEHOLDER; export const DEFAULT_PROPOSAL_SUMMARY_MAX_LENGTH = 1024; export const THRESHOLD_DEADLINE_SUBTITLE = 'Proposal remains open only if ⅔ of nodes place a vote before this date-time'; +export const DEFAULT_APP_ACTIVITY_WEIGHT = '1.0'; + +export const SUPPORTING_URL_LABEL = 'Supporting URL'; +export const SUPPORTING_URL_PLACEHOLDER = URL_PLACEHOLDER; +export const VOTE_REASON_URL_LABEL = 'Vote Reason URL'; +export const VOTE_REASON_SUMMARY_LABEL = 'Reason'; +/** Your Vote Reason empty prompt — aligned with #6910. */ +export const VOTE_REASON_PLACEHOLDER = REASON_PLACEHOLDER; +/** Your Vote URL empty prompt — aligned with #6910. */ +export const VOTE_REASON_URL_PLACEHOLDER = URL_PLACEHOLDER; +export const VOTE_PROPOSAL_CONTRACT_ID_LABEL = 'Vote proposal contract id'; +export const THRESHOLD_DEADLINE_LABEL = 'Quorum Threshold Deadline'; +export const EFFECTIVE_AT_LABEL = 'Effective At'; +export const PROPOSAL_CREATED_LABEL = 'Proposal Created'; + +/** Figma field labels for the initiate-proposal edit step (12px uppercase). */ +export const CREATE_PROPOSAL_LABEL_PROPOSAL_TYPE = 'ACTION'; // Figma FIELD H “Action” (edit + review) +export const CREATE_PROPOSAL_LABEL_THRESHOLD_DEADLINE = 'QUORUM THRESHOLD DEADLINE'; +export const CREATE_PROPOSAL_LABEL_EFFECTIVE_AT = 'EFFECTIVE AT'; +export const CREATE_PROPOSAL_LABEL_PROPOSAL_SUMMARY = 'PROPOSAL SUMMARY'; +export const CREATE_PROPOSAL_LABEL_SUPPORTING_URL = 'SUPPORTING URL'; +export const CREATE_PROPOSAL_LABEL_CONFIGURATION = 'CONFIGURATION'; +export const CREATE_PROPOSAL_LABEL_MEMBER = 'MEMBER'; +export const CREATE_PROPOSAL_LABEL_WEIGHT = 'WEIGHT'; +export const CREATE_PROPOSAL_LABEL_PROVIDER_PARTY_ID = 'PROVIDER PARTY ID'; +export const CREATE_PROPOSAL_LABEL_FEATURED_APP_CONTRACT_ID = 'FEATURED APPLICATION CONTRACT ID'; +export const CREATE_PROPOSAL_LABEL_BENEFICIARY = 'BENEFICIARY'; +export const CREATE_PROPOSAL_LABEL_AMOUNT = 'AMOUNT'; +export const CREATE_PROPOSAL_LABEL_MUST_MINT_BEFORE = 'MUST MINT BEFORE'; diff --git a/apps/sv/frontend/src/utils/governance.ts b/apps/sv/frontend/src/utils/governance.ts index 0fdf4bd4aa..92d423a5c0 100644 --- a/apps/sv/frontend/src/utils/governance.ts +++ b/apps/sv/frontend/src/utils/governance.ts @@ -5,6 +5,7 @@ import type { ActionRequiringConfirmation, AmuletRules_ActionRequiringConfirmation, DsoRules_ActionRequiringConfirmation, + DsoRules_CloseVoteRequestResult, DsoRules_SetConfig, DsoRulesConfig, SvInfo, @@ -25,9 +26,11 @@ import type { PendingConfigFieldInfo, Proposal, ProposalListingStatus, + ProposalListingData, SupportedActionTag, UnclaimedActivityRecordProposal, UnfeatureAppProposal, + UpdateFeatureAppProposal, UpdateSvRewardWeightProposal, YourVoteStatus, } from '../utils/types'; @@ -46,6 +49,7 @@ export const actionTagToTitle = (amuletName: string): Record info.name === requester); + return match?.[0] ?? requester; +} + export function computeYourVote(votes: Vote[], svPartyId: string | undefined): YourVoteStatus { if (svPartyId === undefined) { return 'no-vote'; @@ -110,6 +129,56 @@ export function computeYourVote(votes: Vote[], svPartyId: string | undefined): Y return vote ? (vote.accept ? 'accepted' : 'rejected') : 'no-vote'; } +export function getGovernanceActionTag(action: ActionRequiringConfirmation): string { + switch (action.tag) { + case 'ARC_AmuletRules': + return action.value.amuletRulesAction.tag; + case 'ARC_DsoRules': + return action.value.dsoAction.tag; + default: + return 'Action tag not defined.'; + } +} + +export function buildVoteHistoryData( + voteResults: DsoRules_CloseVoteRequestResult[], + amuletName: string, + svPartyId: string | undefined, + votingThreshold: bigint, + svs: { entriesArray(): [string, SvInfo][] } | undefined +): ProposalListingData[] { + return voteResults + .filter( + vr => + (vr.outcome.tag === 'VRO_Accepted' && + dayjs(vr.outcome.value.effectiveAt).isBefore(dayjs())) || + vr.outcome.tag === 'VRO_Expired' || + vr.outcome.tag === 'VRO_Rejected' + ) + .map(vr => { + const votes = vr.request.votes.entriesArray().map(e => e[1]); + + return { + contractId: vr.request.trackingCid, + actionName: + actionTagToTitle(amuletName)[ + getGovernanceActionTag(vr.request.action) as SupportedActionTag + ], + description: vr.request.reason.body, + votingThresholdDeadline: dayjs(vr.request.voteBefore).format(dateTimeFormatISO), + voteTakesEffect: + (vr.outcome.tag === 'VRO_Accepted' && + dayjs(vr.outcome.value.effectiveAt).format(dateTimeFormatISO)) || + dayjs(vr.completedAt).format(dateTimeFormatISO), + yourVote: computeYourVote(votes, svPartyId), + status: getVoteResultStatus(vr.outcome), + voteStats: computeVoteStats(votes), + acceptanceThreshold: votingThreshold, + requester: getRequesterPartyId(vr.request.requester, svs), + } as ProposalListingData; + }); +} + export function buildProposal(action: ActionRequiringConfirmation, dsoInfo?: DsoInfo): Proposal { if (action.tag === 'ARC_DsoRules') { const dsoAction = action.value.dsoAction; @@ -134,9 +203,17 @@ export function buildProposal(action: ActionRequiringConfirmation, dsoInfo?: Dso dsoAction.value.expiresAt ); case 'SRARC_GrantFeaturedAppRight': - return createGrantFeatureAppProposal(dsoAction.value.provider); + return createGrantFeatureAppProposal( + dsoAction.value.provider, + dsoAction.value.activityWeight ?? '' + ); case 'SRARC_RevokeFeaturedAppRight': return createRevokeFeatureAppProposal(dsoAction.value.rightCid); + case 'SRARC_UpdateFeaturedAppRight': + return createUpdateFeatureAppProposal( + dsoAction.value.rightCid, + dsoAction.value.update.newActivityWeight + ); case 'SRARC_SetConfig': return createDsoRulesConfigProposal(dsoAction.value.baseConfig, dsoAction.value.newConfig); } @@ -156,12 +233,23 @@ function createOffboardMemberProposal(memberToOffboard: string): OffBoardMemberP return { memberToOffboard }; } -function createGrantFeatureAppProposal(provider: string): FeatureAppProposal { +function createGrantFeatureAppProposal( + provider: string, + activityWeight: string +): FeatureAppProposal { return { provider: provider, + activityWeight: activityWeight, }; } +function createUpdateFeatureAppProposal( + rightContractId: string, + newActivityWeight: string +): UpdateFeatureAppProposal { + return { rightContractId, newActivityWeight }; +} + function createRevokeFeatureAppProposal(rightContractId: string): UnfeatureAppProposal { return { rightContractId: rightContractId, @@ -274,6 +362,10 @@ export function getSvRewardWeight(svs: [string, SvInfo][], svPartyId: string): s return svInfo ? svInfo[1].svRewardWeight : ''; } +export function activityWeightToOptional(weight: string): string | null { + return weight.trim() === '' ? null : weight; +} + export function buildPendingConfigFields( proposals: Contract[] | undefined ): PendingConfigFieldInfo[] { diff --git a/apps/sv/frontend/src/utils/proposalSearch.ts b/apps/sv/frontend/src/utils/proposalSearch.ts new file mode 100644 index 0000000000..65dc86c70b --- /dev/null +++ b/apps/sv/frontend/src/utils/proposalSearch.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** MSW mock CIDs use this length with non-ledger prefixes (`10…`, `99…`). */ +const MOCK_TEST_CONTRACT_ID_LENGTH = 138; + +export const CONTRACT_ID_VALIDATION_MESSAGE = 'Enter a valid contract ID.'; + +/** Bounds mirror LfValue.ContractId.fromString (Value.scala) + even hex for Bytes.fromString. */ +const V1_MIN_LENGTH = 66; +const V1_MAX_LENGTH = 254; +const V2_MIN_LENGTH = 26; +const V2_MAX_LENGTH = 92; + +function isHexChar(code: number): boolean { + return (code >= 48 && code <= 57) || (code >= 97 && code <= 102) || (code >= 65 && code <= 70); +} + +function isPlausibleContractId(value: string): boolean { + const len = value.length; + if (len === 0 || (len & 1) !== 0 || len < V2_MIN_LENGTH || len > V1_MAX_LENGTH) { + return false; + } + + for (let i = 0; i < len; i++) { + if (!isHexChar(value.charCodeAt(i))) { + return false; + } + } + + if (value.charCodeAt(0) === 48 && value.charCodeAt(1) === 48) { + return len >= V1_MIN_LENGTH && len <= V1_MAX_LENGTH; + } + if (value.charCodeAt(0) === 48 && value.charCodeAt(1) === 49) { + return len >= V2_MIN_LENGTH && len <= V2_MAX_LENGTH; + } + return len === MOCK_TEST_CONTRACT_ID_LENGTH; +} + +export function isValidContractId(value: string): boolean { + return isPlausibleContractId(value.trim()); +} + +function normalizeContractIdQuery(query: string): string | null { + const trimmed = query.trim(); + if (!trimmed || !isPlausibleContractId(trimmed)) { + return null; + } + return trimmed.toLowerCase(); +} + +function matchesNormalizedContractId( + normalizedQuery: string, + contractId: string | null | undefined +): boolean { + return typeof contractId === 'string' && contractId.toLowerCase() === normalizedQuery; +} + +export function filterByContractId( + items: T[], + query: string | null | undefined +): T[] { + const normalizedQuery = query == null ? null : normalizeContractIdQuery(query); + if (normalizedQuery === null) { + return []; + } + return items.filter(item => + matchesNormalizedContractId(normalizedQuery, item.contractId as string) + ); +} + +export function findByContractId( + items: T[], + query: string, + getContractId: (item: T) => string | null | undefined +): T | undefined { + const normalizedQuery = normalizeContractIdQuery(query); + if (normalizedQuery === null) { + return undefined; + } + return items.find(item => matchesNormalizedContractId(normalizedQuery, getContractId(item))); +} + +export function shouldContinueVoteHistorySearch( + query: string, + results: T[], + getContractId: (item: T) => string | null | undefined +): boolean { + const normalizedQuery = normalizeContractIdQuery(query); + if (normalizedQuery === null) { + return false; + } + return !results.some(item => matchesNormalizedContractId(normalizedQuery, getContractId(item))); +} diff --git a/apps/sv/frontend/src/utils/types.ts b/apps/sv/frontend/src/utils/types.ts index b54b5c6797..b30b3cc598 100644 --- a/apps/sv/frontend/src/utils/types.ts +++ b/apps/sv/frontend/src/utils/types.ts @@ -22,12 +22,18 @@ export interface OffBoardMemberProposal { export interface FeatureAppProposal { provider: string; + activityWeight: string; } export interface UnfeatureAppProposal { rightContractId: string; } +export interface UpdateFeatureAppProposal { + rightContractId: string; + newActivityWeight: string; +} + export interface UnclaimedActivityRecordProposal { beneficiary: string; amount: string; @@ -57,6 +63,14 @@ export interface ConfigChange { * If the field should be disabled for editing. */ disabled?: boolean; + /** + * If set, render as a dropdown with these options instead of free text. + */ + options?: { value: string; label: string }[]; + /** + * Optional description shown as help text below the field. + */ + description?: string; } export interface UpdateSvRewardWeightProposal { @@ -85,6 +99,7 @@ export type Proposal = | UnclaimedActivityRecordProposal | AmuletRulesConfigProposal | DsoRulesConfigProposal + | UpdateFeatureAppProposal | undefined; export type ProposalActionMap = { @@ -95,6 +110,7 @@ export type ProposalActionMap = { SRARC_CreateUnallocatedUnclaimedActivityRecord: UnclaimedActivityRecordProposal; CRARC_SetConfig: AmuletRulesConfigProposal; SRARC_SetConfig: DsoRulesConfigProposal; + SRARC_UpdateFeaturedAppRight: UpdateFeatureAppProposal; // If no proposal type is defined, can use unknown or a specific type: CRARC_AddFutureAmuletConfigSchedule: unknown; }; @@ -133,7 +149,8 @@ export type SupportedActionTag = | 'SRARC_RevokeFeaturedAppRight' | 'SRARC_SetConfig' | 'SRARC_UpdateSvRewardWeight' - | 'SRARC_CreateUnallocatedUnclaimedActivityRecord'; + | 'SRARC_CreateUnallocatedUnclaimedActivityRecord' + | 'SRARC_UpdateFeaturedAppRight'; export type ProposalListingStatus = | 'Accepted' @@ -147,6 +164,7 @@ export interface ProposalListingData { contractId: ContractId; actionName: string; description?: string; + requester: string; votingThresholdDeadline: string; voteTakesEffect: string; yourVote: YourVoteStatus; @@ -198,11 +216,18 @@ export interface ProposalMutationArgs { action: ActionRequiringConfirmation; } +export interface UpdateFeatureAppFormData extends CommonProposalFormData { + partyId: string; + rightCid: string; + newActivityWeight: string; +} + export type NonConfigProposalFormData = | UpdateSvRewardWeightFormData | OffboardSvFormData | GrantRevokeFeaturedAppFormData - | CreateUnallocatedUnclaimedActivityRecordFormData; + | CreateUnallocatedUnclaimedActivityRecordFormData + | UpdateFeatureAppFormData; export type ConfigProposalFormData = SetDsoConfigCompleteFormData | SetAmuletConfigCompleteFormData; diff --git a/apps/sv/src/main/openapi/sv-internal.yaml b/apps/sv/src/main/openapi/sv-internal.yaml index 4884c71255..d271ebfed8 100644 --- a/apps/sv/src/main/openapi/sv-internal.yaml +++ b/apps/sv/src/main/openapi/sv-internal.yaml @@ -76,6 +76,7 @@ paths: get: tags: [sv] x-jvm-package: sv_public + x-external-audience: none operationId: "getCometBftNodeStatus" responses: "200": @@ -96,6 +97,7 @@ paths: post: tags: [sv] x-jvm-package: sv_public + x-external-audience: none operationId: "cometBftJsonRpcRequest" requestBody: required: true @@ -176,7 +178,7 @@ paths: /v0/admin/synchronizer/lsu/cancel: post: tags: [sv] - x-jvm-package: sv_admin + x-jvm-package: sv_operator operationId: "cancelLogicalSynchronizerUpgrade" responses: "200": @@ -229,6 +231,24 @@ paths: application/json: schema: "$ref": "../../../../common/src/main/openapi/common-internal.yaml#/components/schemas/ListDsoRulesVoteResultsResponse" + /v0/admin/sv/voteresults/count: + post: + tags: [ sv ] + x-jvm-package: sv_operator + operationId: "countVoteRequestResults" + requestBody: + required: true + content: + application/json: + schema: + "$ref": "../../../../common/src/main/openapi/common-internal.yaml#/components/schemas/CountVoteResultsRequest" + responses: + "200": + description: ok + content: + application/json: + schema: + "$ref": "../../../../common/src/main/openapi/common-internal.yaml#/components/schemas/CountVoteResultsResponse" /v0/admin/sv/previous-sv-reward-weight: post: tags: [ sv ] @@ -375,6 +395,7 @@ paths: post: tags: [sv] x-jvm-package: sv_public + x-external-audience: validators operationId: "onboardValidator" requestBody: required: true @@ -393,6 +414,7 @@ paths: post: tags: [sv] x-jvm-package: sv_public + x-external-audience: svs operationId: "startSvOnboarding" requestBody: required: true @@ -411,6 +433,7 @@ paths: get: tags: [sv] x-jvm-package: sv_public + x-external-audience: svs operationId: "getSvOnboardingStatus" parameters: - name: "candidate_party_id_or_name" @@ -433,6 +456,7 @@ paths: post: tags: [sv] x-jvm-package: sv_public + x-external-audience: svs operationId: "onboardSvPartyMigrationAuthorize" requestBody: required: true @@ -459,6 +483,7 @@ paths: post: tags: [sv] x-jvm-package: sv_public + x-external-audience: svs operationId: "onboardSvSequencer" requestBody: required: true @@ -479,6 +504,7 @@ paths: post: tags: [sv] x-jvm-package: sv_public + x-external-audience: validators description: "faucet for validator candidates self-service" operationId: "devNetOnboardValidatorPrepare" responses: @@ -497,6 +523,7 @@ paths: tags: [sv] # TODO(DACH-NY/canton-network-internal#2106) Move to sv_operator x-jvm-package: sv_public + x-external-audience: validators operationId: "getDsoInfo" responses: "200": @@ -510,6 +537,7 @@ paths: get: tags: [sv] x-jvm-package: sv_public + x-external-audience: svs operationId: "getMigrationId" description: | Returns the synchronizer migration id this SV is currently using. diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/SvApp.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/SvApp.scala index b7fcb81919..9c26cc8655 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/SvApp.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/SvApp.scala @@ -220,6 +220,7 @@ class SvApp( localSynchronizerNodes.current.close() localSynchronizerNodes.successor.foreach(_.close()) localSynchronizerNodes.legacy.foreach(_.close()) + localSynchronizerNodes.additionalLegacy.foreach(_.close()) Future.failed(err) } ) @@ -498,6 +499,7 @@ class SvApp( timeouts, loggerFactory, amuletAppParameters.upgradesConfig, + participantAdminConnection, ) adminHandler = new HttpSvAdminHandler( @@ -742,12 +744,22 @@ object SvApp { override def closeAsync(): Seq[AsyncOrSyncCloseable] = Seq( + // One SyncCloseable per node so a failing close does not skip the others. SyncCloseable( - s"Domain connections", { - localSynchronizerNodes.current.close() - localSynchronizerNodes.successor.foreach(_.close()) - localSynchronizerNodes.legacy.foreach(_.close()) - }, + s"current domain connections", + localSynchronizerNodes.current.close(), + ), + SyncCloseable( + s"successor domain connections", + localSynchronizerNodes.successor.foreach(_.close()), + ), + SyncCloseable( + s"legacy domain connections", + localSynchronizerNodes.legacy.foreach(_.close()), + ), + SyncCloseable( + s"additional legacy domain connections", + localSynchronizerNodes.additionalLegacy.foreach(_.close()), ), SyncCloseable( s"Participant Admin connection", diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/api/client/commands/HttpSvAdminAppClient.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/api/client/commands/HttpSvAdminAppClient.scala index 6e9060523e..0f6cafa261 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/api/client/commands/HttpSvAdminAppClient.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/api/client/commands/HttpSvAdminAppClient.scala @@ -40,22 +40,4 @@ object HttpSvAdminAppClient { } } - case class CancelLogicalSynchronizerUpgrade() - extends BaseCommand[http.CancelLogicalSynchronizerUpgradeResponse, Unit] { - - override def submitRequest( - client: Client, - headers: List[HttpHeader], - ): EitherT[Future, Either[ - Throwable, - HttpResponse, - ], http.CancelLogicalSynchronizerUpgradeResponse] = - client.cancelLogicalSynchronizerUpgrade(headers = headers) - - override def handleOk()(implicit - decoder: TemplateJsonDecoder - ) = { case http.CancelLogicalSynchronizerUpgradeResponse.OK => - Right(()) - } - } } diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/api/client/commands/HttpSvOperatorAppClient.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/api/client/commands/HttpSvOperatorAppClient.scala index b9f300eebf..2744a82274 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/api/client/commands/HttpSvOperatorAppClient.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/api/client/commands/HttpSvOperatorAppClient.scala @@ -19,6 +19,7 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.validatoronboarding a import org.lfdecentralizedtrust.splice.codegen.java.da.time.types.RelTime import org.lfdecentralizedtrust.splice.environment.SpliceStatus import org.lfdecentralizedtrust.splice.http.v0.{definitions, sv_operator as http} +import org.lfdecentralizedtrust.splice.store.VoteResultsFilters import org.lfdecentralizedtrust.splice.util.{Codec, Contract, TemplateJsonDecoder} import org.lfdecentralizedtrust.splice.sv.util.ValidatorOnboarding import com.digitalasset.canton.admin.api.client.data.NodeStatus @@ -244,11 +245,7 @@ object HttpSvOperatorAppClient { } case class ListVoteRequestResults( - actionName: Option[String], - accepted: Option[Boolean], - requester: Option[String], - effectiveFrom: Option[String], - effectiveTo: Option[String], + filters: VoteResultsFilters, limit: BigInt, pageToken: Option[BigInt] = None, ) extends BaseCommand[ @@ -265,13 +262,13 @@ object HttpSvOperatorAppClient { ): EitherT[Future, Either[Throwable, HttpResponse], http.ListVoteRequestResultsResponse] = client.listVoteRequestResults( body = definitions.ListVoteResultsRequest( - actionName, - accepted, - requester, - effectiveFrom, - effectiveTo, - limit, - pageToken, + filters.actionName, + filters.accepted, + requester = filters.requester, + effectiveFrom = filters.effectiveFrom, + effectiveTo = filters.effectiveTo, + limit = limit, + pageToken = pageToken, ), headers = headers, ) @@ -293,6 +290,35 @@ object HttpSvOperatorAppClient { } } + case class CountVoteRequestResults( + filters: VoteResultsFilters + ) extends BaseCommand[ + http.CountVoteRequestResultsResponse, + Long, + ] { + + override def submitRequest( + client: Client, + headers: List[HttpHeader], + ): EitherT[Future, Either[Throwable, HttpResponse], http.CountVoteRequestResultsResponse] = + client.countVoteRequestResults( + body = definitions.CountVoteResultsRequest( + filters.actionName, + filters.accepted, + requester = filters.requester, + effectiveFrom = filters.effectiveFrom, + effectiveTo = filters.effectiveTo, + ), + headers = headers, + ) + + override def handleOk()(implicit + decoder: TemplateJsonDecoder + ) = { case http.CountVoteRequestResultsResponse.OK(response) => + Right(response.count) + } + } + case class CastVote( trackingCid: VoteRequest.ContractId, isAccepted: Boolean, @@ -442,4 +468,23 @@ object HttpSvOperatorAppClient { Left(response.error) } } + + case class CancelLogicalSynchronizerUpgrade() + extends BaseCommand[http.CancelLogicalSynchronizerUpgradeResponse, Unit] { + + override def submitRequest( + client: Client, + headers: List[HttpHeader], + ): EitherT[Future, Either[ + Throwable, + HttpResponse, + ], http.CancelLogicalSynchronizerUpgradeResponse] = + client.cancelLogicalSynchronizerUpgrade(headers = headers) + + override def handleOk()(implicit + decoder: TemplateJsonDecoder + ) = { case http.CancelLogicalSynchronizerUpgradeResponse.OK => + Right(()) + } + } } diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvAdminHandler.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvAdminHandler.scala index 7b52cf5a43..8ff0a70ae8 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvAdminHandler.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvAdminHandler.scala @@ -37,27 +37,6 @@ class HttpSvAdminHandler( protected val workflowId: String = this.getClass.getSimpleName private val dsoStore = dsoStoreWithIngestion.store - override def cancelLogicalSynchronizerUpgrade( - respond: r0.CancelLogicalSynchronizerUpgradeResponse.type - )()( - extracted: AdminUserRequest - ): Future[r0.CancelLogicalSynchronizerUpgradeResponse] = { - implicit val AdminUserRequest(traceContext) = extracted - withSpan(s"$workflowId.cancelLogicalSynchronizerUpgrade") { _ => _ => - for { - decentralizedSynchronizer <- dsoStore.getDsoRules().map(_.domain) - sequencerId <- synchronizerNodeService.sequencerAdminConnection().flatMap(_.getSequencerId) - _ <- participantAdminConnection - .removeSequencerSuccessor( - decentralizedSynchronizer, - sequencerId, - ) - _ <- participantAdminConnection - .removeLsuAnnouncement(decentralizedSynchronizer) - } yield r0.CancelLogicalSynchronizerUpgradeResponseOK - } - } - override def getSynchronizerNodeIdentitiesDump( respond: r0.GetSynchronizerNodeIdentitiesDumpResponse.type )()( diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala index 822538ca6f..5cf43e04a7 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvOperatorHandler.scala @@ -34,7 +34,12 @@ import org.lfdecentralizedtrust.splice.http.{ } import org.lfdecentralizedtrust.splice.scan.admin.api.client.ScanConnection import org.lfdecentralizedtrust.splice.scan.config.ScanAppClientConfig -import org.lfdecentralizedtrust.splice.store.{ActiveVotesStore, AppStore, AppStoreWithIngestion} +import org.lfdecentralizedtrust.splice.store.{ + ActiveVotesStore, + AppStore, + AppStoreWithIngestion, + VoteResultsFilters, +} import org.lfdecentralizedtrust.splice.sv.cometbft.CometBftClient import org.lfdecentralizedtrust.splice.sv.config.SvAppBackendConfig import org.lfdecentralizedtrust.splice.sv.store.{SvDsoStore, SvSvStore} @@ -57,6 +62,7 @@ class HttpSvOperatorHandler( override protected val timeouts: ProcessingTimeout, protected val loggerFactory: NamedLoggerFactory, upgradesConfig: UpgradesConfig, + participantAdminConnection: ParticipantAdminConnection, )(implicit ec: ExecutionContextExecutor, protected val tracer: Tracer, @@ -131,11 +137,13 @@ class HttpSvOperatorHandler( for { scanConnection <- scanConnectionF (voteResults, nextPageToken) <- scanConnection.listVoteRequestResults( - body.actionName, - body.accepted, - body.requester, - body.effectiveFrom, - body.effectiveTo, + VoteResultsFilters( + body.actionName, + body.accepted, + requester = body.requester, + effectiveFrom = body.effectiveFrom, + effectiveTo = body.effectiveTo, + ), body.limit.intValue, body.pageToken, ) @@ -162,6 +170,34 @@ class HttpSvOperatorHandler( } } + override def countVoteRequestResults( + respond: r0.CountVoteRequestResultsResponse.type + )( + body: definitions.CountVoteResultsRequest + )( + extracted: ActAsKnownUserRequest + ): Future[r0.CountVoteRequestResultsResponse] = { + implicit val ActAsKnownUserRequest(traceContext) = extracted + withSpan(s"$workflowId.countVoteRequestResults") { _ => _ => + for { + scanConnection <- scanConnectionF + count <- scanConnection.countVoteRequestResults( + VoteResultsFilters( + body.actionName, + body.accepted, + requester = body.requester, + effectiveFrom = body.effectiveFrom, + effectiveTo = body.effectiveTo, + ) + ) + } yield { + r0.CountVoteRequestResultsResponse.OK( + definitions.CountVoteResultsResponse(count) + ) + } + } + } + override def getPreviousSvRewardWeight( respond: r0.GetPreviousSvRewardWeightResponse.type )( @@ -562,6 +598,21 @@ class HttpSvOperatorHandler( } } + override def cancelLogicalSynchronizerUpgrade( + respond: r0.CancelLogicalSynchronizerUpgradeResponse.type + )()( + extracted: ActAsKnownUserRequest + ): Future[r0.CancelLogicalSynchronizerUpgradeResponse] = { + implicit val ActAsKnownUserRequest(traceContext) = extracted + withSpan(s"$workflowId.cancelLogicalSynchronizerUpgrade") { _ => _ => + for { + decentralizedSynchronizer <- dsoStore.getDsoRules().map(_.domain) + _ <- participantAdminConnection + .removeLsuAnnouncement(decentralizedSynchronizer) + } yield r0.CancelLogicalSynchronizerUpgradeResponseOK + } + } + private def withClientOrNotFound[T]( notFound: definitions.ErrorResponse => T )(call: CometBftClient => Future[T])(implicit tc: TraceContext) = diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvPublicHandler.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvPublicHandler.scala index 9605f13d3e..997a2df1ea 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvPublicHandler.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/admin/http/HttpSvPublicHandler.scala @@ -880,6 +880,7 @@ class HttpSvPublicHandler( ), deduplicationOffset = offset, ) + .recoveringAcceptedDuplicates() .yieldUnit() } .value diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/DsoDelegateBasedAutomationService.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/DsoDelegateBasedAutomationService.scala index 81df31bf99..dc9052433e 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/DsoDelegateBasedAutomationService.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/DsoDelegateBasedAutomationService.scala @@ -13,11 +13,10 @@ import org.lfdecentralizedtrust.splice.automation.AutomationServiceCompanion.{ } import org.lfdecentralizedtrust.splice.automation.{AutomationService, AutomationServiceCompanion} import org.lfdecentralizedtrust.splice.environment.RetryProvider -import org.lfdecentralizedtrust.splice.store.DomainTimeSynchronization +import org.lfdecentralizedtrust.splice.store.{DomainTimeSynchronization, IgnoredPartiesStore} import org.lfdecentralizedtrust.splice.scan.admin.api.client.{BftScanConnection, ScanConnection} import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.* import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.ExpiredAmuletAllocationTrigger -import org.lfdecentralizedtrust.splice.sv.store.IgnoredPartiesStore import org.lfdecentralizedtrust.splice.sv.config.SvAppBackendConfig import scala.concurrent.{ExecutionContextExecutor, Future} @@ -46,7 +45,7 @@ class DsoDelegateBasedAutomationService( : org.lfdecentralizedtrust.splice.sv.automation.DsoDelegateBasedAutomationService.type = DsoDelegateBasedAutomationService - val expiredAmuletIgnoredPartiesStore = new IgnoredPartiesStore( + val unavailablePartiesStore = new IgnoredPartiesStore( triggerContext.config.ignoredPartyIds ) @@ -69,7 +68,7 @@ class DsoDelegateBasedAutomationService( config, triggerContext, svTaskContext, - expiredAmuletIgnoredPartiesStore, + unavailablePartiesStore, ) ) registerTrigger( @@ -77,7 +76,7 @@ class DsoDelegateBasedAutomationService( config, triggerContext, svTaskContext, - expiredAmuletIgnoredPartiesStore, + unavailablePartiesStore, ) ) registerTrigger( @@ -86,7 +85,7 @@ class DsoDelegateBasedAutomationService( clock, triggerContext, svTaskContext, - expiredAmuletIgnoredPartiesStore, + unavailablePartiesStore, ) ) registerTrigger( @@ -95,7 +94,7 @@ class DsoDelegateBasedAutomationService( clock, triggerContext, svTaskContext, - expiredAmuletIgnoredPartiesStore, + unavailablePartiesStore, ) ) registerTrigger( @@ -104,7 +103,7 @@ class DsoDelegateBasedAutomationService( clock, triggerContext, svTaskContext, - expiredAmuletIgnoredPartiesStore, + unavailablePartiesStore, ) ) registerTrigger(new ExpiredSvOnboardingRequestTrigger(triggerContext, svTaskContext)) @@ -119,15 +118,36 @@ class DsoDelegateBasedAutomationService( new ExpireRewardCouponsTrigger( triggerContext, svTaskContext, - expiredAmuletIgnoredPartiesStore, + unavailablePartiesStore, config, ) ) registerTrigger(new AnsSubscriptionRenewalPaymentTrigger(triggerContext, svTaskContext)) - registerTrigger(new ExpiredAnsEntryTrigger(triggerContext, svTaskContext)) - registerTrigger(new ExpireTransferPreapprovalsTrigger(triggerContext, svTaskContext)) - registerTrigger(new ExpiredAnsSubscriptionTrigger(triggerContext, svTaskContext)) + registerTrigger( + new ExpiredAnsEntryTrigger( + triggerContext, + svTaskContext, + config, + unavailablePartiesStore, + ) + ) + registerTrigger( + new ExpireTransferPreapprovalsTrigger( + triggerContext, + svTaskContext, + config, + unavailablePartiesStore, + ) + ) + registerTrigger( + new ExpiredAnsSubscriptionTrigger( + triggerContext, + svTaskContext, + config, + unavailablePartiesStore, + ) + ) registerTrigger(new TerminatedSubscriptionTrigger(triggerContext, svTaskContext)) registerTrigger(new MergeSvRewardStateContractsTrigger(triggerContext, svTaskContext)) @@ -143,7 +163,7 @@ class DsoDelegateBasedAutomationService( triggerContext, svTaskContext, config, - expiredAmuletIgnoredPartiesStore, + unavailablePartiesStore, ) ) @@ -180,6 +200,7 @@ class DsoDelegateBasedAutomationService( config, triggerContext, svTaskContext, + unavailablePartiesStore, ) ) @@ -214,7 +235,7 @@ class DsoDelegateBasedAutomationService( object DsoDelegateBasedAutomationService extends AutomationServiceCompanion { // defined because the service isn't available immediately in sv app state, - // but created later by the restart trigger + // but created later override protected[this] def expectedTriggerClasses: Seq[TriggerClass] = Seq( aTrigger[AdvanceOpenMiningRoundTrigger], aTrigger[UpdateExternalPartyConfigStateTrigger], diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/PeriodicTopologySnapshotTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/PeriodicTopologySnapshotTrigger.scala index 038683cf44..91a336982c 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/PeriodicTopologySnapshotTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/PeriodicTopologySnapshotTrigger.scala @@ -8,7 +8,6 @@ import com.digitalasset.canton.SynchronizerAlias import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.time.Clock import com.digitalasset.canton.topology.PhysicalSynchronizerId -import com.digitalasset.canton.topology.admin.grpc.TopologyStoreId import com.digitalasset.canton.tracing.TraceContext import io.circe.Json import io.grpc.{Status, StatusRuntimeException} @@ -153,21 +152,12 @@ class PeriodicTopologySnapshotTrigger( }, logger, ) - // list a summary of the transactions state at the time of the snapshot to validate further imports - summary <- triggerContext.retryProvider.retry( - RetryFor.Automation, - "getTopologyTransactionsSummary", - "Get topology transactions summary", - sequencerAdminConnection.getTopologyTransactionsSummary( - TopologyStoreId.Synchronizer(physicalSynchronizerId.logical), - clock.now, - ), - logger, - ) // we create a single metadata file to store the amounts of the different transactions along the sequencerId - metadataMap = summary.map(e => (e._1.code, e._2.toString)) + - ("sequencerId" -> sequencerId.toProtoPrimitive) + - ("physicalSynchronizerId" -> physicalSynchronizerId.toProtoPrimitive) + metadataMap = + Map( + "sequencerId" -> sequencerId.toProtoPrimitive, + "physicalSynchronizerId" -> physicalSynchronizerId.toProtoPrimitive, + ) metadataJson = Json .obj(metadataMap.map { case (k, v) => k -> Json.fromString(v) }.toSeq*) .spaces2 diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/SvDsoAutomationService.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/SvDsoAutomationService.scala index b67dafbebb..81ba6fdd61 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/SvDsoAutomationService.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/SvDsoAutomationService.scala @@ -7,7 +7,12 @@ import cats.implicits.catsSyntaxOptionId import com.daml.grpc.adapter.ExecutionSequencerFactory import com.digitalasset.canton.SynchronizerAlias import com.digitalasset.canton.config.ClientConfig -import com.digitalasset.canton.lifecycle.{AsyncCloseable, AsyncOrSyncCloseable} +import com.digitalasset.canton.lifecycle.{ + AsyncCloseable, + AsyncOrSyncCloseable, + LifeCycle, + SyncCloseable, +} import com.digitalasset.canton.logging.NamedLoggerFactory import com.digitalasset.canton.time.{Clock, WallClock} import com.digitalasset.canton.topology.SynchronizerId @@ -47,6 +52,7 @@ import org.lfdecentralizedtrust.splice.sv.automation.SvDsoAutomationService.{ LocalSequencerClientContext, } import org.lfdecentralizedtrust.splice.sv.automation.confirmation.* +import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.SvTaskBasedTrigger import org.lfdecentralizedtrust.splice.sv.automation.singlesv.* import org.lfdecentralizedtrust.splice.sv.automation.singlesv.offboarding.{ SvOffboardingMediatorTrigger, @@ -210,7 +216,10 @@ class SvDsoAutomationService( } override protected def closeAsync(): Seq[AsyncOrSyncCloseable] = - super.closeAsync() ++ + SyncCloseable( + "dso-delegate-based-automation", + LifeCycle.close(dsoDelegateBasedAutomation)(logger), + ) +: (super.closeAsync() ++ // super.closeAsync() waits for all triggers to close, so we do not need to worry // about synchronization when closing the scan connections here. ownScanConnectionF @@ -235,7 +244,7 @@ class SvDsoAutomationService( timeouts.shutdownNetwork, ) ) - .toList + .toList) private val packageVettingService = new PackageVettingLookupService( config.packageVettingCache, @@ -251,19 +260,24 @@ class SvDsoAutomationService( // notice the absence of UpdateHistory: the history for the dso party is duplicate with Scan - private[splice] val restartDsoDelegateBasedAutomationTrigger = - new RestartDsoDelegateBasedAutomationTrigger( - triggerContext, - domainTimeSync, - dsoStore, - connection, + private[splice] val dsoDelegateBasedAutomation = + new DsoDelegateBasedAutomationService( clock, + domainTimeSync, config, - retryProvider, - packageVersionSupport, - packageVettingService, + SvTaskBasedTrigger.Context( + dsoStore, + connection, + config.delegatelessAutomationExpectedTaskDuration, + config.delegatelessAutomationExpiredRewardCouponBatchSize, + config.delegatelessAutomationExpiredRewardCouponNumBatches, + packageVersionSupport, + packageVettingService, + ), () => getOrCreateOwnScanConnection(), () => getOrCreatePeerScanConnection(), + retryProvider, + loggerFactory, ) // required for triggers that must run in sim time as well @@ -428,7 +442,7 @@ class SvDsoAutomationService( synchronizerNodeService.nodes.successor.foreach(registerTriggersForSynchronizers) } - def registerLsuTriggers() = { + def registerLsuTriggers(): Unit = { synchronizerNodeService.nodes.successor match { case Some(successorSynchronizerNode) => registerTrigger( @@ -538,7 +552,7 @@ class SvDsoAutomationService( ) ) - registerTrigger(restartDsoDelegateBasedAutomationTrigger) + dsoDelegateBasedAutomation.start() registerTrigger( new AnsSubscriptionInitialPaymentTrigger( @@ -597,6 +611,12 @@ class SvDsoAutomationService( dsoStore, ) ) + registerTrigger( + new VoteRequestMetricsTrigger( + triggerContext, + dsoStore, + ) + ) registerTrigger( new RewardMetricsTrigger( triggerContext, @@ -737,7 +757,6 @@ object SvDsoAutomationService extends AutomationServiceCompanion { aTrigger[CalculateRewardsTrigger], aTrigger[CalculateRewardsDryRunTrigger], aTrigger[ConfirmationMismatchReportTrigger], - aTrigger[RestartDsoDelegateBasedAutomationTrigger], aTrigger[AnsSubscriptionInitialPaymentTrigger], aTrigger[SvPackageVettingTrigger], aTrigger[SvOffboardingPartyToParticipantProposalTrigger], @@ -765,6 +784,7 @@ object SvDsoAutomationService extends AutomationServiceCompanion { aTrigger[FollowAmuletConversionRateFeedTrigger], aTrigger[CopyVotesTrigger], aTrigger[AmuletPriceMetricsTrigger], + aTrigger[VoteRequestMetricsTrigger], aTrigger[RewardMetricsTrigger], aTrigger[CreateBootstrapExternalPartyConfigStateInstructionTrigger], aTrigger[LsuTrigger], diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/VoteRequestMetricsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/VoteRequestMetricsTrigger.scala new file mode 100644 index 0000000000..c7f761dcb2 --- /dev/null +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/VoteRequestMetricsTrigger.scala @@ -0,0 +1,113 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.sv.automation + +import com.daml.metrics.api.MetricHandle.{Gauge, LabeledMetricsFactory} +import com.daml.metrics.api.MetricQualification.Saturation +import com.daml.metrics.api.{MetricInfo, MetricName, MetricsContext} +import com.digitalasset.canton.lifecycle.{AsyncOrSyncCloseable, SyncCloseable} +import com.digitalasset.canton.tracing.TraceContext +import io.opentelemetry.api.trace.Tracer +import org.apache.pekko.stream.Materializer +import org.lfdecentralizedtrust.splice.automation.{PollingTrigger, TriggerContext} +import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.VoteRequest +import org.lfdecentralizedtrust.splice.environment.SpliceMetrics +import org.lfdecentralizedtrust.splice.store.PageLimit +import org.lfdecentralizedtrust.splice.sv.automation.VoteRequestMetricsTrigger.{ + VoteRequestMetrics, + countByState, +} +import org.lfdecentralizedtrust.splice.sv.store.SvDsoStore +import org.lfdecentralizedtrust.splice.util.Contract + +import scala.concurrent.{ExecutionContext, Future} +import scala.jdk.CollectionConverters.* + +class VoteRequestMetricsTrigger( + override protected val context: TriggerContext, + dsoStore: SvDsoStore, +)(implicit + override val ec: ExecutionContext, + override val tracer: Tracer, + val mat: Materializer, +) extends PollingTrigger { + + private val voteRequestMetrics = new VoteRequestMetrics(context.metricsFactory) + private val svParty = dsoStore.key.svParty.toProtoPrimitive + + override def performWorkIfAvailable()(implicit traceContext: TraceContext): Future[Boolean] = + for { + voteRequests <- dsoStore.listVoteRequests() + readyToCloseContracts <- dsoStore.listVoteRequestsReadyToBeClosed( + context.clock.now, + PageLimit.Max, + )(traceContext) + } yield { + val counts = countByState( + voteRequests, + readyToCloseContracts.map(_.contractId).toSet, + svParty, + ) + voteRequestMetrics.actionNeeded.updateValue(counts.actionNeeded) + voteRequestMetrics.inProgress.updateValue(counts.inProgress) + voteRequestMetrics.readyToClose.updateValue(counts.readyToClose) + false + } + + override def closeAsync(): Seq[AsyncOrSyncCloseable] = super + .closeAsync() + .appended(SyncCloseable("vote request metrics", voteRequestMetrics.close())) +} + +object VoteRequestMetricsTrigger { + + case class VoteRequestCounts(actionNeeded: Long, inProgress: Long, readyToClose: Long) + + def countByState( + voteRequests: Seq[Contract[VoteRequest.ContractId, VoteRequest]], + readyToCloseCids: Set[VoteRequest.ContractId], + svParty: String, + ): VoteRequestCounts = { + val (readyToClose, open) = + voteRequests.partition(request => readyToCloseCids.contains(request.contractId)) + val (inProgress, actionNeeded) = + open.partition(_.payload.votes.values().asScala.exists(_.sv == svParty)) + VoteRequestCounts( + actionNeeded = actionNeeded.size.toLong, + inProgress = inProgress.size.toLong, + readyToClose = readyToClose.size.toLong, + ) + } + + case class VoteRequestMetrics(metricsFactory: LabeledMetricsFactory) extends AutoCloseable { + + private val name: MetricName = + SpliceMetrics.MetricsPrefix :+ "sv_vote_requests" :+ "active" + + private def stateGauge(state: String): Gauge[Long] = + metricsFactory.gauge( + MetricInfo( + name, + "The number of active vote requests, split by their state relative to this SV", + Saturation, + "The state label is one of: " + + "action_needed (the request is open for voting and this SV has not voted yet), " + + "in_progress (the request is open for voting and this SV has voted), " + + "ready_to_close (the request fulfills the conditions for the closing automation " + + "to close it, e.g. its voting deadline has passed).", + ), + 0L, + )(MetricsContext.Empty.withExtraLabels("state" -> state)) + + val actionNeeded: Gauge[Long] = stateGauge("action_needed") + val inProgress: Gauge[Long] = stateGauge("in_progress") + val readyToClose: Gauge[Long] = stateGauge("ready_to_close") + + override def close(): Unit = { + actionNeeded.close() + inProgress.close() + readyToClose.close() + } + } +} diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala index 6682db3c36..dcb628e3be 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/CalculateRewardsTrigger.scala @@ -159,10 +159,12 @@ abstract class CalculateRewardsTriggerBase( rewardMetrics.calculateRewardsRootHashBftReads.mark() for { bftScan <- getPeerBftScanConnection() - response <- bftScan.getRewardAccountingRootHash(round) + response <- bftScan.getRewardAccountingRootHashWithScanUris(round) } yield response match { - case RewardAccountingRootHashOk(ok) => - logger.info(s"Obtained the root-hash for round $round via BFT read.") + case (RewardAccountingRootHashOk(ok), scanUris) => + logger.info( + s"Obtained the root-hash for round $round via BFT read from scans: ${scanUris.mkString(", ")}." + ) new Hash(ok.rootHash) case _ => rootHashUnavailable("could not obtain root-hash via BFT read.") } diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/SummarizingMiningRoundTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/SummarizingMiningRoundTrigger.scala index 98ab463033..5fdbaca608 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/SummarizingMiningRoundTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/confirmation/SummarizingMiningRoundTrigger.scala @@ -238,10 +238,13 @@ class SummarizingMiningRoundTrigger( miningRoundMetrics.summarizingRoundTotalsBftReads.mark() for { bftScan <- bftScanConnectionF() - response <- bftScan.getRewardAccountingActivityTotals(round) + response <- bftScan.getRewardAccountingActivityTotalsWithScanUris(round) } yield response match { - case RewardAccountingActivityTotalsOk(ok) => - logger.info(s"Obtained the reward accounting totals for round $round via BFT read.") + case (RewardAccountingActivityTotalsOk(ok), scanUris) => + logger.info( + s"Obtained the reward accounting totals for round $round via BFT read from scans: ${scanUris + .mkString(", ")}." + ) ok case _ => totalsUnavailable("could not obtain reward accounting totals via BFT read.") } diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpireRewardCouponV2Trigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpireRewardCouponV2Trigger.scala index 533bdab3fe..837e02f79f 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpireRewardCouponV2Trigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpireRewardCouponV2Trigger.scala @@ -6,24 +6,26 @@ package org.lfdecentralizedtrust.splice.sv.automation.delegatebased import org.lfdecentralizedtrust.splice.automation.* import org.lfdecentralizedtrust.splice.codegen.java.splice import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletrules.AmuletRules_ClaimExpiredRewardsV2 -import com.digitalasset.canton.topology.PartyId import com.digitalasset.canton.tracing.TraceContext import io.opentelemetry.api.trace.Tracer import org.apache.pekko.stream.Materializer import scala.concurrent.{ExecutionContext, Future} -import ExpireRewardCouponV2Trigger.* +import ExpireRewardCouponV2Trigger.{Coupon, CouponCid, Task, getStakeholders} import org.lfdecentralizedtrust.splice.environment.{DarResources, PackageIdResolver} import org.lfdecentralizedtrust.splice.store.AppStoreWithIngestion.SpliceLedgerConnectionPriority +import org.lfdecentralizedtrust.splice.store.IgnoredPartiesStore import org.lfdecentralizedtrust.splice.sv.config.SvAppBackendConfig +import org.lfdecentralizedtrust.splice.sv.util.ContractStakeholders import scala.jdk.CollectionConverters.* import scala.jdk.OptionConverters.* class ExpireRewardCouponV2Trigger( - svConfig: SvAppBackendConfig, + override protected val svConfig: SvAppBackendConfig, override protected val context: TriggerContext, override protected val svTaskContext: SvTaskBasedTrigger.Context, + override protected val ignoredPartiesStore: IgnoredPartiesStore, )(implicit override val ec: ExecutionContext, mat: Materializer, @@ -35,13 +37,23 @@ class ExpireRewardCouponV2Trigger( splice.amulet.RewardCouponV2.COMPANION, svTaskContext.vettingLookupService, PackageIdResolver.Package.SpliceAmulet, - payload => (payload.dso +: observerParties(payload)).map(PartyId.tryFromProtoPrimitive(_)), + getStakeholders, ) - with SvTaskBasedTrigger[Task] { + with SvTaskBasedTrigger[Task] + with IgnoredUnavailablePartiesGuard { private val store = svTaskContext.dsoStore override def completeTaskAsDsoDelegate(task: Task, controller: String)(implicit tc: TraceContext + ): Future[TaskOutcome] = + completeUnlessAmuletVersionIgnored( + task.work.vettedVersion.toString, + task.work.stakeholders, + ignoreUnresponsiveParties = true, + )(completeExpiryTaskAsDsoDelegate(task, controller)) + + private def completeExpiryTaskAsDsoDelegate(task: Task, controller: String)(implicit + tc: TraceContext ): Future[TaskOutcome] = { val expiredCoupons = task.work.expiredContracts // The batch is already split by the amulet version so we skip the whole batch. @@ -54,8 +66,11 @@ class ExpireRewardCouponV2Trigger( ) } else { val cids = expiredCoupons.map(_.contractId).asJava - val expiryObservers = - expiredCoupons.flatMap(c => observerParties(c.payload)).distinct.sorted + val expiryInformees = (task.work.stakeholders - store.key.dsoParty) + .map(_.toProtoPrimitive) + .toSeq + .distinct + .sorted for { dsoRules <- store.getDsoRules() amuletRules <- store.getAmuletRules() @@ -64,7 +79,7 @@ class ExpireRewardCouponV2Trigger( amuletRules.contractId, new AmuletRules_ClaimExpiredRewardsV2( cids, - expiryObservers.asJava, + expiryInformees.asJava, ), controller, ) @@ -82,9 +97,10 @@ class ExpireRewardCouponV2Trigger( } yield TaskSuccess(s"archived ${expiredCoupons.size} expired reward coupons v2") } } + } -object ExpireRewardCouponV2Trigger { +object ExpireRewardCouponV2Trigger extends ContractStakeholders[splice.amulet.RewardCouponV2] { private type CouponCid = splice.amulet.RewardCouponV2.ContractId private type Coupon = splice.amulet.RewardCouponV2 @@ -93,7 +109,10 @@ object ExpireRewardCouponV2Trigger { BatchedMultiDomainExpiredContractTrigger.Batch[CouponCid, Coupon] ] - private def observerParties(coupon: Coupon): Seq[String] = - if (coupon.providerIsObserver) coupon.provider +: coupon.beneficiary.toScala.toList - else Seq.empty + override def informees(payload: splice.amulet.RewardCouponV2): Seq[String] = if ( + payload.providerIsObserver + ) payload.provider +: payload.beneficiary.toScala.toList + else Seq.empty + + override def dso(payload: splice.amulet.RewardCouponV2): String = payload.dso } diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpireRewardCouponsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpireRewardCouponsTrigger.scala index 26d51e6196..4248c82c54 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpireRewardCouponsTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpireRewardCouponsTrigger.scala @@ -16,18 +16,18 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletrules.{ } import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.DsoRules import org.lfdecentralizedtrust.splice.environment.PackageIdResolver.Package.SpliceAmulet -import org.lfdecentralizedtrust.splice.sv.store.{ExpiredRewardCouponsBatch, IgnoredPartiesStore} +import org.lfdecentralizedtrust.splice.sv.store.ExpiredRewardCouponsBatch import org.lfdecentralizedtrust.splice.util.{AssignedContract, Contract} import org.lfdecentralizedtrust.splice.util.PrettyInstances.* import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} -import com.digitalasset.canton.topology.PartyId import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.util.ShowUtil.* import com.digitalasset.canton.util.MonadUtil import io.opentelemetry.api.trace.Tracer import org.apache.pekko.stream.Materializer import org.lfdecentralizedtrust.splice.store.AppStoreWithIngestion.SpliceLedgerConnectionPriority -import org.lfdecentralizedtrust.splice.store.PageLimit +import org.lfdecentralizedtrust.splice.store.{IgnoredPartiesStore, PageLimit} +import org.lfdecentralizedtrust.splice.codegen.java.splice import java.util.Optional import scala.concurrent.{ExecutionContext, Future} @@ -35,7 +35,12 @@ import scala.jdk.CollectionConverters.* import scala.jdk.OptionConverters.* import scala.util.Random import ExpireRewardCouponsTrigger.Task +import org.lfdecentralizedtrust.splice.codegen.java.splice.validatorlicense.{ + ValidatorFaucetCoupon, + ValidatorLivenessActivityRecord, +} import org.lfdecentralizedtrust.splice.sv.config.SvAppBackendConfig +import org.lfdecentralizedtrust.splice.sv.util.ContractStakeholders class ExpireRewardCouponsTrigger( override protected val context: TriggerContext, @@ -48,7 +53,7 @@ class ExpireRewardCouponsTrigger( tracer: Tracer, ) extends PollingParallelTaskExecutionTrigger[Task] with SvTaskBasedTrigger[Task] - with IgnoredAmuletVersionGuard { + with IgnoredUnavailablePartiesGuard { private val store = svTaskContext.dsoStore override protected def retrieveTasks()(implicit @@ -89,33 +94,27 @@ class ExpireRewardCouponsTrigger( SpliceAmulet, batch.validatorCoupons, svTaskContext.delegatelessAutomationExpiredRewardCouponBatchSize, - )(c => Seq(c.payload.dso, c.payload.user).map(PartyId.tryFromProtoPrimitive(_))) + )(c => ValidatorCoupons.getStakeholders(c.payload)) appCoupons <- svTaskContext.vettingLookupService.splitBatch( SpliceAmulet, batch.appCoupons, svTaskContext.delegatelessAutomationExpiredRewardCouponBatchSize, - )(c => - (Seq(c.payload.dso, c.payload.provider) ++ c.payload.beneficiary.toScala.toList) - .map(PartyId.tryFromProtoPrimitive(_)) - ) + )(c => AppRewardCoupons.getStakeholders(c.payload)) svRewardCoupons <- svTaskContext.vettingLookupService.splitBatch( SpliceAmulet, batch.svRewardCoupons, svTaskContext.delegatelessAutomationExpiredRewardCouponBatchSize, - )(c => - Seq(c.payload.dso, c.payload.sv, c.payload.beneficiary) - .map(PartyId.tryFromProtoPrimitive(_)) - ) + )(c => SvRewardCoupons.getStakeholders(c.payload)) validatorFaucets <- svTaskContext.vettingLookupService.splitBatch( SpliceAmulet, batch.validatorFaucets, svTaskContext.delegatelessAutomationExpiredRewardCouponBatchSize, - )(c => Seq(c.payload.dso, c.payload.validator).map(PartyId.tryFromProtoPrimitive(_))) + )(c => ValidatorFaucetCoupons.getStakeholders(c.payload)) validatorLivenessActivityRecords <- svTaskContext.vettingLookupService.splitBatch( SpliceAmulet, batch.validatorLivenessActivityRecords, svTaskContext.delegatelessAutomationExpiredRewardCouponBatchSize, - )(c => Seq(c.payload.dso, c.payload.validator).map(PartyId.tryFromProtoPrimitive(_))) + )(c => ValidatorLivenessActivityRecords.getStakeholders(c.payload)) } yield { val emptyBatch = ExpiredRewardCouponsBatch( closedRoundCid = batch.closedRoundCid, @@ -157,19 +156,17 @@ class ExpireRewardCouponsTrigger( override def completeTaskAsDsoDelegate(task: Task, controller: String)(implicit tc: TraceContext ): Future[TaskOutcome] = { - val informees = - (task.batch.validatorCoupons.map(_.payload.user) ++ task.batch.appCoupons.flatMap(c => - Seq(c.payload.provider) ++ c.payload.beneficiary.toScala - ) ++ - task.batch.svRewardCoupons.map(_.payload.beneficiary) ++ task.batch.validatorFaucets.map( - _.payload.validator - ) ++ task.batch.validatorLivenessActivityRecords.map(_.payload.validator)) - .map(PartyId.tryFromProtoPrimitive(_)) - .toSet - completeWithIgnoredAmuletVersionCheck( + val informees = ValidatorCoupons.getInformeesFromContracts(task.batch.validatorCoupons) ++ + AppRewardCoupons.getInformeesFromContracts(task.batch.appCoupons) ++ + SvRewardCoupons.getInformeesFromContracts(task.batch.svRewardCoupons) ++ + ValidatorFaucetCoupons.getInformeesFromContracts(task.batch.validatorFaucets) ++ + ValidatorLivenessActivityRecords.getInformeesFromContracts( + task.batch.validatorLivenessActivityRecords + ) + completeUnlessAmuletVersionIgnored( task.vettedAmuletVersion.toString, informees, - enableUnresponsivePartiesAutoIgnore = true, + ignoreUnresponsiveParties = true, )(completeExpiryTaskAsDsoDelegate(task, controller)) } @@ -318,3 +315,35 @@ object ExpireRewardCouponsTrigger { ) } } + +object ValidatorCoupons extends ContractStakeholders[splice.amulet.ValidatorRewardCoupon] { + override def informees(payload: splice.amulet.ValidatorRewardCoupon): Seq[String] = Seq( + payload.user + ) + override def dso(payload: splice.amulet.ValidatorRewardCoupon): String = payload.dso +} + +object AppRewardCoupons extends ContractStakeholders[splice.amulet.AppRewardCoupon] { + override def informees(payload: splice.amulet.AppRewardCoupon): Seq[String] = + Seq(payload.provider) ++ payload.beneficiary.toScala.toList + override def dso(payload: splice.amulet.AppRewardCoupon): String = payload.dso +} + +object SvRewardCoupons extends ContractStakeholders[splice.amulet.SvRewardCoupon] { + override def informees(payload: splice.amulet.SvRewardCoupon): Seq[String] = + Seq(payload.sv, payload.beneficiary) + override def dso(payload: splice.amulet.SvRewardCoupon): String = payload.dso +} + +object ValidatorFaucetCoupons extends ContractStakeholders[ValidatorFaucetCoupon] { + override def informees(payload: ValidatorFaucetCoupon): Seq[String] = Seq(payload.validator) + override def dso(payload: ValidatorFaucetCoupon): String = payload.dso +} + +object ValidatorLivenessActivityRecords + extends ContractStakeholders[ValidatorLivenessActivityRecord] { + override def informees(payload: ValidatorLivenessActivityRecord): Seq[String] = Seq( + payload.validator + ) + override def dso(payload: ValidatorLivenessActivityRecord): String = payload.dso +} diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpireTransferPreapprovalsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpireTransferPreapprovalsTrigger.scala index 7071d559e4..ea84f04c59 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpireTransferPreapprovalsTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpireTransferPreapprovalsTrigger.scala @@ -10,13 +10,19 @@ import com.digitalasset.canton.tracing.TraceContext import io.opentelemetry.api.trace.Tracer import org.apache.pekko.stream.Materializer import org.lfdecentralizedtrust.splice.store.AppStoreWithIngestion.SpliceLedgerConnectionPriority +import org.lfdecentralizedtrust.splice.sv.config.SvAppBackendConfig +import org.lfdecentralizedtrust.splice.sv.util.ContractStakeholders import java.util.Optional import scala.concurrent.{ExecutionContext, Future} +import ExpireTransferPreapprovalsTrigger.{Task, getStakeholders} +import org.lfdecentralizedtrust.splice.store.IgnoredPartiesStore class ExpireTransferPreapprovalsTrigger( override protected val context: TriggerContext, override protected val svTaskContext: SvTaskBasedTrigger.Context, + override protected val svConfig: SvAppBackendConfig, + override protected val ignoredPartiesStore: IgnoredPartiesStore, )(implicit override val ec: ExecutionContext, mat: Materializer, @@ -26,30 +32,36 @@ class ExpireTransferPreapprovalsTrigger( TransferPreapproval, ]( svTaskContext.dsoStore.multiDomainAcsStore, - svTaskContext.dsoStore.listExpiredTransferPreapprovals, + svTaskContext.dsoStore.listExpiredTransferPreapprovals(Some(ignoredPartiesStore)), TransferPreapproval.COMPANION, ) with SvTaskBasedTrigger[ScheduledTaskTrigger.ReadyTask[AssignedContract[ TransferPreapproval.ContractId, TransferPreapproval, - ]]] { - type Task = ScheduledTaskTrigger.ReadyTask[ - AssignedContract[ - TransferPreapproval.ContractId, - TransferPreapproval, - ] - ] + ]]] + with IgnoredUnavailablePartiesGuard { private val store = svTaskContext.dsoStore - override def completeTaskAsDsoDelegate(co: Task, controller: String)(implicit + override def completeTaskAsDsoDelegate(task: Task, controller: String)(implicit tc: TraceContext ): Future[TaskOutcome] = + completeWithVettedAmuletVersion( + getStakeholders(task.work.payload).toSet, + Seq(task.work.contractId.contractId), + )(completeExpiryTaskAsDsoDelegate(task, controller)) + + private def completeExpiryTaskAsDsoDelegate( + task: Task, + controller: String, + )(implicit + tc: TraceContext + ): Future[TaskOutcome] = { for { dsoRules <- store.getDsoRules() cmd = dsoRules.exercise( _.exerciseDsoRules_ExpireTransferPreapproval( - co.work.contractId, + task.work.contractId, Optional.of(controller), ) ) @@ -59,6 +71,21 @@ class ExpireTransferPreapprovalsTrigger( .noDedup .yieldUnit() } yield TaskSuccess( - s"Archived expired TransferPreapproval with contractId ${co.work.contractId}" + s"Archived expired TransferPreapproval with contractId ${task.work.contractId}" ) + } +} + +object ExpireTransferPreapprovalsTrigger extends ContractStakeholders[TransferPreapproval] { + type Task = ScheduledTaskTrigger.ReadyTask[ + AssignedContract[ + TransferPreapproval.ContractId, + TransferPreapproval, + ] + ] + + override def informees(payload: TransferPreapproval): Seq[String] = + Seq(payload.provider, payload.receiver) + + override def dso(payload: TransferPreapproval): String = payload.dso } diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAmuletAllocationTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAmuletAllocationTrigger.scala index 88f8009773..038caae7ae 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAmuletAllocationTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAmuletAllocationTrigger.scala @@ -12,12 +12,13 @@ import io.opentelemetry.api.trace.Tracer import org.apache.pekko.stream.Materializer import scala.concurrent.{ExecutionContext, Future} -import ExpiredAmuletAllocationTrigger.* +import ExpiredAmuletAllocationTrigger.{Task, getStakeholders} import com.digitalasset.canton.util.MonadUtil import org.lfdecentralizedtrust.splice.environment.PackageIdResolver import org.lfdecentralizedtrust.splice.store.AppStoreWithIngestion.SpliceLedgerConnectionPriority +import org.lfdecentralizedtrust.splice.store.IgnoredPartiesStore import org.lfdecentralizedtrust.splice.sv.config.SvAppBackendConfig -import org.lfdecentralizedtrust.splice.sv.store.IgnoredPartiesStore +import org.lfdecentralizedtrust.splice.sv.util.ContractStakeholders import scala.jdk.CollectionConverters.* @@ -41,50 +42,38 @@ class ExpiredAmuletAllocationTrigger( splice.amuletallocation.AmuletAllocation.COMPANION, svTaskContext.vettingLookupService, PackageIdResolver.Package.SpliceAmulet, - allocation => - Seq( - allocation.allocation.transferLeg.sender, - allocation.allocation.settlement.executor, - svTaskContext.dsoStore.key.dsoParty.partyId.toProtoPrimitive, - ).map(PartyId.tryFromProtoPrimitive), + getStakeholders, ) with SvTaskBasedTrigger[Task] - with IgnoredAmuletVersionGuard { + with IgnoredUnavailablePartiesGuard { private val store = svTaskContext.dsoStore override def completeTaskAsDsoDelegate(task: Task, controller: String)(implicit tc: TraceContext ): Future[TaskOutcome] = { - val informees = task.work.expiredContracts.flatMap { contract => - val sender = PartyId.tryFromProtoPrimitive(contract.payload.allocation.transferLeg.sender) - val executor = PartyId.tryFromProtoPrimitive(contract.payload.allocation.settlement.executor) - Seq(sender, executor) - }.toSet - completeWithIgnoredAmuletVersionCheck( + completeUnlessAmuletVersionIgnored( task.work.vettedVersion.toString, - informees, - enableUnresponsivePartiesAutoIgnore = true, - )(completeExpiryTaskAsDsoDelegate(task, controller, informees)) + task.work.stakeholders, + ignoreUnresponsiveParties = true, + )(completeExpiryTaskAsDsoDelegate(task, controller)) } private def completeExpiryTaskAsDsoDelegate( task: Task, controller: String, - informees: Set[PartyId], )(implicit tc: TraceContext): Future[TaskOutcome] = { - val allParties = informees + store.key.dsoParty - + val stakeholders = task.work.stakeholders for { packageSupport <- svTaskContext.packageVersionSupport.supportsExpireAmuletAllocations( - allParties.toSeq, + stakeholders.toSeq, Seq(store.key.dsoParty), clock.now, ) res <- if (!packageSupport.supported) { logger.info( - s"Skipping expiry of ${task.work.expiredContracts.size} allocations because not all parties have vetted the required Amulet package version. Parties: ${allParties + s"Skipping expiry of ${task.work.expiredContracts.size} allocations because not all parties have vetted the required Amulet package version. Parties: ${stakeholders .mkString(", ")}" ) Future.successful( @@ -180,7 +169,8 @@ class ExpiredAmuletAllocationTrigger( } } -object ExpiredAmuletAllocationTrigger { +object ExpiredAmuletAllocationTrigger + extends ContractStakeholders[splice.amuletallocation.AmuletAllocation] { type Task = ScheduledTaskTrigger.ReadyTask[ BatchedMultiDomainExpiredContractTrigger.Batch[ @@ -188,4 +178,10 @@ object ExpiredAmuletAllocationTrigger { splice.amuletallocation.AmuletAllocation, ] ] + + override def informees(payload: splice.amuletallocation.AmuletAllocation): Seq[String] = + Seq(payload.allocation.transferLeg.sender, payload.allocation.settlement.executor) + + override def dso(payload: splice.amuletallocation.AmuletAllocation): String = + payload.allocation.transferLeg.instrumentId.admin } diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAmuletAllocationV2Trigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAmuletAllocationV2Trigger.scala index 43f68ffe8a..ed3b73c8a9 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAmuletAllocationV2Trigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAmuletAllocationV2Trigger.scala @@ -4,7 +4,6 @@ package org.lfdecentralizedtrust.splice.sv.automation.delegatebased import com.digitalasset.canton.time.Clock -import com.digitalasset.canton.topology.PartyId import com.digitalasset.canton.tracing.TraceContext import io.opentelemetry.api.trace.Tracer import org.apache.pekko.stream.Materializer @@ -13,14 +12,15 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice import org.lfdecentralizedtrust.splice.environment.PackageIdResolver import org.lfdecentralizedtrust.splice.store.AppStoreWithIngestion.SpliceLedgerConnectionPriority import org.lfdecentralizedtrust.splice.sv.config.SvAppBackendConfig -import org.lfdecentralizedtrust.splice.sv.store.IgnoredPartiesStore import org.lfdecentralizedtrust.splice.util.{ChoiceContextWithDisclosures, TokenStandardMetadata} +import org.lfdecentralizedtrust.splice.sv.util.ContractStakeholders import scala.concurrent.{ExecutionContext, Future} import scala.jdk.CollectionConverters.* import scala.jdk.OptionConverters.* import cats.implicits.* import org.lfdecentralizedtrust.splice.codegen.java.splice.api.token.metadatav1.anyvalue.AV_Bool +import org.lfdecentralizedtrust.splice.store.IgnoredPartiesStore class ExpiredAmuletAllocationV2Trigger( override protected val svConfig: SvAppBackendConfig, @@ -42,10 +42,10 @@ class ExpiredAmuletAllocationV2Trigger( splice.amuletallocationv2.AmuletAllocationV2.COMPANION, svTaskContext.vettingLookupService, PackageIdResolver.Package.SpliceAmulet, - ExpiredAmuletAllocationV2Trigger.allocationV2Stakeholders, + ExpiredAmuletAllocationV2Trigger.getStakeholders, ) with SvTaskBasedTrigger[ExpiredAmuletAllocationV2Trigger.Task] - with IgnoredAmuletVersionGuard { + with IgnoredUnavailablePartiesGuard { private val store = svTaskContext.dsoStore @@ -55,32 +55,28 @@ class ExpiredAmuletAllocationV2Trigger( )(implicit tc: TraceContext ): Future[TaskOutcome] = { - val expiredStakeholders = task.work.expiredContracts.flatMap { contract => - ExpiredAmuletAllocationV2Trigger.allocationV2Stakeholders(contract.payload) - }.toSet - completeWithIgnoredAmuletVersionCheck( + completeUnlessAmuletVersionIgnored( task.work.vettedVersion.toString, - expiredStakeholders, - enableUnresponsivePartiesAutoIgnore = true, - )(completeExpiryTaskAsDsoDelegate(task, controller, expiredStakeholders)) + task.work.stakeholders, + ignoreUnresponsiveParties = true, + )(completeExpiryTaskAsDsoDelegate(task, controller)) } private def completeExpiryTaskAsDsoDelegate( task: ExpiredAmuletAllocationV2Trigger.Task, controller: String, - informees: Set[PartyId], )(implicit tc: TraceContext): Future[TaskOutcome] = { - val allParties = informees + store.key.dsoParty - + val stakeholders = task.work.stakeholders + val informees = stakeholders - store.key.dsoParty for { packageSupport <- svTaskContext.packageVersionSupport.supportsAmuletAllocationV2( - allParties.toSeq, + stakeholders.toSeq, clock.now, ) res <- if (!packageSupport.supported) { logger.info( - s"Skipping expiry of ${task.work.expiredContracts.size} allocations because not all parties have vetted the required Amulet package version. Parties: ${allParties + s"Skipping expiry of ${task.work.expiredContracts.size} allocations because not all parties have vetted the required Amulet package version. Parties: ${stakeholders .mkString(", ")}" ) Future.successful( @@ -158,7 +154,8 @@ class ExpiredAmuletAllocationV2Trigger( } -object ExpiredAmuletAllocationV2Trigger { +object ExpiredAmuletAllocationV2Trigger + extends ContractStakeholders[splice.amuletallocationv2.AmuletAllocationV2] { type Task = ScheduledTaskTrigger.ReadyTask[ BatchedMultiDomainExpiredContractTrigger.Batch[ @@ -167,9 +164,9 @@ object ExpiredAmuletAllocationV2Trigger { ] ] - private def allocationV2Stakeholders(allocation: splice.amuletallocationv2.AmuletAllocationV2) = - (Seq( - allocation.allocation.admin - ) ++ allocation.allocation.authorizer.owner.toScala.toList ++ allocation.settlement.executors.asScala) - .map(PartyId.tryFromProtoPrimitive) + override def informees(payload: splice.amuletallocationv2.AmuletAllocationV2): Seq[String] = + payload.allocation.authorizer.owner.toScala.toList ++ payload.settlement.executors.asScala + + override def dso(payload: splice.amuletallocationv2.AmuletAllocationV2): String = + payload.allocation.admin } diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAmuletTransferInstructionTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAmuletTransferInstructionTrigger.scala index c90afbdcd0..d50197a9cd 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAmuletTransferInstructionTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAmuletTransferInstructionTrigger.scala @@ -12,12 +12,13 @@ import io.opentelemetry.api.trace.Tracer import org.apache.pekko.stream.Materializer import scala.concurrent.{ExecutionContext, Future} -import ExpiredAmuletTransferInstructionTrigger.* +import ExpiredAmuletTransferInstructionTrigger.{Task, getStakeholders} import com.digitalasset.canton.util.MonadUtil import org.lfdecentralizedtrust.splice.environment.PackageIdResolver import org.lfdecentralizedtrust.splice.store.AppStoreWithIngestion.SpliceLedgerConnectionPriority +import org.lfdecentralizedtrust.splice.store.IgnoredPartiesStore import org.lfdecentralizedtrust.splice.sv.config.SvAppBackendConfig -import org.lfdecentralizedtrust.splice.sv.store.IgnoredPartiesStore +import org.lfdecentralizedtrust.splice.sv.util.ContractStakeholders import scala.jdk.CollectionConverters.* @@ -41,50 +42,38 @@ class ExpiredAmuletTransferInstructionTrigger( splice.amulettransferinstruction.AmuletTransferInstruction.COMPANION, svTaskContext.vettingLookupService, PackageIdResolver.Package.SpliceAmulet, - instruction => - Seq( - instruction.transfer.sender, - instruction.transfer.receiver, - svTaskContext.dsoStore.key.dsoParty.partyId.toProtoPrimitive, - ).map(PartyId.tryFromProtoPrimitive), + getStakeholders, ) with SvTaskBasedTrigger[Task] - with IgnoredAmuletVersionGuard { + with IgnoredUnavailablePartiesGuard { private val store = svTaskContext.dsoStore override def completeTaskAsDsoDelegate(task: Task, controller: String)(implicit tc: TraceContext ): Future[TaskOutcome] = { - val informees = task.work.expiredContracts - .map(c => PartyId.tryFromProtoPrimitive(c.payload.transfer.sender)) - .toSet ++ task.work.expiredContracts - .map(c => PartyId.tryFromProtoPrimitive(c.payload.transfer.receiver)) - .toSet - completeWithIgnoredAmuletVersionCheck( + completeUnlessAmuletVersionIgnored( task.work.vettedVersion.toString, - informees, - enableUnresponsivePartiesAutoIgnore = true, - )(completeExpiryTaskAsDsoDelegate(task, controller, informees)) + task.work.stakeholders, + ignoreUnresponsiveParties = true, + )(completeExpiryTaskAsDsoDelegate(task, controller)) } private def completeExpiryTaskAsDsoDelegate( task: Task, controller: String, - informees: Set[PartyId], )(implicit tc: TraceContext): Future[TaskOutcome] = { - val allParties = informees + store.key.dsoParty - + val stakeholders = task.work.stakeholders for { packageSupport <- svTaskContext.packageVersionSupport.supportsExpireTransferInstructions( - allParties.toSeq, + stakeholders.toSeq, Seq(store.key.dsoParty), clock.now, ) res <- if (!packageSupport.supported) { logger.info( - s"Skipping expiry of ${task.work.expiredContracts.size} transfer instructions because not all parties have vetted the required Amulet package version. Parties: ${allParties + s"Skipping expiry of ${task.work.expiredContracts.size} transfer instructions because not all parties have vetted the required Amulet package version. Parties: ${stakeholders .mkString(", ")}" ) Future.successful( @@ -165,7 +154,8 @@ class ExpiredAmuletTransferInstructionTrigger( } } -object ExpiredAmuletTransferInstructionTrigger { +object ExpiredAmuletTransferInstructionTrigger + extends ContractStakeholders[splice.amulettransferinstruction.AmuletTransferInstruction] { type Task = ScheduledTaskTrigger.ReadyTask[ BatchedMultiDomainExpiredContractTrigger.Batch[ @@ -173,4 +163,12 @@ object ExpiredAmuletTransferInstructionTrigger { splice.amulettransferinstruction.AmuletTransferInstruction, ] ] + + override def informees( + payload: splice.amulettransferinstruction.AmuletTransferInstruction + ): Seq[String] = Seq(payload.transfer.sender, payload.transfer.receiver) + + override def dso( + payload: splice.amulettransferinstruction.AmuletTransferInstruction + ): String = payload.transfer.instrumentId.admin } diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAmuletTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAmuletTrigger.scala index a62847bd17..32a1f131de 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAmuletTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAmuletTrigger.scala @@ -5,17 +5,17 @@ package org.lfdecentralizedtrust.splice.sv.automation.delegatebased import org.lfdecentralizedtrust.splice.automation.* import org.lfdecentralizedtrust.splice.codegen.java.splice -import com.digitalasset.canton.topology.PartyId import com.digitalasset.canton.tracing.TraceContext import io.opentelemetry.api.trace.Tracer import org.apache.pekko.stream.Materializer import scala.concurrent.{ExecutionContext, Future} -import ExpiredAmuletTrigger.* import org.lfdecentralizedtrust.splice.environment.PackageIdResolver import org.lfdecentralizedtrust.splice.store.AppStoreWithIngestion.SpliceLedgerConnectionPriority import org.lfdecentralizedtrust.splice.sv.config.SvAppBackendConfig -import org.lfdecentralizedtrust.splice.sv.store.IgnoredPartiesStore +import org.lfdecentralizedtrust.splice.sv.util.ContractStakeholders +import ExpiredAmuletTrigger.{Task, getStakeholders} +import org.lfdecentralizedtrust.splice.store.IgnoredPartiesStore import java.util.Optional import scala.jdk.CollectionConverters.* @@ -40,36 +40,33 @@ class ExpiredAmuletTrigger( splice.amulet.Amulet.COMPANION, svTaskContext.vettingLookupService, PackageIdResolver.Package.SpliceAmulet, - c => Seq(c.dso, c.owner).map(PartyId.tryFromProtoPrimitive(_)), + getStakeholders, ) with SvTaskBasedTrigger[Task] - with IgnoredAmuletVersionGuard { + with IgnoredUnavailablePartiesGuard { private val store = svTaskContext.dsoStore override def completeTaskAsDsoDelegate(task: Task, controller: String)(implicit tc: TraceContext ): Future[TaskOutcome] = { - val informees = - task.work.expiredContracts.map(c => PartyId.tryFromProtoPrimitive(c.payload.owner)).toSet - completeWithIgnoredAmuletVersionCheck( + completeUnlessAmuletVersionIgnored( task.work.vettedVersion.toString, - informees, - enableUnresponsivePartiesAutoIgnore = true, - )(completeExpiryTaskAsDsoDelegate(task, controller, informees)) + task.work.stakeholders, + ignoreUnresponsiveParties = true, + )(completeExpiryTaskAsDsoDelegate(task, controller)) } private def completeExpiryTaskAsDsoDelegate( task: Task, controller: String, - informees: Set[PartyId], )(implicit tc: TraceContext ): Future[TaskOutcome] = { - val allParties = informees + store.key.dsoParty + val stakeholders = task.work.stakeholders for { dsoRules <- store.getDsoRules() supports24hSubmissionDelay <- svTaskContext.packageVersionSupport.supports24hSubmissionDelay( - allParties.toSeq, + stakeholders.toSeq, Seq(store.key.dsoParty), context.clock.now, ) @@ -131,7 +128,7 @@ class ExpiredAmuletTrigger( } } -object ExpiredAmuletTrigger { +object ExpiredAmuletTrigger extends ContractStakeholders[splice.amulet.Amulet] { type Task = ScheduledTaskTrigger.ReadyTask[ BatchedMultiDomainExpiredContractTrigger.Batch[ @@ -139,4 +136,8 @@ object ExpiredAmuletTrigger { splice.amulet.Amulet, ] ] + + override def informees(payload: splice.amulet.Amulet): Seq[String] = Seq(payload.owner) + + override def dso(payload: splice.amulet.Amulet): String = payload.dso } diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAnsEntryTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAnsEntryTrigger.scala index 5b60065613..3cb66cc1c1 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAnsEntryTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAnsEntryTrigger.scala @@ -11,13 +11,19 @@ import com.digitalasset.canton.tracing.TraceContext import io.opentelemetry.api.trace.Tracer import org.apache.pekko.stream.Materializer import org.lfdecentralizedtrust.splice.store.AppStoreWithIngestion.SpliceLedgerConnectionPriority +import org.lfdecentralizedtrust.splice.sv.config.SvAppBackendConfig +import org.lfdecentralizedtrust.splice.sv.util.ContractStakeholders import java.util.Optional import scala.concurrent.{ExecutionContext, Future} +import ExpiredAnsEntryTrigger.{Task, getStakeholders} +import org.lfdecentralizedtrust.splice.store.IgnoredPartiesStore class ExpiredAnsEntryTrigger( override protected val context: TriggerContext, override protected val svTaskContext: SvTaskBasedTrigger.Context, + override protected val svConfig: SvAppBackendConfig, + override protected val ignoredPartiesStore: IgnoredPartiesStore, )(implicit override val ec: ExecutionContext, mat: Materializer, @@ -27,30 +33,36 @@ class ExpiredAnsEntryTrigger( splice.ans.AnsEntry, ]( svTaskContext.dsoStore.multiDomainAcsStore, - svTaskContext.dsoStore.listExpiredAnsEntries, + svTaskContext.dsoStore.listExpiredAnsEntries(Some(ignoredPartiesStore)), splice.ans.AnsEntry.COMPANION, ) with SvTaskBasedTrigger[ScheduledTaskTrigger.ReadyTask[AssignedContract[ splice.ans.AnsEntry.ContractId, splice.ans.AnsEntry, - ]]] { - type Task = ScheduledTaskTrigger.ReadyTask[ - AssignedContract[ - splice.ans.AnsEntry.ContractId, - splice.ans.AnsEntry, - ] - ] + ]]] + with IgnoredUnavailablePartiesGuard { private val store = svTaskContext.dsoStore - override def completeTaskAsDsoDelegate(co: Task, controller: String)(implicit + override def completeTaskAsDsoDelegate(task: Task, controller: String)(implicit + tc: TraceContext + ): Future[TaskOutcome] = + completeWithVettedAmuletVersion( + getStakeholders(task.work.payload).toSet, + Seq(task.work.contractId.contractId), + )(completeExpiryTaskAsDsoDelegate(task, controller)) + + private def completeExpiryTaskAsDsoDelegate( + task: Task, + controller: String, + )(implicit tc: TraceContext ): Future[TaskOutcome] = for { dsoRules <- store.getDsoRules() cmd = dsoRules.exercise( _.exerciseDsoRules_ExpireAnsEntry( - co.work.contractId, + task.work.contractId, new AnsEntry_Expire(store.key.dsoParty.toProtoPrimitive), Optional.of(controller), ) @@ -62,3 +74,16 @@ class ExpiredAnsEntryTrigger( .yieldUnit() } yield TaskSuccess("archived expired ANS entry") } + +object ExpiredAnsEntryTrigger extends ContractStakeholders[splice.ans.AnsEntry] { + type Task = ScheduledTaskTrigger.ReadyTask[ + AssignedContract[ + splice.ans.AnsEntry.ContractId, + splice.ans.AnsEntry, + ] + ] + + override def informees(payload: splice.ans.AnsEntry): Seq[String] = Seq(payload.user) + + override def dso(payload: splice.ans.AnsEntry): String = payload.dso +} diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAnsSubscriptionTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAnsSubscriptionTrigger.scala index 8f8b673493..8b6e009a08 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAnsSubscriptionTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredAnsSubscriptionTrigger.scala @@ -16,32 +16,46 @@ import io.opentelemetry.api.trace.Tracer import org.lfdecentralizedtrust.splice.codegen.java.splice.ans as ansCodegen import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.subscriptions as subsCodegen import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.subscriptions.SubscriptionIdleState_ExpireSubscription -import org.lfdecentralizedtrust.splice.store.PageLimit +import org.lfdecentralizedtrust.splice.store.{IgnoredPartiesStore, PageLimit} +import org.lfdecentralizedtrust.splice.sv.config.SvAppBackendConfig import org.lfdecentralizedtrust.splice.sv.store.SvDsoStore +import org.lfdecentralizedtrust.splice.sv.util.ContractStakeholders import org.apache.pekko.stream.Materializer import org.lfdecentralizedtrust.splice.store.AppStoreWithIngestion.SpliceLedgerConnectionPriority import java.util.Optional import scala.concurrent.{ExecutionContext, Future} +import ExpiredAnsSubscriptionTrigger.{Task, getStakeholders} class ExpiredAnsSubscriptionTrigger( override protected val context: TriggerContext, override protected val svTaskContext: SvTaskBasedTrigger.Context, + override protected val svConfig: SvAppBackendConfig, + override protected val ignoredPartiesStore: IgnoredPartiesStore, )(implicit - ec: ExecutionContext, + override val ec: ExecutionContext, mat: Materializer, tracer: Tracer, ) extends ScheduledTaskTrigger[SvDsoStore.IdleAnsSubscription] - with SvTaskBasedTrigger[ScheduledTaskTrigger.ReadyTask[SvDsoStore.IdleAnsSubscription]] { + with SvTaskBasedTrigger[ScheduledTaskTrigger.ReadyTask[SvDsoStore.IdleAnsSubscription]] + with IgnoredUnavailablePartiesGuard { private val store = svTaskContext.dsoStore override protected def listReadyTasks(now: CantonTimestamp, limit: Int)(implicit tc: TraceContext ): Future[Seq[SvDsoStore.IdleAnsSubscription]] = - store.listExpiredAnsSubscriptions(now, PageLimit.tryCreate(limit)) + store.listExpiredAnsSubscriptions(now, PageLimit.tryCreate(limit), Some(ignoredPartiesStore)) - override protected def completeTaskAsDsoDelegate( - task: ScheduledTaskTrigger.ReadyTask[SvDsoStore.IdleAnsSubscription], + override protected def completeTaskAsDsoDelegate(task: Task, controller: String)(implicit + tc: TraceContext + ): Future[TaskOutcome] = + completeWithVettedAmuletVersion( + getStakeholders(task.work.state.payload).toSet, + Seq(task.work.state.contractId.contractId), + )(completeExpiryTaskAsDsoDelegate(task, controller)) + + private def completeExpiryTaskAsDsoDelegate( + task: Task, controller: String, )(implicit tc: TraceContext): Future[TaskOutcome] = for { dsoRules <- store.getDsoRules() @@ -67,7 +81,7 @@ class ExpiredAnsSubscriptionTrigger( } yield result override protected def isStaleTask( - task: ScheduledTaskTrigger.ReadyTask[SvDsoStore.IdleAnsSubscription] + task: Task )(implicit tc: TraceContext): Future[Boolean] = (for { _ <- OptionT( @@ -86,3 +100,18 @@ class ExpiredAnsSubscriptionTrigger( ) } yield ()).isEmpty } + +object ExpiredAnsSubscriptionTrigger + extends ContractStakeholders[subsCodegen.SubscriptionIdleState] { + type Task = ScheduledTaskTrigger.ReadyTask[SvDsoStore.IdleAnsSubscription] + + override def informees(payload: subsCodegen.SubscriptionIdleState): Seq[String] = + Seq( + payload.subscriptionData.sender, + payload.subscriptionData.receiver, + payload.subscriptionData.provider, + ) + + override def dso(payload: subsCodegen.SubscriptionIdleState): String = + payload.subscriptionData.dso +} diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredLockedAmuletTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredLockedAmuletTrigger.scala index 5affd08b9a..c01e110be4 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredLockedAmuletTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ExpiredLockedAmuletTrigger.scala @@ -6,16 +6,16 @@ package org.lfdecentralizedtrust.splice.sv.automation.delegatebased import org.lfdecentralizedtrust.splice.automation.* import org.lfdecentralizedtrust.splice.codegen.java.splice import org.lfdecentralizedtrust.splice.environment.PackageIdResolver -import com.digitalasset.canton.topology.PartyId import com.digitalasset.canton.tracing.TraceContext import io.opentelemetry.api.trace.Tracer import org.apache.pekko.stream.Materializer import scala.concurrent.{ExecutionContext, Future} -import ExpiredLockedAmuletTrigger.* +import ExpiredLockedAmuletTrigger.{Task, getStakeholders} import org.lfdecentralizedtrust.splice.store.AppStoreWithIngestion.SpliceLedgerConnectionPriority +import org.lfdecentralizedtrust.splice.store.IgnoredPartiesStore import org.lfdecentralizedtrust.splice.sv.config.SvAppBackendConfig -import org.lfdecentralizedtrust.splice.sv.store.IgnoredPartiesStore +import org.lfdecentralizedtrust.splice.sv.util.ContractStakeholders import java.util.Optional import scala.jdk.CollectionConverters.* @@ -40,41 +40,31 @@ class ExpiredLockedAmuletTrigger( splice.amulet.LockedAmulet.COMPANION, svTaskContext.vettingLookupService, PackageIdResolver.Package.SpliceAmulet, - c => - (Seq(c.amulet.dso, c.amulet.owner) ++ c.lock.holders.asScala) - .map(PartyId.tryFromProtoPrimitive(_)), + getStakeholders, ) with SvTaskBasedTrigger[Task] - with IgnoredAmuletVersionGuard { + with IgnoredUnavailablePartiesGuard { private val store = svTaskContext.dsoStore override def completeTaskAsDsoDelegate(task: Task, controller: String)(implicit tc: TraceContext ): Future[TaskOutcome] = { - val informees = task.work.expiredContracts - .flatMap(c => - PartyId.tryFromProtoPrimitive( - c.payload.amulet.owner - ) +: c.payload.lock.holders.asScala.toSeq.map(PartyId.tryFromProtoPrimitive(_)) - ) - .toSet - completeWithIgnoredAmuletVersionCheck( + completeUnlessAmuletVersionIgnored( task.work.vettedVersion.toString, - informees, - enableUnresponsivePartiesAutoIgnore = true, - )(completeExpiryTaskAsDsoDelegate(task, controller, informees)) + task.work.stakeholders, + ignoreUnresponsiveParties = true, + )(completeExpiryTaskAsDsoDelegate(task, controller)) } private def completeExpiryTaskAsDsoDelegate( task: Task, controller: String, - informees: Set[PartyId], )(implicit tc: TraceContext): Future[TaskOutcome] = { - val allParties = informees + store.key.dsoParty + val stakeholders = task.work.stakeholders for { dsoRules <- store.getDsoRules() supports24hSubmissionDelay <- svTaskContext.packageVersionSupport.supports24hSubmissionDelay( - allParties.toSeq, + stakeholders.toSeq, Seq(store.key.dsoParty), context.clock.now, ) @@ -136,7 +126,7 @@ class ExpiredLockedAmuletTrigger( } } -object ExpiredLockedAmuletTrigger { +object ExpiredLockedAmuletTrigger extends ContractStakeholders[splice.amulet.LockedAmulet] { type Task = ScheduledTaskTrigger.ReadyTask[ BatchedMultiDomainExpiredContractTrigger.Batch[ @@ -144,4 +134,9 @@ object ExpiredLockedAmuletTrigger { splice.amulet.LockedAmulet, ] ] + + override def informees(payload: splice.amulet.LockedAmulet): Seq[String] = + Seq(payload.amulet.owner) ++ payload.lock.holders.asScala + + override def dso(payload: splice.amulet.LockedAmulet): String = payload.amulet.dso } diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/FeaturedAppActivityMarkerTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/FeaturedAppActivityMarkerTrigger.scala index 12f7940b6f..ddcbfa2bff 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/FeaturedAppActivityMarkerTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/FeaturedAppActivityMarkerTrigger.scala @@ -25,10 +25,17 @@ import com.digitalasset.canton.util.ShowUtil.* import scala.concurrent.{ExecutionContext, Future} import scala.jdk.CollectionConverters.* import scala.jdk.OptionConverters.* -import FeaturedAppActivityMarkerTrigger.{CrossVersionBatch, Task} +import FeaturedAppActivityMarkerTrigger.{ + CrossVersionBatch, + Task, + getInformeesFromContracts, + getStakeholders, +} +import com.digitalasset.canton.discard.Implicits.DiscardOps import org.lfdecentralizedtrust.splice.store.AppStoreWithIngestion.SpliceLedgerConnectionPriority +import org.lfdecentralizedtrust.splice.store.IgnoredPartiesStore import org.lfdecentralizedtrust.splice.sv.config.SvAppBackendConfig -import org.lfdecentralizedtrust.splice.sv.store.IgnoredPartiesStore +import org.lfdecentralizedtrust.splice.sv.util.ContractStakeholders import java.util.Optional import scala.util.Random @@ -45,7 +52,7 @@ class FeaturedAppActivityMarkerTrigger( // This is a polling trigger as we usually expect to be able to batch together the conversion ) extends PollingParallelTaskExecutionTrigger[Task] with SvTaskBasedTrigger[Task] - with IgnoredAmuletVersionGuard { + with IgnoredUnavailablePartiesGuard { private val rng: Random = new Random() @@ -83,32 +90,35 @@ class FeaturedAppActivityMarkerTrigger( def splitBatchByVettingState( batch: CrossVersionBatch - )(implicit tc: TraceContext): Future[Seq[Task]] = + )(implicit tc: TraceContext): Future[Seq[Task]] = { svTaskContext.vettingLookupService .splitBatch( PackageIdResolver.Package.SpliceAmulet, batch.markers, batchSize, - )(c => - Seq(c.payload.provider, c.payload.beneficiary, c.payload.dso) - .map(PartyId.tryFromProtoPrimitive(_)) - ) + )(c => getStakeholders(c.payload)) .map { _.toSeq.flatMap { case (Some(version), markerBatches) => - markerBatches.map( + markerBatches.map { markers => Task( batch.retrievalKind, - _, + markers, version, + getInformeesFromContracts(markers), ) - ) + } case (None, markers) => - logger.warn(show"No vetted amulet version for $markers") + ignorePartiesWithoutVettedAmulet( + getInformeesFromContracts(markers.flatten), + markers.flatten.map(_.contractId.contractId), + logAsWarning = true, + ).discard Seq.empty } } + } private def retrieveBatchesBySvIndex( dsoRules: dsorules.DsoRules @@ -195,34 +205,29 @@ class FeaturedAppActivityMarkerTrigger( override def completeTaskAsDsoDelegate(task: Task, controller: String)(implicit tc: TraceContext ): Future[TaskOutcome] = { - val informees = task.markers - .flatMap(m => Seq(m.payload.provider, m.payload.beneficiary)) - .map(PartyId.tryFromProtoPrimitive) - .toSet - completeWithIgnoredAmuletVersionCheck( + completeUnlessAmuletVersionIgnored( task.vettedAmuletVersion.toString, - informees, + task.informees, // ignoring a party would mean their featured app activity markers do not get converted into rewards - enableUnresponsivePartiesAutoIgnore = false, - )(completeExpiryTaskAsDsoDelegate(task, controller, informees)) + ignoreUnresponsiveParties = false, + )(completeExpiryTaskAsDsoDelegate(task, controller)) } private def completeExpiryTaskAsDsoDelegate( task: Task, controller: String, - informees: Set[PartyId], )(implicit tc: TraceContext): Future[TaskOutcome] = { for { dsoRules <- store.getDsoRules() amuletRules <- store.getAmuletRules() now = context.clock.now openMiningRound <- store.getLatestUsableOpenMiningRound(now) - allParties = informees + PartyId.tryFromProtoPrimitive(dsoRules.payload.dso) + stakeholders = task.informees + store.key.dsoParty supportsConvertFeaturedAppActivityMarkerObservers <- if (svConfig.convertFeaturedAppActivityMarkerObservers) { svTaskContext.packageVersionSupport .supportsConvertFeaturedAppActivityMarkerObservers( - allParties.toSeq, + stakeholders.toSeq, context.clock.now, ) .map(_.supported) @@ -240,7 +245,7 @@ class FeaturedAppActivityMarkerTrigger( Option .when( supportsConvertFeaturedAppActivityMarkerObservers - )(allParties.toSeq.map(_.toProtoPrimitive).asJava) + )(stakeholders.toSeq.map(_.toProtoPrimitive).asJava) .toJava, ), Optional.of(controller), @@ -270,7 +275,8 @@ class FeaturedAppActivityMarkerTrigger( } yield markers.exists(_.isEmpty) } -object FeaturedAppActivityMarkerTrigger { +object FeaturedAppActivityMarkerTrigger + extends ContractStakeholders[amulet.FeaturedAppActivityMarker] { final case class CrossVersionBatch( retrievalKind: String, markers: Seq[ @@ -291,6 +297,7 @@ object FeaturedAppActivityMarkerTrigger { Contract[amulet.FeaturedAppActivityMarker.ContractId, amulet.FeaturedAppActivityMarker] ], vettedAmuletVersion: PackageVersion, + informees: Set[PartyId], ) extends PrettyPrinting { override def pretty: Pretty[this.type] = prettyOfClass( @@ -298,6 +305,12 @@ object FeaturedAppActivityMarkerTrigger { param("numMarkers", _.markers.size), param("vettedAmuletVersion", _.vettedAmuletVersion), param("markerCids", _.markers.map(_.contractId.contractId.unquoted)), + param("informees", _.informees), ) } + + override def informees(payload: amulet.FeaturedAppActivityMarker): Seq[String] = + Seq(payload.provider, payload.beneficiary) + + override def dso(payload: amulet.FeaturedAppActivityMarker): String = payload.dso } diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/IgnoredAmuletVersionGuard.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/IgnoredAmuletVersionGuard.scala deleted file mode 100644 index 3e7c3ede52..0000000000 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/IgnoredAmuletVersionGuard.scala +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package org.lfdecentralizedtrust.splice.sv.automation.delegatebased - -import com.digitalasset.base.error.utils.ErrorDetails -import com.digitalasset.canton.topology.PartyId -import io.grpc.StatusRuntimeException -import io.grpc.protobuf.StatusProto -import org.lfdecentralizedtrust.splice.automation.{TaskOutcome, TaskSuccess} -import org.lfdecentralizedtrust.splice.sv.config.SvAppBackendConfig -import org.lfdecentralizedtrust.splice.sv.store.IgnoredPartiesStore -import org.lfdecentralizedtrust.splice.util.UnresponsiveParties - -import scala.concurrent.{ExecutionContext, Future} - -trait IgnoredAmuletVersionGuard { - protected def svConfig: SvAppBackendConfig - protected def ignoredPartiesStore: IgnoredPartiesStore - protected def svTaskContext: SvTaskBasedTrigger.Context - - protected def completeWithIgnoredAmuletVersionCheck( - vettedVersion: String, - expiredOwners: Set[PartyId], - enableUnresponsivePartiesAutoIgnore: Boolean, - )( - fallback: => Future[TaskOutcome] - )(implicit ec: ExecutionContext): Future[TaskOutcome] = { - if ( - svConfig.allIgnoredAmuletVersions.contains(vettedVersion) && - svConfig.parameters.enabledFeatures.ignorePartyIdWithIgnoredAmulet - ) { - ignoredPartiesStore.addAll(expiredOwners) - Future.successful( - TaskSuccess( - s"Skipped batch with ignored version $vettedVersion: added ${expiredOwners.size} parties to ignore list: $expiredOwners" - ) - ) - } else { - val enableNaiveUnresponsivePartiesAutoIgnore = - svConfig.parameters.enabledFeatures.naiveUnresponsivePartiesAutoIgnore && enableUnresponsivePartiesAutoIgnore - fallback.recoverWith { - case ex: StatusRuntimeException if enableNaiveUnresponsivePartiesAutoIgnore => - extractUnresponsiveParties(ex) match { - case parties if parties.nonEmpty => - val partiesToIgnore = parties - svTaskContext.dsoStore.key.dsoParty.partyId - ignoredPartiesStore.addAll(partiesToIgnore) - Future.successful( - TaskSuccess( - s"Batch failed due to unresponsive parties, added ${partiesToIgnore.size} to ignore list: $partiesToIgnore" - ) - ) - case _ => Future.failed(ex) - } - } - } - } - - private def extractUnresponsiveParties(ex: StatusRuntimeException): Set[PartyId] = { - val statusProto = StatusProto.fromThrowable(ex) - val errorDetails = ErrorDetails.from(statusProto) - errorDetails - .collectFirst { case UnresponsiveParties(parties) => parties } - .getOrElse(Set.empty) - } - -} diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/IgnoredUnavailablePartiesGuard.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/IgnoredUnavailablePartiesGuard.scala new file mode 100644 index 0000000000..b6f001e9b0 --- /dev/null +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/IgnoredUnavailablePartiesGuard.scala @@ -0,0 +1,110 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.sv.automation.delegatebased + +import com.digitalasset.base.error.utils.ErrorDetails +import com.digitalasset.canton.logging.NamedLogging +import com.digitalasset.canton.topology.PartyId +import com.digitalasset.canton.tracing.TraceContext +import io.grpc.StatusRuntimeException +import io.grpc.protobuf.StatusProto +import org.lfdecentralizedtrust.splice.automation.{TaskOutcome, TaskSuccess} +import org.lfdecentralizedtrust.splice.store.IgnoredPartiesStore +import org.lfdecentralizedtrust.splice.sv.config.SvAppBackendConfig +import org.lfdecentralizedtrust.splice.util.UnresponsiveParties +import org.lfdecentralizedtrust.splice.environment.PackageIdResolver +import scala.concurrent.{ExecutionContext, Future} + +trait IgnoredUnavailablePartiesGuard extends NamedLogging { + protected def svConfig: SvAppBackendConfig + protected def ignoredPartiesStore: IgnoredPartiesStore + protected def svTaskContext: SvTaskBasedTrigger.Context + + protected def completeUnlessAmuletVersionIgnored( + vettedVersion: String, + stakeholders: Set[PartyId], + ignoreUnresponsiveParties: Boolean, + )(task: => Future[TaskOutcome])(implicit ec: ExecutionContext): Future[TaskOutcome] = + if ( + svConfig.allIgnoredAmuletVersions.contains(vettedVersion) && + svConfig.parameters.enabledFeatures.ignorePartyIdWithIgnoredAmulet + ) { + val toIgnore = withoutDsoParty(stakeholders) + ignoredPartiesStore.addAll(toIgnore) + Future.successful( + TaskSuccess( + s"Skipped batch with ignored version $vettedVersion: added ${toIgnore.size} parties to ignore list: $toIgnore" + ) + ) + } else { + task.recoverWith(recoverUnresponsiveParties(ignoreUnresponsiveParties)) + } + + protected def completeWithVettedAmuletVersion( + stakeholders: Set[PartyId], + contractIds: Seq[String], + ignoreUnresponsiveParties: Boolean = true, + )(task: => Future[TaskOutcome])(implicit + ec: ExecutionContext, + tc: TraceContext, + ): Future[TaskOutcome] = + svTaskContext.vettingLookupService + .lookupVettingState(stakeholders.toSeq, PackageIdResolver.Package.SpliceAmulet) + .flatMap { + case Some(vettedVersion) => + completeUnlessAmuletVersionIgnored( + vettedVersion.toString, + stakeholders, + ignoreUnresponsiveParties, + )(task) + case None => + Future.successful( + TaskSuccess(ignorePartiesWithoutVettedAmulet(stakeholders, contractIds)) + ) + } + + protected def ignorePartiesWithoutVettedAmulet( + informees: Set[PartyId], + contractIds: Seq[String], + logAsWarning: Boolean = false, + )(implicit tc: TraceContext): String = { + val toIgnore = withoutDsoParty(informees) + ignoredPartiesStore.addAll(toIgnore) + val msg = + s"No vetted Amulet version for $contractIds; ignoring ${toIgnore.size} parties: $toIgnore" + if (logAsWarning) logger.warn(msg) + msg + } + + private def recoverUnresponsiveParties( + enabled: Boolean + ): PartialFunction[Throwable, Future[TaskOutcome]] = { + case ex: StatusRuntimeException + if enabled && svConfig.parameters.enabledFeatures.naiveUnresponsivePartiesAutoIgnore => + val toIgnore = withoutDsoParty(extractUnresponsiveParties(ex)) + if (toIgnore.isEmpty) { + Future.failed(ex) + } else { + ignoredPartiesStore.addAll(toIgnore) + Future.successful( + TaskSuccess( + s"Batch failed due to unresponsive parties, added ${toIgnore.size} to ignore list: $toIgnore" + ) + ) + } + } + + // never ignore the DSO party itself: it is a stakeholder on every DSO contract + private def withoutDsoParty(parties: Set[PartyId]): Set[PartyId] = + parties - svTaskContext.dsoStore.key.dsoParty + + private def extractUnresponsiveParties(ex: StatusRuntimeException): Set[PartyId] = { + val statusProto = StatusProto.fromThrowable(ex) + val errorDetails = ErrorDetails.from(statusProto) + errorDetails + .collectFirst { case UnresponsiveParties(parties) => parties } + .getOrElse(Set.empty) + } + +} diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala index 67cb4bd29e..67131b8551 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/ProcessRewardsTrigger.scala @@ -20,6 +20,8 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.rewardaccounti BatchOfBatches, BatchOfMintingAllowances, } +import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletrules.AmuletRules +import org.lfdecentralizedtrust.splice.environment.PackageIdResolver import org.lfdecentralizedtrust.splice.http.v0.definitions.{ GetRewardAccountingBatchResponse, RewardAccountingMintingAllowance, @@ -27,12 +29,13 @@ import org.lfdecentralizedtrust.splice.http.v0.definitions.{ import org.lfdecentralizedtrust.splice.scan.admin.api.client.{BftScanConnection, ScanConnection} import org.lfdecentralizedtrust.splice.store.AppStoreWithIngestion.SpliceLedgerConnectionPriority import org.lfdecentralizedtrust.splice.store.PageLimit -import org.lfdecentralizedtrust.splice.util.AssignedContract +import org.lfdecentralizedtrust.splice.util.{AmuletConfigSchedule, AssignedContract, Contract} import org.lfdecentralizedtrust.splice.util.PrettyInstances.* import com.daml.metrics.api.{MetricInfo, MetricName, MetricsContext} import com.daml.metrics.api.MetricsContext.Implicits.empty import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} import com.digitalasset.canton.tracing.TraceContext +import com.digitalasset.daml.lf.language.Ast import io.grpc.Status import io.opentelemetry.api.trace.Tracer import org.lfdecentralizedtrust.splice.codegen.java.da.set.types.Set as DamlSet @@ -82,11 +85,16 @@ private[delegatebased] abstract class ProcessRewardsTriggerBase( val batchHash = processRewards.payload.batchHash.value val batchF = fetchBatch(round, batchHash) val dsoRulesF = store.getDsoRules() + val amuletRulesF = store.getAmuletRules() for { batch <- batchF dsoRules <- dsoRulesF + amuletRules <- amuletRulesF damlBatch = convertBatch(batch) - providersWithWrongVettingState <- determineProvidersWithWrongVettingState(batch) + providersWithWrongVettingState <- determineProvidersWithWrongVettingState( + batch, + amuletRules, + ) choiceArg = new ProcessRewardsV2_ProcessBatch( damlBatch, providersWithWrongVettingState, @@ -149,7 +157,8 @@ private[delegatebased] abstract class ProcessRewardsTriggerBase( } private def determineProvidersWithWrongVettingState( - batch: GetRewardAccountingBatchResponse + batch: GetRewardAccountingBatchResponse, + amuletRules: Contract[AmuletRules.ContractId, AmuletRules], )(implicit tc: TraceContext): Future[DamlSet[String]] = { val providers = batch match { case GetRewardAccountingBatchResponse.members.RewardAccountingBatchOfMintingAllowances( @@ -160,15 +169,42 @@ private[delegatebased] abstract class ProcessRewardsTriggerBase( Vector.empty[String] } val now = context.clock.now + + // Checking for vetting state on the active amulet version is conservative, + // but it ensures that we don't hit issues where the common version vetted by + // all providers in a batch is below the version where V2 was introduced. (see #6372) + // + // Being conservative here is OK, as this keeps our vetting state checking + // simple, while avoiding potential issues in the submission of ProcessBatch. + // And also because the UnhideRewardCouponV2Trigger would make the coupons + // visible based on the vetting state of each party. + // In practice we expect most providers to have vetted the active amulet version. + val activeAmuletVersionMetadata = Ast.PackageMetadata( + PackageIdResolver.Package.SpliceAmulet.packageName, + PackageIdResolver.readPackageVersion( + AmuletConfigSchedule(amuletRules).getConfigAsOf(now).packageConfig, + PackageIdResolver.Package.SpliceAmulet, + ), + None, + ) Future .traverse(providers) { provider => val partyId = PartyId.tryFromProtoPrimitive(provider) svTaskContext.packageVersionSupport - .supportsTrafficBasedAppRewards(Seq(partyId), now) - .map(support => provider -> support.supported) + .isPackageSupported( + Seq(PackageIdResolver.Package.SpliceAmulet -> Seq(store.key.dsoParty, partyId)), + now, + activeAmuletVersionMetadata, + ) + .map(supported => provider -> supported.supported) } .map { supportByProvider => val withWrongVettingState = supportByProvider.collect { case (provider, false) => provider } + if (withWrongVettingState.nonEmpty) { + logger.info( + s"Providers with wrong vetting state for batch: ${withWrongVettingState.mkString(", ")}" + ) + } damlSetOf(withWrongVettingState) } } diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/SvTaskBasedTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/SvTaskBasedTrigger.scala index 54d6969fc5..0f42533995 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/SvTaskBasedTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/delegatebased/SvTaskBasedTrigger.scala @@ -46,19 +46,8 @@ trait SvTaskBasedTrigger[T <: PrettyPrinting] { )(implicit tc: TraceContext): Future[TaskOutcome] = { for { dsoRules <- store.getDsoRules() - sameEpoch = dsoRules.payload.epoch == svTaskContext.epoch svParty = store.key.svParty.toProtoPrimitive - result <- - if (sameEpoch) { - completeTaskAsAnySv(task, svParty, dsoRules) - } else { - // TODO(DACH-NY/canton-network-internal#495) Could this be busy-looping as well, if we are a polling trigger? - Future.successful( - TaskSuccess( - s"Skipping because current epoch ${dsoRules.payload.epoch} is not the same as trigger registration epoch ${svTaskContext.epoch}" - ) - ) - } + result <- completeTaskAsAnySv(task, svParty, dsoRules) } yield result } @@ -130,7 +119,6 @@ object SvTaskBasedTrigger { case class Context( dsoStore: SvDsoStore, connection: SpliceLedgerConnectionPriority => SpliceLedgerConnection, - epoch: Long, delegatelessAutomationExpectedTaskDuration: Long, delegatelessAutomationExpiredRewardCouponBatchSize: Int, delegatelessAutomationExpiredRewardCouponNumBatches: Int, diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/ReconcileDynamicSynchronizerParametersTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/ReconcileDynamicSynchronizerParametersTrigger.scala index f4a0cbc02a..bab6b0900c 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/ReconcileDynamicSynchronizerParametersTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/ReconcileDynamicSynchronizerParametersTrigger.scala @@ -196,6 +196,8 @@ class ReconcileDynamicSynchronizerParametersTrigger( maxBaseTrafficAccumulationDuration = PositiveFiniteDuration.tryOfSeconds( domainFeesConfig.baseRateTrafficLimits.burstWindow.microseconds / 1000_000 ), + setBalanceRequestSubmissionWindowSize = + PositiveFiniteDuration.fromConfig(config.setBalanceRequestSubmissionWindowSize), freeConfirmationResponses = enableFreeConfirmationResponses, ) }, diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/RestartDsoDelegateBasedAutomationTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/RestartDsoDelegateBasedAutomationTrigger.scala deleted file mode 100644 index 43903e171f..0000000000 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/singlesv/RestartDsoDelegateBasedAutomationTrigger.scala +++ /dev/null @@ -1,204 +0,0 @@ -// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package org.lfdecentralizedtrust.splice.sv.automation.singlesv - -import org.apache.pekko.stream.Materializer -import org.lfdecentralizedtrust.splice.automation.{ - OnAssignedContractTrigger, - TaskOutcome, - TaskSuccess, - TriggerContext, -} -import org.lfdecentralizedtrust.splice.codegen.java.splice -import org.lfdecentralizedtrust.splice.environment.{ - PackageVersionSupport, - PackageVettingLookupService, - RetryProvider, - SpliceLedgerConnection, -} -import org.lfdecentralizedtrust.splice.scan.admin.api.client.{BftScanConnection, ScanConnection} -import org.lfdecentralizedtrust.splice.store.DomainTimeSynchronization -import org.lfdecentralizedtrust.splice.util.AssignedContract -import org.lfdecentralizedtrust.splice.sv.automation.DsoDelegateBasedAutomationService -import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.SvTaskBasedTrigger -import org.lfdecentralizedtrust.splice.sv.config.SvAppBackendConfig -import org.lfdecentralizedtrust.splice.sv.store.SvDsoStore -import com.digitalasset.canton.time.Clock -import com.digitalasset.canton.tracing.TraceContext -import io.opentelemetry.api.trace.Tracer - -import scala.concurrent.{ExecutionContextExecutor, Future, blocking} -import com.digitalasset.canton.lifecycle.RunOnClosing -import com.digitalasset.canton.lifecycle.AsyncOrSyncCloseable -import com.digitalasset.canton.lifecycle.SyncCloseable -import com.digitalasset.canton.lifecycle.LifeCycle -import com.digitalasset.canton.lifecycle.UnlessShutdown -import com.digitalasset.canton.util.ShowUtil.* -import org.lfdecentralizedtrust.splice.store.AppStoreWithIngestion.SpliceLedgerConnectionPriority - -class RestartDsoDelegateBasedAutomationTrigger( - override protected val context: TriggerContext, - domainTimeSync: DomainTimeSynchronization, - store: SvDsoStore, - connection: SpliceLedgerConnectionPriority => SpliceLedgerConnection, - clock: Clock, - config: SvAppBackendConfig, - appLevelRetryProvider: RetryProvider, - packageVersionSupport: PackageVersionSupport, - packageVettingService: PackageVettingLookupService, - getOwnScanConnection: () => Future[ScanConnection], - getPeerBftScanConnection: () => Future[BftScanConnection], -)(implicit - override val ec: ExecutionContextExecutor, - mat: Materializer, - tracer: Tracer, -) extends OnAssignedContractTrigger.Template[ - splice.dsorules.DsoRules.ContractId, - splice.dsorules.DsoRules, - ]( - store, - splice.dsorules.DsoRules.COMPANION, - ) { - type DsoRulesContract = AssignedContract[ - splice.dsorules.DsoRules.ContractId, - splice.dsorules.DsoRules, - ] - - @volatile - @SuppressWarnings(Array("org.wartremover.warts.Var")) - private var epochStateVar: Option[EpochState] = None - - private def closeRetryProvider(): Unit = - epochStateVar.foreach(epochState => LifeCycle.close(epochState.retryProvider)(logger)) - - private def closeService(): Unit = - epochStateVar.foreach(epochState => - LifeCycle.close(epochState.dsoDelegateBasedAutomation)(logger) - ) - - def epochState: Option[EpochState] = epochStateVar - - appLevelRetryProvider.runOnShutdownWithPriority_(new RunOnClosing { - override def name = s"set per-epoch retry provider as closing" - override def done = false - override def run()(implicit tc: TraceContext) = - epochStateVar.foreach(_.retryProvider.setAsClosing()) - }) - - appLevelRetryProvider.runOnOrAfterClose_(new RunOnClosing { - override def name = s"shutdown per-epoch retry provider" - override def done = false - override def run()(implicit tc: TraceContext) = closeRetryProvider() - })(TraceContext.empty) - - override protected def closeAsync(): Seq[AsyncOrSyncCloseable] = - SyncCloseable("Per-epoch DsoDelegateBasedAutomationService", closeService()) +: super - .closeAsync() - - override def completeTask( - dsoRules: DsoRulesContract - )(implicit tc: TraceContext): Future[TaskOutcome] = Future { - blocking { - - mutex.exclusive { - val currentEpoch = dsoRules.payload.epoch - val lastKnownEpoch = epochStateVar.map(_.epoch) - - epochStateVar match { - case None => - logger.debug(s"Learned first epoch $currentEpoch") - restartAutomation(currentEpoch) - case Some(state) => - if (state.epoch != currentEpoch) { - logger.info( - show"Noticed an DsoRules epoch change (from ${state.epoch} to $currentEpoch)." - ) - logger.debug( - s"Restarting automation, as the epoch changed from ${state.epoch} to $currentEpoch" - ) - restartAutomation(currentEpoch) - } else { - TaskSuccess( - s"DsoRules changed, but the epoch stayed the same (epoch $lastKnownEpoch)" - ) - } - } - } - } - } - - private def restartAutomation(epoch: Long): TaskOutcome = { - val svTaskContext = - SvTaskBasedTrigger.Context( - store, - connection, - epoch, - config.delegatelessAutomationExpectedTaskDuration, - config.delegatelessAutomationExpiredRewardCouponBatchSize, - config.delegatelessAutomationExpiredRewardCouponNumBatches, - packageVersionSupport, - packageVettingService, - ) - - (if (appLevelRetryProvider.isClosing) { - // Avoid updating state when we are shutting down. - UnlessShutdown.AbortedDueToShutdown - } else { - closeRetryProvider() - closeService() - - val retryProvider = - RetryProvider( - loggerFactory, - timeouts, - appLevelRetryProvider.futureSupervisor, - context.metricsFactory, - ) - val dsoDelegateBasedAutomation = new DsoDelegateBasedAutomationService( - clock, - domainTimeSync, - config, - svTaskContext, - getOwnScanConnection, - getPeerBftScanConnection, - retryProvider, - loggerFactory, - ) - - epochStateVar = Some( - EpochState( - epoch, - dsoDelegateBasedAutomation, - retryProvider, - ) - ) - - // Shutdown might have been initiated concurrently with our change to the epochStateVar - if (appLevelRetryProvider.isClosing) { - logger.debug( - "Detected race between update of state and shutdown: closing down delegate-based automation again to be on the safe side." - )(TraceContext.empty) - closeRetryProvider() - closeService() - UnlessShutdown.AbortedDueToShutdown - } else { - // Delay startup of tasks until here. - // Even if right after the else, but before starting, it starts shutdown, that's okay, - // because the child RetryProvider is already scheduled for shutdown. - dsoDelegateBasedAutomation.start() - UnlessShutdown.Outcome(TaskSuccess(s"Started automation for epoch $epoch")) - } - }).onShutdown( - TaskSuccess( - s"Skipped or aborted restarting triggers for new epoch: $epoch, as we are shutting down." - ) - ) - } -} - -case class EpochState( - epoch: Long, - dsoDelegateBasedAutomation: DsoDelegateBasedAutomationService, - retryProvider: RetryProvider, -) {} diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/config/SvAppConfig.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/config/SvAppConfig.scala index 2c6661b4bc..5db47a2e22 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/config/SvAppConfig.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/config/SvAppConfig.scala @@ -454,6 +454,11 @@ case class SvAppBackendConfig( convertFeaturedAppActivityMarkerObservers: Boolean = true, // Whether to ensure that heuristic free confirmation responses get enabled on the synchronizer via the ReconcileDynamicSynchronizerConfigTrigger. enableFreeConfirmationResponses: Boolean = true, + // Target value for the setBalanceRequestSubmissionWindowSize traffic control parameter, + // applied to the synchronizer via the ReconcileDynamicSynchronizerParametersTrigger. + // The default matches Canton's current default as of 3.5.12 + setBalanceRequestSubmissionWindowSize: PositiveFiniteDuration = + PositiveFiniteDuration.ofMinutes(2), packageVettingCache: PackageVettingLookupService.CacheConfig = PackageVettingLookupService.CacheConfig(), useInternalSequencerApi: Boolean = false, @@ -461,9 +466,18 @@ case class SvAppBackendConfig( cantonBftSequencingParameters: Option[BftSequencingParameters] = Some( BftSequencingParameters( pbftViewChangeTimeout = PositiveFiniteDuration.ofSeconds(5), - segmentLength = SequencingParameters.DefaultSegmentLength.length, + // increased from default as epoch changes are synchronization points which can slow things down. + segmentLength = + PositiveLong.tryCreate(SequencingParameters.DefaultSegmentLength.length.value * 4), blacklistLeaderSelectionPolicyConfig = - SequencingParameters.DefaultLeaderSelectionPolicyConfig, + SequencingParameters.DefaultLeaderSelectionPolicyConfig.copy( + howLongToBlacklist = + BlacklistLeaderSelectionPolicyConfig.HowLongToBlacklist.Exponential( + initialValue = 1L, + // Reduced by 4 to compensate for increased segmentLength. + maximumEpochBlacklisted = Some(250L / 4L), + ) + ), ) ), // Set to false to disable the DB-level exclusive lock that prevents two SV instances diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/lsu/LsuTrigger.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/lsu/LsuTrigger.scala index 966bd3b50a..5fbdd2c78e 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/lsu/LsuTrigger.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/lsu/LsuTrigger.scala @@ -7,7 +7,7 @@ import cats.implicits.{catsSyntaxOptionId, showInterpolator, toTraverseOps} import com.digitalasset.canton.admin.api.client.data.NodeStatus import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} -import com.digitalasset.canton.topology.transaction.LsuAnnouncement +import com.digitalasset.canton.topology.transaction.{LsuAnnouncement, TopologyChangeOp} import com.digitalasset.canton.topology.PhysicalSynchronizerId import com.digitalasset.canton.tracing.TraceContext import io.opentelemetry.api.trace.Tracer @@ -211,7 +211,12 @@ class LsuTrigger( for { sequencerId <- currentSynchronizerNode.sequencerAdminConnection.getSequencerId hasNoSuccessor <- currentSynchronizerNode.sequencerAdminConnection - .lookupSequencerSuccessors(currentPsid.logical, sequencerId) + .lookupSequencerSuccessors( + announcement.successorSynchronizerId.logical, + sequencerId, + Some(announcement.successorSynchronizerId), + Some(TopologyChangeOp.Replace), + ) .map(_.isEmpty) participantPsid <- participantAdminConnection .getPhysicalSynchronizerId(currentPsid.logical) diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/onboarding/SequencerBftPeerReconciler.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/onboarding/SequencerBftPeerReconciler.scala index 019a1f175b..beb8cd4be7 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/onboarding/SequencerBftPeerReconciler.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/onboarding/SequencerBftPeerReconciler.scala @@ -78,11 +78,9 @@ abstract class SequencerBftPeerReconciler( configuredPeers <- sequencerAdminConnection .listConfiguredPeerEndpoints() peersToAdd = dsoSequencerEndpoints - .filterNot(endpoint => configuredPeers.exists(_.id == endpoint.id)) - candidatePeersToRemove = configuredPeers - .filterNot(peer => dsoSequencerEndpoints.exists(_.id == peer.id)) - peersToRemove <- computePeersToRemove( - candidatePeersToRemove, + .filterNot(endpoint => configuredPeers.map(_._1).exists(_.id == endpoint.id)) + peersToRemove = computePeersToRemove( + configuredPeers, dsoSequencersWithEndpoint, ) } yield { @@ -99,48 +97,39 @@ abstract class SequencerBftPeerReconciler( } yield result } - /** If all DSO sequencers have an associated peer endpoint advertised by scan, any configured peer - * that does not correspond to one of those endpoints is stale and safe to remove. - * - * Otherwise we cannot rely on scan alone (as some scans can be unavailable), so we cross-check the peer network status to find the - * sequencer id backing each candidate endpoint. Removal is only safe if that sequencer id is no - * longer part of the DSO sequencers, or if it is now associated with a different endpoint. If no - * sequencer id can be found for a candidate endpoint we keep it and log a warning. - */ private def computePeersToRemove( - candidatePeersToRemove: Seq[P2PEndpoint], + configuredPeers: Seq[(P2PEndpoint, Option[SequencerId])], dsoSequencersWithEndpoint: Seq[(SequencerId, Option[P2PEndpoint])], - )(implicit tc: TraceContext, ec: ExecutionContext): Future[Seq[P2PEndpoint]] = { - val allDsoSequencersHaveEndpoint = dsoSequencersWithEndpoint.forall { case (_, endpoint) => - endpoint.isDefined + ): Seq[P2PEndpoint] = { + val peersWithWrongSequencerId = configuredPeers.filter { + case (_, Some(sequencerId)) => + !dsoSequencersWithEndpoint.exists({ case (dsoSequencerId, _) => + sequencerId == dsoSequencerId + }) + case _ => false } - if (candidatePeersToRemove.isEmpty || allDsoSequencersHaveEndpoint) { - Future.successful(candidatePeersToRemove) - } else { - sequencerAdminConnection.listCurrentPeerEndpoints().map { networkStatus => - candidatePeersToRemove.filter { peer => - networkStatus.collectFirst { - case (Some(sequencerId), Some(endpointId)) if endpointId == peer.id => sequencerId - } match { - case Some(sequencerId) => - val sequencerNoLongerInDso = - !dsoSequencersWithEndpoint.exists { case (dsoSequencerId, _) => - dsoSequencerId == sequencerId - } - val sequencerMovedToDifferentEndpoint = - dsoSequencersWithEndpoint.exists { case (dsoSequencerId, endpoint) => - dsoSequencerId == sequencerId && endpoint.exists(_.id != peer.id) - } - sequencerNoLongerInDso || sequencerMovedToDifferentEndpoint - case None => - logger.warn( - s"Could not find a sequencer id for the configured peer endpoint ${peer.id} in the peer network status; not removing it to be safe." - ) - false - } - } - } + val peersWithChangedEndpoint = configuredPeers.filter { + case (peer, Some(sequencerId)) => + dsoSequencersWithEndpoint.exists({ case (dsoSequencerId, endpoint) => + sequencerId == dsoSequencerId && endpoint.exists(_.id != peer.id) + }) + case _ => false } + // we only remove connections for which we don't have a sequencer id when we have been able to query all scans to get connections. otherwise a temporary scan issue could result in us removing the peer. + val unknownPeers = + if (dsoSequencersWithEndpoint.forall { case (_, endpoint) => endpoint.isDefined }) { + configuredPeers.filter(peer => + !dsoSequencersWithEndpoint.exists { case (_, endpoint) => + endpoint.exists(_.id == peer._1.id) + } + ) + } else Seq.empty + + (peersWithWrongSequencerId ++ peersWithChangedEndpoint ++ unknownPeers) + .map( + _._1 + ) + .distinct } private def getAllBftSequencers()(implicit ec: ExecutionContext, tc: TraceContext) = { @@ -149,7 +138,8 @@ abstract class SequencerBftPeerReconciler( scan .listSvBftSequencers() .recover { case NonFatal(ex) => - logger.warn(s"Failed to read bft sequencers list from scan ${scan.url}", ex) + // not a warn because short-term failures are benign and longer-term outages should be covered by other monitoring + logger.info(s"Failed to read bft sequencers list from scan ${scan.url}", ex) Seq.empty } } @@ -161,6 +151,6 @@ object SequencerBftPeerReconciler { case class BftPeerDifference( toAdd: Seq[P2PEndpoint], toRemove: Seq[P2PEndpoint.Id], - currentPeers: Seq[P2PEndpoint], + currentPeers: Seq[(P2PEndpoint, Option[SequencerId])], ) } diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/onboarding/sv1/SV1Initializer.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/onboarding/sv1/SV1Initializer.scala index 77b46c1545..8cc1f727d7 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/onboarding/sv1/SV1Initializer.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/onboarding/sv1/SV1Initializer.scala @@ -180,7 +180,7 @@ class SV1Initializer( sequencerConnectionPoolDelays = config.participantClient.sequencerConnectionPoolDelays.toInternal, ), - synchronizerId = Some(psid), + psid = Some(psid), timeTracker = SynchronizerTimeTrackerConfig( minObservationDuration = config.timeTrackerMinObservationDuration, observationLatency = config.timeTrackerObservationLatency, diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala index 14250dbba4..1de78c5c15 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala @@ -753,15 +753,20 @@ trait SvDsoStore : ListExpiredContracts[so.SvOnboardingConfirmed.ContractId, so.SvOnboardingConfirmed] = multiDomainAcsStore.listExpiredFromPayloadExpiry(so.SvOnboardingConfirmed.COMPANION) - def listExpiredAnsEntries: ListExpiredContracts[ + def listExpiredAnsEntries(ignoredPartiesStore: Option[IgnoredPartiesStore]): ListExpiredContracts[ splice.ans.AnsEntry.ContractId, splice.ans.AnsEntry, ] = - multiDomainAcsStore.listExpiredFromPayloadExpiry(splice.ans.AnsEntry.COMPANION) + multiDomainAcsStore.listExpiredFromPayloadExpiry( + splice.ans.AnsEntry.COMPANION, + ignoredPartiesStore, + ignoredPartyFields = Seq("user"), + ) def listExpiredAnsSubscriptions( now: CantonTimestamp, limit: Limit = defaultLimit, + ignoredPartiesStore: Option[IgnoredPartiesStore], )(implicit tc: TraceContext): Future[Seq[SvDsoStore.IdleAnsSubscription]] def listExpiredUnallocatedUnclaimedActivityRecord: ListExpiredContracts[ @@ -1102,12 +1107,16 @@ trait SvDsoStore Seq[Contract[splice.dsorules.Confirmation.ContractId, splice.dsorules.Confirmation]] ] - def listExpiredTransferPreapprovals: ListExpiredContracts[ + def listExpiredTransferPreapprovals( + ignoredPartiesStore: Option[IgnoredPartiesStore] + ): ListExpiredContracts[ splice.amuletrules.TransferPreapproval.ContractId, splice.amuletrules.TransferPreapproval, ] = multiDomainAcsStore.listExpiredFromPayloadExpiry( - splice.amuletrules.TransferPreapproval.COMPANION + splice.amuletrules.TransferPreapproval.COMPANION, + ignoredPartiesStore, + ignoredPartyFields = Seq("receiver", "provider"), ) def getExternalPartyAmuletRules()(implicit diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala index 06d7133d6a..301d1d387f 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/db/DbSvDsoStore.scala @@ -46,17 +46,13 @@ import org.lfdecentralizedtrust.splice.store.db.{ } import org.lfdecentralizedtrust.splice.store.{ DbVotesAcsStoreQueryBuilder, + IgnoredPartiesStore, IngestionSummary, Limit, LimitHelpers, MultiDomainAcsStore, } -import org.lfdecentralizedtrust.splice.sv.store.{ - AppRewardCouponsSum, - IgnoredPartiesStore, - SvDsoStore, - SvStore, -} +import org.lfdecentralizedtrust.splice.sv.store.{AppRewardCouponsSum, SvDsoStore, SvStore} import SvDsoStore.RoundBatch import com.digitalasset.canton.config.CantonRequireTypes.String2066 import org.lfdecentralizedtrust.splice.util.* @@ -146,11 +142,22 @@ class DbSvDsoStore( override def listExpiredAnsSubscriptions( now: CantonTimestamp, limit: Limit = defaultLimit, + ignoredPartiesStore: Option[IgnoredPartiesStore] = None, )(implicit tc: TraceContext): Future[Seq[SvDsoStore.IdleAnsSubscription]] = waitUntilAcsIngested { + val ignoredParties = ignoredPartiesStore.fold(Set.empty[PartyId])(_.getAll) + val ignoredPartiesFilter: SQLActionBuilder = + if (ignoredParties.nonEmpty) { + (sql" and " ++ notInClause( + "idle.create_arguments->'subscriptionData'->>'sender'", + ignoredParties, + )).toActionBuilder + } else { + sql"" + } for { joinedRows <- storage .query( - sql""" + (sql""" select idle.store_id, idle.migration_id, @@ -188,9 +195,10 @@ class DbSvDsoStore( AnsEntryContext.TEMPLATE_ID_WITH_PACKAGE_ID )} and idle.subscription_next_payment_due_at < $now + """ ++ ignoredPartiesFilter ++ sql""" order by idle.subscription_next_payment_due_at limit ${sqlLimit(limit)} - """.as[(SelectFromAcsTableResult, SelectFromAcsTableResult)], + """).toActionBuilder.as[(SelectFromAcsTableResult, SelectFromAcsTableResult)], "listExpiredAnsSubscriptions", ) } yield applyLimit("listExpiredAnsSubscriptions", limit, joinedRows).map { diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/util/ContractStakeholders.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/util/ContractStakeholders.scala new file mode 100644 index 0000000000..b7782d80ff --- /dev/null +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/util/ContractStakeholders.scala @@ -0,0 +1,30 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.sv.util + +import com.digitalasset.canton.topology.PartyId +import org.lfdecentralizedtrust.splice.util.Contract + +trait ContractStakeholders[T] { + + def informees(payload: T): Seq[String] + + def dso(payload: T): String + + final def getStakeholders(payload: T): Seq[PartyId] = + getInformees(payload) :+ getDsoParty(payload) + + private final def getInformees(payload: T): Seq[PartyId] = + informees(payload).map(PartyId.tryFromProtoPrimitive) + + private final def getDsoParty(payload: T): PartyId = + PartyId.tryFromProtoPrimitive(dso(payload)) + + // ExpireRewardCouponTrigger and FeaturedAppActivityMarkerTrigger do not use BatchedMultiDomainExpiredContractTrigger + final def getInformeesFromContracts[TCid]( + contracts: Seq[Contract[TCid, T]] + ): Set[PartyId] = + contracts.flatMap(c => getInformees(c.payload)).toSet + +} diff --git a/apps/sv/src/test/scala/org/lfdecentralizedtrust/splice/store/db/SvDsoStoreTest.scala b/apps/sv/src/test/scala/org/lfdecentralizedtrust/splice/store/db/SvDsoStoreTest.scala index ba343fdae2..aeb5808050 100644 --- a/apps/sv/src/test/scala/org/lfdecentralizedtrust/splice/store/db/SvDsoStoreTest.scala +++ b/apps/sv/src/test/scala/org/lfdecentralizedtrust/splice/store/db/SvDsoStoreTest.scala @@ -54,6 +54,7 @@ import org.lfdecentralizedtrust.splice.environment.{DarResources, RetryProvider} import org.lfdecentralizedtrust.splice.store.MultiDomainAcsStore.QueryResult import org.lfdecentralizedtrust.splice.store.{ HardLimit, + IgnoredPartiesStore, Limit, MiningRoundsStore, PageLimit, @@ -61,7 +62,7 @@ import org.lfdecentralizedtrust.splice.store.{ } import org.lfdecentralizedtrust.splice.sv.store.SvDsoStore.{IdleAnsSubscription, RoundBatch} import org.lfdecentralizedtrust.splice.sv.store.db.DbSvDsoStore -import org.lfdecentralizedtrust.splice.sv.store.{IgnoredPartiesStore, SvDsoStore, SvStore} +import org.lfdecentralizedtrust.splice.sv.store.{SvDsoStore, SvStore} import org.lfdecentralizedtrust.splice.sv.util.SvUtil import org.lfdecentralizedtrust.splice.util.{ AssignedContract, @@ -1775,28 +1776,64 @@ abstract class SvDsoStoreTest extends StoreTestBase with HasExecutionContext { } - "listExpiredAnsSubscriptions" should { + "listExpiredAnsEntries" should { - "return all entries where subscription_next_payment_due_at < now" in { + // 1 to 3 expire at time(1..3), 4 to 6 at time(4..6); queries run at time(4). + def mkAnsEntries(range: Range) = + range.map(i => ansEntry(userParty(i), s"entry$i", expiresAt = time(i.toLong).toInstant)) + + def setupAnsEntries(store: SvDsoStore) = { + val expired = mkAnsEntries(1 to 3) + val notExpired = mkAnsEntries(4 to 6) + MonadUtil + .sequentialTraverse(expired ++ notExpired)( + dummyDomain.create(_)(store.multiDomainAcsStore) + ) + .map(_ => expired) + } + + "return all expired ans entries" in { for { store <- mkStore() - // 1 to 3 are expired, 4 to 6 are not - data = ((1 to 3).map(n => - n -> Instant.now().truncatedTo(ChronoUnit.MICROS).minusSeconds(n * 1000L) - ) ++ (4 to 6) - .map(n => n -> Instant.now().truncatedTo(ChronoUnit.MICROS).plusSeconds(n * 1000L))) - .map { case (n, nextPaymentDueAt) => - val contextContract = - ansEntryContext(n, n.toString) - val idleStateContract = - subscriptionIdleState( - n, - nextPaymentDueAt, - ) + expired <- setupAnsEntries(store) + result <- store.listExpiredAnsEntries(None)( + time(4), + PageLimit.tryCreate(100), + )(traceContext) + } yield { + result.map(_.contract) should contain theSameElementsAs expired + } + } - (contextContract, idleStateContract) - } - _ <- MonadUtil.sequentialTraverse(data) { case (contextContract, idleContract) => + "filter out ans entries whose user is ignored" in { + for { + store <- mkStore() + expired <- setupAnsEntries(store) + result <- store.listExpiredAnsEntries( + Some(new IgnoredPartiesStore(Set(userParty(1), userParty(2)))) + )( + time(4), + PageLimit.tryCreate(100), + )(traceContext) + } yield { + result.map(_.contract) should contain theSameElementsAs Seq(expired(2)) + } + } + } + + "listExpiredAnsSubscriptions" should { + + def setupExpiredSubscriptions(store: SvDsoStore) = { + // 1 to 3 are expired, 4 to 6 are not + val data = ((1 to 3).map(n => + n -> Instant.now().truncatedTo(ChronoUnit.MICROS).minusSeconds(n * 1000L) + ) ++ (4 to 6) + .map(n => n -> Instant.now().truncatedTo(ChronoUnit.MICROS).plusSeconds(n * 1000L))) + .map { case (n, nextPaymentDueAt) => + (ansEntryContext(n, n.toString), subscriptionIdleState(n, nextPaymentDueAt)) + } + MonadUtil + .sequentialTraverse(data) { case (contextContract, idleContract) => for { _ <- dummyDomain.create(contextContract, createdEventSignatories = Seq(dsoParty))( store.multiDomainAcsStore @@ -1806,6 +1843,13 @@ abstract class SvDsoStoreTest extends StoreTestBase with HasExecutionContext { ) } yield () } + .map(_ => data) + } + + "return all entries where subscription_next_payment_due_at < now" in { + for { + store <- mkStore() + data <- setupExpiredSubscriptions(store) } yield { val expected = data .take(3) @@ -1814,7 +1858,34 @@ abstract class SvDsoStoreTest extends StoreTestBase with HasExecutionContext { } .reverse store - .listExpiredAnsSubscriptions(CantonTimestamp.now(), limit = PageLimit.tryCreate(3)) + .listExpiredAnsSubscriptions( + CantonTimestamp.now(), + limit = PageLimit.tryCreate(3), + None, + ) + .futureValue should be(expected) + } + } + + "filter out subscriptions whose sender is in the ignored parties store" in { + for { + store <- mkStore() + data <- setupExpiredSubscriptions(store) + } yield { + // n=1 and n=2 are expired but their senders are ignored, only n=3 remains + val expected = data + .slice(2, 3) + .map { case (ctxContract, idleContract) => + IdleAnsSubscription(idleContract, ctxContract) + } + store + .listExpiredAnsSubscriptions( + CantonTimestamp.now(), + limit = PageLimit.tryCreate(3), + ignoredPartiesStore = Some( + new IgnoredPartiesStore(Set(userParty(1), userParty(2))) + ), + ) .futureValue should be(expected) } } @@ -1935,41 +2006,54 @@ abstract class SvDsoStoreTest extends StoreTestBase with HasExecutionContext { "listExpiredTransferPreapprovals" should { - "return all expired transfer pre-approvals" in { - val expired = (1 to 3).map(n => + def mkTransferPreapprovals(n: Range) = + n.map(i => transferPreapproval( - userParty(n), - providerParty(n), + userParty(i), + providerParty(i), time(0), - expiresAt = time(n.toLong), + expiresAt = time(i.toLong), ) ) - val notExpired = - (4 to 6).map(n => - transferPreapproval( - userParty(n), - providerParty(n), - time(0), - expiresAt = time(n.toLong), - ) + + def setupTransferPreapprovals(store: SvDsoStore) = { + val expired = mkTransferPreapprovals(1 to 3) + val notExpired = mkTransferPreapprovals(4 to 6) + MonadUtil + .sequentialTraverse(expired ++ notExpired)( + dummyDomain.create(_)(store.multiDomainAcsStore) ) + .map(_ => expired) + } + + "return all expired transfer pre-approvals" in { for { store <- mkStore() - _ <- MonadUtil.sequentialTraverse(expired ++ notExpired)( - dummyDomain.create(_)(store.multiDomainAcsStore) - ) - result <- store.listExpiredTransferPreapprovals( + expired <- setupTransferPreapprovals(store) + result <- store.listExpiredTransferPreapprovals(None)( time(4), PageLimit.tryCreate(100), - )( - traceContext - ) + )(traceContext) } yield { - val contracts = result.map(_.contract) - contracts should contain theSameElementsAs expired + result.map(_.contract) should contain theSameElementsAs expired } } + "filter out pre-approvals whose receiver or provider is ignored" in { + for { + store <- mkStore() + expired <- setupTransferPreapprovals(store) + result <- store.listExpiredTransferPreapprovals( + // n=1 is dropped via its receiver, n=2 via its provider + Some(new IgnoredPartiesStore(Set(userParty(1), providerParty(2)))) + )( + time(4), + PageLimit.tryCreate(100), + )(traceContext) + } yield { + result.map(_.contract) should contain theSameElementsAs Seq(expired(2)) + } + } } } diff --git a/apps/sv/src/test/scala/org/lfdecentralizedtrust/splice/sv/automation/VoteRequestMetricsTriggerTest.scala b/apps/sv/src/test/scala/org/lfdecentralizedtrust/splice/sv/automation/VoteRequestMetricsTriggerTest.scala new file mode 100644 index 0000000000..49a8d07bec --- /dev/null +++ b/apps/sv/src/test/scala/org/lfdecentralizedtrust/splice/sv/automation/VoteRequestMetricsTriggerTest.scala @@ -0,0 +1,35 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.sv.automation + +import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.{Reason, Vote} +import org.lfdecentralizedtrust.splice.store.StoreTestBase +import org.lfdecentralizedtrust.splice.sv.automation.VoteRequestMetricsTrigger.VoteRequestCounts + +import java.util.Optional + +class VoteRequestMetricsTriggerTest extends StoreTestBase { + + "countByState" should { + "partition vote requests by their state relative to the SV" in { + val sv = userParty(1) + val otherSv = userParty(2) + def vote(svParty: com.digitalasset.canton.topology.PartyId): Vote = + new Vote(svParty.toProtoPrimitive, true, new Reason("", ""), Optional.empty()) + + val notVoted = voteRequest(requester = otherSv, votes = Seq(vote(otherSv))) + val voted = voteRequest(requester = otherSv, votes = Seq(vote(otherSv), vote(sv))) + val ownRequest = voteRequest(requester = sv, votes = Seq(vote(sv))) + // ready to close counts as such regardless of whether the SV has voted + val readyVoted = voteRequest(requester = sv, votes = Seq(vote(sv))) + val readyNotVoted = voteRequest(requester = otherSv, votes = Seq(vote(otherSv))) + + VoteRequestMetricsTrigger.countByState( + Seq(notVoted, voted, ownRequest, readyVoted, readyNotVoted), + Set(readyVoted.contractId, readyNotVoted.contractId), + sv.toProtoPrimitive, + ) shouldBe VoteRequestCounts(actionNeeded = 1, inProgress = 2, readyToClose = 2) + } + } +} diff --git a/apps/sv/src/test/scala/org/lfdecentralizedtrust/splice/sv/onboarding/SequencerBftPeerReconcilerSpec.scala b/apps/sv/src/test/scala/org/lfdecentralizedtrust/splice/sv/onboarding/SequencerBftPeerReconcilerSpec.scala index f6efa519cc..1b6d7b115d 100644 --- a/apps/sv/src/test/scala/org/lfdecentralizedtrust/splice/sv/onboarding/SequencerBftPeerReconcilerSpec.scala +++ b/apps/sv/src/test/scala/org/lfdecentralizedtrust/splice/sv/onboarding/SequencerBftPeerReconcilerSpec.scala @@ -141,8 +141,8 @@ class SequencerBftPeerReconcilerSpec extends AnyFlatSpec with BaseTest with HasR .thenReturn( Future.successful( Seq( - configuredPeer(sequencer1Host), - configuredPeer(sequencer2Host), + configuredPeer(sequencer1Host, Some(sequencer1Id)), + configuredPeer(sequencer2Host, Some(sequencer2Id)), ) ) ) @@ -177,8 +177,8 @@ class SequencerBftPeerReconcilerSpec extends AnyFlatSpec with BaseTest with HasR .thenReturn( Future.successful( Seq( - configuredPeer(sequencer1Host), - configuredPeer(sequencer2Host), + configuredPeer(sequencer1Host, Some(sequencer1Id)), + configuredPeer(sequencer2Host, Some(sequencer2Id)), ) ) ) @@ -187,7 +187,7 @@ class SequencerBftPeerReconcilerSpec extends AnyFlatSpec with BaseTest with HasR result should be(empty) } - it should "do nothing when scan doesn't contain the sequencer info but the dso state still contains it and the network status confirms the sequencer" in { + it should "do nothing when scan doesn't contain the sequencer info but the dso state still contains it and the configured peer's sequencer id confirms the sequencer" in { withConfiguredDsoSequencers( Seq( createSequencerConfig(sequencer1Id), @@ -207,17 +207,12 @@ class SequencerBftPeerReconcilerSpec extends AnyFlatSpec with BaseTest with HasR .thenReturn( Future.successful( Seq( - configuredPeer(sequencer1Host), - configuredPeer(sequencer2Host), + configuredPeer(sequencer1Host, Some(sequencer1Id)), + configuredPeer(sequencer2Host, Some(sequencer2Id)), ) ) ) - withNetworkStatus( - (Some(sequencer1Id), Some(sequencer1Host)), - (Some(sequencer2Id), Some(sequencer2Host)), - ) - val result = reconciler.diffDsoRulesWithTopology().futureValue result should be(empty) } @@ -243,7 +238,7 @@ class SequencerBftPeerReconcilerSpec extends AnyFlatSpec with BaseTest with HasR .thenReturn( Future.successful( Seq( - configuredPeer(sequencer1Host) + configuredPeer(sequencer1Host, Some(sequencer1Id)) ) ) ) @@ -277,15 +272,11 @@ class SequencerBftPeerReconcilerSpec extends AnyFlatSpec with BaseTest with HasR .thenReturn( Future.successful( Seq( - configuredPeer(sequencer1Host) + configuredPeer(sequencer1Host, Some(sequencer1Id)) ) ) ) - withNetworkStatus( - (Some(sequencer1Id), Some(sequencer1Host)) - ) - val result = reconciler.diffDsoRulesWithTopology().futureValue result should be(empty) } @@ -309,7 +300,7 @@ class SequencerBftPeerReconcilerSpec extends AnyFlatSpec with BaseTest with HasR .thenReturn( Future.successful( Seq( - configuredPeer(sequencer1Host) + configuredPeer(sequencer1Host, Some(sequencer1Id)) ) ) ) @@ -317,7 +308,7 @@ class SequencerBftPeerReconcilerSpec extends AnyFlatSpec with BaseTest with HasR reconciler.diffDsoRulesWithTopology().futureValue should be(empty) } - it should "remove a configured peer whose sequencer id is no longer in the dso, using the network status as a fallback" in { + it should "remove a configured peer whose sequencer id is no longer in the dso, using the configured peer's sequencer id as a fallback" in { withConfiguredDsoSequencers( Seq( createSequencerConfig(sequencer1Id), @@ -337,23 +328,18 @@ class SequencerBftPeerReconcilerSpec extends AnyFlatSpec with BaseTest with HasR .thenReturn( Future.successful( Seq( - configuredPeer(sequencer1Host), - configuredPeer(sequencer3Host), + configuredPeer(sequencer1Host, Some(sequencer1Id)), + configuredPeer(sequencer3Host, Some(sequencer3Id)), ) ) ) - withNetworkStatus( - (Some(sequencer1Id), Some(sequencer1Host)), - (Some(sequencer3Id), Some(sequencer3Host)), - ) - val result = reconciler.diffDsoRulesWithTopology().futureValue.loneElement result.toAdd should be(empty) result.toRemove should contain only sequencer3Host } - it should "replace a configured peer whose sequencer moved to a different endpoint, using the network status as a fallback" in { + it should "replace a configured peer whose sequencer moved to a different endpoint, using the configured peer's sequencer id as a fallback" in { withConfiguredDsoSequencers( Seq( createSequencerConfig(sequencer1Id), @@ -375,21 +361,17 @@ class SequencerBftPeerReconcilerSpec extends AnyFlatSpec with BaseTest with HasR .thenReturn( Future.successful( Seq( - configuredPeer(sequencer1Host) + configuredPeer(sequencer1Host, Some(sequencer1Id)) ) ) ) - withNetworkStatus( - (Some(sequencer1Id), Some(sequencer1Host)) - ) - val result = reconciler.diffDsoRulesWithTopology().futureValue.loneElement result.toAdd.map(_.id) should contain only newSequencer1Host result.toRemove should contain only sequencer1Host } - it should "keep a configured peer and log a warning when no sequencer id can be found for it in the network status" in { + it should "keep a configured peer when it has no sequencer id and the dso endpoints are not fully known" in { withConfiguredDsoSequencers( Seq( createSequencerConfig(sequencer1Id), @@ -409,22 +391,13 @@ class SequencerBftPeerReconcilerSpec extends AnyFlatSpec with BaseTest with HasR .thenReturn( Future.successful( Seq( - configuredPeer(sequencer1Host), + configuredPeer(sequencer1Host, Some(sequencer1Id)), configuredPeer(sequencer2Host), ) ) ) - withNetworkStatus( - (Some(sequencer1Id), Some(sequencer1Host)) - ) - - val result = loggerFactory.assertLogs( - reconciler.diffDsoRulesWithTopology().futureValue, - _.warningMessage should include( - s"Could not find a sequencer id for the configured peer endpoint ${sequencer2Host}" - ), - ) + val result = reconciler.diffDsoRulesWithTopology().futureValue result should be(empty) } @@ -507,12 +480,9 @@ class SequencerBftPeerReconcilerSpec extends AnyFlatSpec with BaseTest with HasR ) } - private def configuredPeer(host: P2PEndpoint.Id): P2PEndpoint = - BftSequencer(serialId, selfSequencerId, host.url).peerId - - private def withNetworkStatus( - entries: (Option[SequencerId], Option[P2PEndpoint.Id])* - ) = - when(sequencerAdminConnection.listCurrentPeerEndpoints()) - .thenReturn(Future.successful(entries)) + private def configuredPeer( + host: P2PEndpoint.Id, + sequencerId: Option[SequencerId] = None, + ): (P2PEndpoint, Option[SequencerId]) = + BftSequencer(serialId, selfSequencerId, host.url).peerId -> sequencerId } diff --git a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/ValidatorApp.scala b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/ValidatorApp.scala index d9b8dfb543..f9b9173550 100644 --- a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/ValidatorApp.scala +++ b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/ValidatorApp.scala @@ -782,6 +782,7 @@ class ValidatorApp( config.parameters.enabledFeatures, config.additionalPackagesToUnvet, config.domains.global.alias, + config.enableDeprecatedTransferCommandSupport, loggerFactory, packageVersionSupport, ) diff --git a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/admin/http/HttpValidatorAdminHandler.scala b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/admin/http/HttpValidatorAdminHandler.scala index 650f7dbd32..8f9bb3b47d 100644 --- a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/admin/http/HttpValidatorAdminHandler.scala +++ b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/admin/http/HttpValidatorAdminHandler.scala @@ -96,6 +96,16 @@ class HttpValidatorAdminHandler( )(handleRequest) } + private def requireTransferCommandSupport[T](handleRequest: => T): T = { + if (config.enableDeprecatedTransferCommandSupport) { + handleRequest + } else { + throw HttpErrorHandler.notImplemented( + "Transfer command support is disabled by default and will be removed in 0.8.0. You can temporarily enable in on 0.7.x by setting enable-deprecated-transfer-command-support=true in your validator config." + ) + } + } + def onboardUser( respond: v0.ValidatorAdminResource.OnboardUserResponse.type )( @@ -454,6 +464,7 @@ class HttpValidatorAdminHandler( BaseLedgerConnection.sanitizeUserIdToPartyString(body.userPartyId), ), DedupOffset(implicitly[Ordering[Long]].min(offsetESP, offsetTP)), + recoverAcceptedDuplicates = true, ) ), ) @@ -704,89 +715,91 @@ class HttpValidatorAdminHandler( ): Future[v0.ValidatorAdminResource.PrepareTransferPreapprovalSendResponse] = { implicit val AdminUserRequest(tracedContext) = tuser requireWalletEnabled { _ => - val senderParty = PartyId.tryFromProtoPrimitive(body.senderPartyId) - val receiverParty = PartyId.tryFromProtoPrimitive(body.receiverPartyId) - for { - synchronizerId <- getAmuletRulesDomain()(tracedContext) - // This check is just to make it fail early. The actual preapproval is fixed when the automation - // executes the transfer but we want the user to get feedback during the prepare step already. - _ <- scanConnection.lookupTransferPreapprovalByParty(receiverParty).map { preapprovalO => - if (preapprovalO.isEmpty) { - throw Status.INVALID_ARGUMENT - .withDescription(s"Receiver $receiverParty does not have a TransferPreapproval") - .asRuntimeException + requireTransferCommandSupport { + val senderParty = PartyId.tryFromProtoPrimitive(body.senderPartyId) + val receiverParty = PartyId.tryFromProtoPrimitive(body.receiverPartyId) + for { + synchronizerId <- getAmuletRulesDomain()(tracedContext) + // This check is just to make it fail early. The actual preapproval is fixed when the automation + // executes the transfer but we want the user to get feedback during the prepare step already. + _ <- scanConnection.lookupTransferPreapprovalByParty(receiverParty).map { preapprovalO => + if (preapprovalO.isEmpty) { + throw Status.INVALID_ARGUMENT + .withDescription(s"Receiver $receiverParty does not have a TransferPreapproval") + .asRuntimeException + } } - } - externalPartyAmuletRules <- scanConnection.getExternalPartyAmuletRules() - supportsDescription <- packageVersionSupport - .supportsDescriptionInTransferPreapprovals( - Seq(receiverParty, senderParty, store.key.dsoParty), - clock.now, - ) - .map(_.supported) - commands = externalPartyAmuletRules.toAssignedContract - .getOrElse( - throw Status.Code.FAILED_PRECONDITION.toStatus - .withDescription( - s"ExternalPartyAmuletRules is currently inflight between synchronizers, retry until it is assigned to a synchronizer" + externalPartyAmuletRules <- scanConnection.getExternalPartyAmuletRules() + supportsDescription <- packageVersionSupport + .supportsDescriptionInTransferPreapprovals( + Seq(receiverParty, senderParty, store.key.dsoParty), + clock.now, + ) + .map(_.supported) + commands = externalPartyAmuletRules.toAssignedContract + .getOrElse( + throw Status.Code.FAILED_PRECONDITION.toStatus + .withDescription( + s"ExternalPartyAmuletRules is currently inflight between synchronizers, retry until it is assigned to a synchronizer" + ) + .asRuntimeException() + ) + .exercise( + _.exerciseExternalPartyAmuletRules_CreateTransferCommand( + senderParty.toProtoPrimitive, + receiverParty.toProtoPrimitive, + store.key.validatorParty.toProtoPrimitive, + body.amount.bigDecimal, + body.expiresAt.toInstant, + body.nonce, + Option.when(supportsDescription)(body.description).flatten.toJava, + java.util.Optional.of(store.key.dsoParty.toProtoPrimitive), ) - .asRuntimeException() - ) - .exercise( - _.exerciseExternalPartyAmuletRules_CreateTransferCommand( - senderParty.toProtoPrimitive, - receiverParty.toProtoPrimitive, - store.key.validatorParty.toProtoPrimitive, - body.amount.bigDecimal, - body.expiresAt.toInstant, - body.nonce, - Option.when(supportsDescription)(body.description).flatten.toJava, - java.util.Optional.of(store.key.dsoParty.toProtoPrimitive), + ) + .update + .commands() + .asScala + .toSeq + r <- storeWithIngestion + .connection(SpliceLedgerConnectionPriority.Medium) + .prepareSubmission( + Some(synchronizerId), + Seq(senderParty), + Seq(senderParty), + commands, + storeWithIngestion + .connection(SpliceLedgerConnectionPriority.Medium) + .disclosedContracts(externalPartyAmuletRules), + body.verboseHashing.getOrElse(false), + ) + transferCommandCid = r.preparedTransaction + .flatMap(_.transaction) + .toList + .flatMap(_.nodes) + .flatMap(n => + n.getV1.nodeType match { + case interactive.transaction.v1.interactive_submission_data.Node.NodeType + .Create(create) => + Seq(create.contractId) + case _ => Seq.empty + } + ) + .headOption + .getOrElse( + throw Status.INTERNAL + .withDescription("Failed to obtain transferCommandCid from prepared transaction") + .asRuntimeException() + ) + } yield { + v0.ValidatorAdminResource.PrepareTransferPreapprovalSendResponse.OK( + definitions.PrepareTransferPreapprovalSendResponse( + Base64.getEncoder.encodeToString(r.getPreparedTransaction.toByteArray), + HexString.toHexString(r.preparedTransactionHash), + transferCommandCid, + r.hashingDetails, ) ) - .update - .commands() - .asScala - .toSeq - r <- storeWithIngestion - .connection(SpliceLedgerConnectionPriority.Medium) - .prepareSubmission( - Some(synchronizerId), - Seq(senderParty), - Seq(senderParty), - commands, - storeWithIngestion - .connection(SpliceLedgerConnectionPriority.Medium) - .disclosedContracts(externalPartyAmuletRules), - body.verboseHashing.getOrElse(false), - ) - transferCommandCid = r.preparedTransaction - .flatMap(_.transaction) - .toList - .flatMap(_.nodes) - .flatMap(n => - n.getV1.nodeType match { - case interactive.transaction.v1.interactive_submission_data.Node.NodeType - .Create(create) => - Seq(create.contractId) - case _ => Seq.empty - } - ) - .headOption - .getOrElse( - throw Status.INTERNAL - .withDescription("Failed to obtain transferCommandCid from prepared transaction") - .asRuntimeException() - ) - } yield { - v0.ValidatorAdminResource.PrepareTransferPreapprovalSendResponse.OK( - definitions.PrepareTransferPreapprovalSendResponse( - Base64.getEncoder.encodeToString(r.getPreparedTransaction.toByteArray), - HexString.toHexString(r.preparedTransactionHash), - transferCommandCid, - r.hashingDetails, - ) - ) + } } } } @@ -798,15 +811,17 @@ class HttpValidatorAdminHandler( ): Future[v0.ValidatorAdminResource.SubmitTransferPreapprovalSendResponse] = { implicit val AdminUserRequest(tracedContext) = tuser requireWalletEnabled { _ => - for { - updateId <- ValidatorUtil.submitAsExternalParty( - storeWithIngestion.connection(SpliceLedgerConnectionPriority.Medium), - body.submission, - waitForOffset = false, + requireTransferCommandSupport { + for { + updateId <- ValidatorUtil.submitAsExternalParty( + storeWithIngestion.connection(SpliceLedgerConnectionPriority.Medium), + body.submission, + waitForOffset = false, + ) + } yield v0.ValidatorAdminResource.SubmitTransferPreapprovalSendResponseOK( + definitions.SubmitTransferPreapprovalSendResponse(updateId) ) - } yield v0.ValidatorAdminResource.SubmitTransferPreapprovalSendResponseOK( - definitions.SubmitTransferPreapprovalSendResponse(updateId) - ) + } } } diff --git a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/automation/AcceptTransferPreapprovalProposalTrigger.scala b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/automation/AcceptTransferPreapprovalProposalTrigger.scala index 21d5e4edc9..c60f31b25a 100644 --- a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/automation/AcceptTransferPreapprovalProposalTrigger.scala +++ b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/automation/AcceptTransferPreapprovalProposalTrigger.scala @@ -110,11 +110,20 @@ class AcceptTransferPreapprovalProposalTrigger( for { validatorWallet <- ValidatorUtil.getValidatorWallet(store, walletManager) result <- store.lookupTransferPreapprovalByReceiverPartyWithOffset(receiverParty) flatMap { - case QueryResult(_, Some(_)) => + // Expired pre-approvals are ignored: the receiver cannot be paid through them anymore + // and they may stick around for a while until the SV automation archives them. + case QueryResult(_, Some(existing)) + if existing.payload.expiresAt.isAfter(clock.now.toInstant) => Future.successful( TaskSuccess(show"TransferPreapproval for receiver $receiverParty already exists") ) - case QueryResult(offset, None) => + case QueryResult(offset, existing) => + existing.foreach(expired => + logger.info( + s"Accepting proposal for receiver $receiverParty as its existing TransferPreapproval " + + s"${expired.contractId.contractId} expired at ${expired.payload.expiresAt}" + ) + ) validatorWallet.treasury .enqueueAmuletOperation( operation, diff --git a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/automation/ReconcileSequencerConnectionsTrigger.scala b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/automation/ReconcileSequencerConnectionsTrigger.scala index 1326081f63..6e0c852829 100644 --- a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/automation/ReconcileSequencerConnectionsTrigger.scala +++ b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/automation/ReconcileSequencerConnectionsTrigger.scala @@ -114,7 +114,7 @@ class ReconcileSequencerConnectionsTrigger( SynchronizerConnectionConfig( alias, sequencerConnectionConfig, - synchronizerId = Some(psid), + psid = Some(psid), ), reconnectOnSynchronizerConfigurationChange, modifySequencerConnections(sequencerConnectionConfig), diff --git a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/automation/ValidatorAutomationService.scala b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/automation/ValidatorAutomationService.scala index cac4d2676c..fb4b1e42d0 100644 --- a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/automation/ValidatorAutomationService.scala +++ b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/automation/ValidatorAutomationService.scala @@ -76,6 +76,7 @@ class ValidatorAutomationService( enabledFeatures: EnabledFeaturesConfig, additionalPackagesToUnvet: Map[PackageName, Set[PackageVersion]], globalSynchronizerAlias: SynchronizerAlias, + enableDeprecatedTransferCommandSupport: Boolean, override protected val loggerFactory: NamedLoggerFactory, packageVersionSupport: PackageVersionSupport, )(implicit @@ -196,15 +197,17 @@ class ValidatorAutomationService( ) ) - registerTrigger( - new TransferCommandSendTrigger( - triggerContext, - scanConnection, - store, - walletManager.externalPartyWalletManager, - connection(SpliceLedgerConnectionPriority.Medium), + if (enableDeprecatedTransferCommandSupport) { + registerTrigger( + new TransferCommandSendTrigger( + triggerContext, + scanConnection, + store, + walletManager.externalPartyWalletManager, + connection(SpliceLedgerConnectionPriority.Medium), + ) ) - ) + } } backupDumpConfig.foreach(config => @@ -241,6 +244,7 @@ class ValidatorAutomationService( maxVettingDelay, latestPackagesOnly, enabledFeatures.enableUnsupportedDarsUnvetting, + enabledFeatures.enableValidatorDarsUnvetting, additionalPackagesToUnvet, ) ) diff --git a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/automation/ValidatorPackageVettingTrigger.scala b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/automation/ValidatorPackageVettingTrigger.scala index 999eb92fd3..f59d6c399f 100644 --- a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/automation/ValidatorPackageVettingTrigger.scala +++ b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/automation/ValidatorPackageVettingTrigger.scala @@ -23,6 +23,7 @@ class ValidatorPackageVettingTrigger( maxVettingDelay: NonNegativeFiniteDuration, latestPackagesOnly: Boolean, enableUnsupportedDarsUnvetting: Boolean, + enableValidatorDarsUnvetting: Boolean, additionalPackagesToUnvet: Map[PackageName, Set[PackageVersion]], )(implicit override val ec: ExecutionContext, @@ -31,7 +32,7 @@ class ValidatorPackageVettingTrigger( ValidatorPackageVettingTrigger.packages, maxVettingDelay, latestPackagesOnly, - enableUnvetting = false, // Currently only supported by SVs. + enableUnvetting = enableValidatorDarsUnvetting, enableUnsupportedDarsUnvetting, additionalPackagesToUnvet, ) { diff --git a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/config/ValidatorAppConfig.scala b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/config/ValidatorAppConfig.scala index 9dde4c84da..2973fca980 100644 --- a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/config/ValidatorAppConfig.scala +++ b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/config/ValidatorAppConfig.scala @@ -224,6 +224,8 @@ case class ValidatorAppBackendConfig( // from running concurrently against the same database. Only disable for migration scenarios // where intentional overlap is required. instanceLockEnabled: Boolean = true, + // Enable the deprecated transfer command support, will be fully removed in 0.8.0. + enableDeprecatedTransferCommandSupport: Boolean = false, ) extends SpliceBackendConfig // TODO(DACH-NY/canton-network-node#736): fork or generalize this trait. { override val nodeTypeName: String = "validator" diff --git a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/domain/SynchronizerConnector.scala b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/domain/SynchronizerConnector.scala index 5a1938bc44..5b6850bc76 100644 --- a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/domain/SynchronizerConnector.scala +++ b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/domain/SynchronizerConnector.scala @@ -190,7 +190,7 @@ class SynchronizerConnector( SynchronizerConnectionConfig( alias, sequencerConnections, - synchronizerId = psid, + psid = psid, timeTracker = SynchronizerTimeTrackerConfig( minObservationDuration = config.timeTrackerMinObservationDuration, observationLatency = config.timeTrackerObservationLatency, diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/ExternalPartyWalletManager.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/ExternalPartyWalletManager.scala index 19c50c6fcd..d410127388 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/ExternalPartyWalletManager.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/ExternalPartyWalletManager.scala @@ -27,6 +27,7 @@ import org.lfdecentralizedtrust.splice.wallet.store.{ExternalPartyWalletStore, W import scala.collection.concurrent.TrieMap import scala.concurrent.{blocking, ExecutionContext} +import scala.util.control.NonFatal /** Manages all services comprising an external party wallets. */ class ExternalPartyWalletManager( @@ -134,8 +135,8 @@ class ExternalPartyWalletManager( )(TraceContext.empty) externalPartyRetryProviderAndWalletService.foreach { case (externalPartyRetryProvider, walletService) => - externalPartyRetryProvider.close() - walletService.close() + try externalPartyRetryProvider.close() + finally walletService.close() } UnlessShutdown.AbortedDueToShutdown } else { @@ -160,22 +161,32 @@ class ExternalPartyWalletManager( retryProvider.futureSupervisor, retryProvider.metricsFactory, ) - val walletService = new ExternalPartyWalletService( - ledgerClient, - key, - automationConfig, - clock, - domainTimeSync, - storage, - externalPartyRetryProvider, - partyLoggerFactory, - migrationId, - participantId, - params, - scanConnection, - packageVersionSupport, - rewardSharingConfigByParty.getOrElse(externalParty.toProtoPrimitive, RewardSharingConfig()), - ) + val walletService = + try { + new ExternalPartyWalletService( + ledgerClient, + key, + automationConfig, + clock, + domainTimeSync, + storage, + externalPartyRetryProvider, + partyLoggerFactory, + migrationId, + participantId, + params, + scanConnection, + packageVersionSupport, + rewardSharingConfigByParty.getOrElse( + externalParty.toProtoPrimitive, + RewardSharingConfig.BuiltIn(), + ), + ) + } catch { + case NonFatal(e) => + externalPartyRetryProvider.close() + throw e + } (externalPartyRetryProvider, walletService) } diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/ExternalPartyWalletService.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/ExternalPartyWalletService.scala index d2c96ebd44..aa4acf27cc 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/ExternalPartyWalletService.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/ExternalPartyWalletService.scala @@ -3,7 +3,7 @@ package org.lfdecentralizedtrust.splice.wallet -import com.digitalasset.canton.lifecycle.{CloseContext, FlagCloseable} +import com.digitalasset.canton.lifecycle.{CloseContext, FlagCloseable, LifeCycle} import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.resource.DbStorage import com.digitalasset.canton.time.Clock @@ -20,6 +20,7 @@ import org.lfdecentralizedtrust.splice.wallet.config.RewardSharingConfig import org.lfdecentralizedtrust.splice.wallet.store.ExternalPartyWalletStore import scala.concurrent.ExecutionContext +import scala.util.control.NonFatal /** A service managing the treasury, automation, and store for an external party's wallet. */ class ExternalPartyWalletService( @@ -59,23 +60,31 @@ class ExternalPartyWalletService( params.defaultLimit, ) - val automation = new ExternalPartyWalletAutomationService( - store, - ledgerClient, - automationConfig, - clock, - domainTimeSync, - retryProvider, - params, - scanConnection, - loggerFactory, - packageVersionSupport, - rewardSharingConfig, - ) + val automation = + try { + new ExternalPartyWalletAutomationService( + store, + ledgerClient, + automationConfig, + clock, + domainTimeSync, + retryProvider, + params, + scanConnection, + loggerFactory, + packageVersionSupport, + rewardSharingConfig, + ) + } catch { + // a failed construction never reaches onClosed, so close the store here + case NonFatal(e) => + store.close() + throw e + } override def onClosed(): Unit = { - automation.close() - store.close() + // LifeCycle.close closes both in order even if the first close fails. + LifeCycle.close(automation, store)(logger) super.onClosed() } } diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/UserWalletManager.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/UserWalletManager.scala index dcd52b5750..5e90a51ba6 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/UserWalletManager.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/UserWalletManager.scala @@ -37,6 +37,7 @@ import io.opentelemetry.api.trace.Tracer import scala.collection.concurrent.TrieMap import scala.concurrent.{blocking, ExecutionContext, Future} +import scala.util.control.NonFatal /** Manages all services comprising an end-user wallets. */ class UserWalletManager( @@ -185,8 +186,8 @@ class UserWalletManager( show"Detected race between adding wallet for party ${endUserParty} and shutdown: closing wallet." )(TraceContext.empty) userRetryProviderAndWalletService.foreach { case (userRetryProvider, walletService) => - userRetryProvider.close() - walletService.close() + try userRetryProvider.close() + finally walletService.close() } UnlessShutdown.AbortedDueToShutdown } else { @@ -213,29 +214,37 @@ class UserWalletManager( retryProvider.futureSupervisor, retryProvider.metricsFactory, ) - val walletService = new UserWalletService( - ledgerClient, - key, - this, - automationConfig, - clock, - domainTimeSync, - treasuryConfig, - storage, - userRetryProvider, - userLoggerFactory, - scanConnection, - packageVersionSupport, - migrationId, - participantId, - Option.when(endUserParty == store.walletKey.validatorParty)(validatorTopupConfig), - // TODO(DACH-NY/canton-network-node#12554): make it easier to configure the sweep functionality and guard better against operator errors (typos, etc.) - walletSweep.get(endUserParty.toProtoPrimitive), - autoAcceptTransfers.get(endUserParty.toProtoPrimitive), - rewardSharingConfigByParty.getOrElse(endUserParty.toProtoPrimitive, RewardSharingConfig()), - dedupDuration, - params, - ) + val walletService = + try { + new UserWalletService( + ledgerClient, + key, + this, + automationConfig, + clock, + domainTimeSync, + treasuryConfig, + storage, + userRetryProvider, + userLoggerFactory, + scanConnection, + packageVersionSupport, + migrationId, + participantId, + Option.when(endUserParty == store.walletKey.validatorParty)(validatorTopupConfig), + // TODO(DACH-NY/canton-network-node#12554): make it easier to configure the sweep functionality and guard better against operator errors (typos, etc.) + walletSweep.get(endUserParty.toProtoPrimitive), + autoAcceptTransfers.get(endUserParty.toProtoPrimitive), + rewardSharingConfigByParty + .getOrElse(endUserParty.toProtoPrimitive, RewardSharingConfig.BuiltIn()), + dedupDuration, + params, + ) + } catch { + case NonFatal(e) => + userRetryProvider.close() + throw e + } (userRetryProvider, walletService) } @@ -246,8 +255,8 @@ class UserWalletManager( .withDescription(show"No wallet service found for user party ${userParty}") .asRuntimeException() case Some((userRetryProvider, walletService)) => - userRetryProvider.close() - walletService.close() + try userRetryProvider.close() + finally walletService.close() } } diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/UserWalletService.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/UserWalletService.scala index 1b3923127f..3a8b7a316e 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/UserWalletService.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/UserWalletService.scala @@ -19,7 +19,7 @@ import org.lfdecentralizedtrust.splice.wallet.config.{ import org.lfdecentralizedtrust.splice.wallet.store.UserWalletStore import org.lfdecentralizedtrust.splice.wallet.treasury.TreasuryService import org.lfdecentralizedtrust.splice.wallet.util.ValidatorTopupConfig -import com.digitalasset.canton.lifecycle.{CloseContext, FlagCloseable} +import com.digitalasset.canton.lifecycle.{CloseContext, FlagCloseable, LifeCycle} import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.resource.DbStorage import com.digitalasset.canton.time.Clock @@ -30,6 +30,7 @@ import org.apache.pekko.stream.Materializer import org.lfdecentralizedtrust.splice.store.AppStoreWithIngestion.SpliceLedgerConnectionPriority import scala.concurrent.ExecutionContext +import scala.util.control.NonFatal /** A service managing the treasury, automation, and store for an end-user's wallet. */ class UserWalletService( @@ -78,47 +79,62 @@ class UserWalletService( params.defaultLimit, ) - val treasury: TreasuryService = new TreasuryService( - // The treasury gets its own connection, and is required to manage waiting for the store on its own. - ledgerClient.connection( - this.getClass.getSimpleName, - loggerFactory, - SpliceCircuitBreaker( - "treasury", - params.circuitBreakers.mediumPriority, + val treasury: TreasuryService = + try { + new TreasuryService( + // The treasury gets its own connection, and is required to manage waiting for the store on its own. + ledgerClient.connection( + this.getClass.getSimpleName, + loggerFactory, + SpliceCircuitBreaker( + "treasury", + params.circuitBreakers.mediumPriority, + clock, + store.dsoPartyId, + loggerFactory, + ), + ), + treasuryConfig, clock, - store.dsoPartyId, + store, + walletManager, + retryProvider, + scanConnection, + mintUnassignedRewardCouponsV2 = rewardSharingConfig.mintUnassignedCoupons, loggerFactory, - ), - ), - treasuryConfig, - clock, - store, - walletManager, - retryProvider, - scanConnection, - mintUnassignedRewardCouponsV2 = rewardSharingConfig.beneficiaries.isEmpty, - loggerFactory, - ) + ) + } catch { + // a failed construction never reaches onClosed, so close the store here + case NonFatal(e) => + store.close() + throw e + } - val automation = new UserWalletAutomationService( - store, - treasury, - ledgerClient, - automationConfig, - clock, - domainTimeSync, - scanConnection, - retryProvider, - packageVersionSupport, - loggerFactory, - validatorTopupConfigO, - walletSweep, - autoAcceptTransfers, - rewardSharingConfig, - dedupDuration, - params, - ) + val automation: UserWalletAutomationService = + try { + new UserWalletAutomationService( + store, + treasury, + ledgerClient, + automationConfig, + clock, + domainTimeSync, + scanConnection, + retryProvider, + packageVersionSupport, + loggerFactory, + validatorTopupConfigO, + walletSweep, + autoAcceptTransfers, + rewardSharingConfig, + dedupDuration, + params, + ) + } catch { + case NonFatal(e) => + LifeCycle.close(treasury, store)(logger) + throw e + } /** The connection to use when submitting commands based on reads from the WalletStore. * The submission will wait for the store to ingest the effect of the command before completing the future. @@ -132,9 +148,8 @@ class UserWalletService( // Close treasury early, that will result in it no longer accepting new requests // but in-flight requests can complete. If we close the automation first, // a task can get stuck forever waiting for store ingestion to complete. - treasury.close() - automation.close() - store.close() + // LifeCycle.close closes all of them in order even if one of them fails. + LifeCycle.close(treasury, automation, store)(logger) super.onClosed() } } diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/admin/http/HttpWalletHandler.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/admin/http/HttpWalletHandler.scala index 5219aef9d8..bb5b781c4d 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/admin/http/HttpWalletHandler.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/admin/http/HttpWalletHandler.scala @@ -557,6 +557,11 @@ class HttpWalletHandler( Codec.tryDecodeJavaContractId(subsCodegen.SubscriptionRequest.COMPANION)( contractId ) + val commandId = CommandId( + "org.lfdecentralizedtrust.splice.wallet.acceptSubscriptionRequest", + Seq(userWallet.store.key.endUserParty), + contractId, + ) retryProvider.retryForClientCalls( "accept_subscription", "Accept subscription and make initial payment", @@ -567,6 +572,13 @@ class HttpWalletHandler( d0.AcceptSubscriptionRequestResponse( Codec.encodeContractId(outcome.contractIdValue) ), + dedupConfig = Some( + AmuletOperationDedupConfig( + commandId, + dedupDuration, + recoverAcceptedDuplicates = true, + ) + ), ), logger, ) @@ -676,6 +688,7 @@ class HttpWalletHandler( AmuletOperationDedupConfig( commandId, dedupDuration, + recoverAcceptedDuplicates = true, ) ), ) @@ -798,6 +811,7 @@ class HttpWalletHandler( ), deduplicationOffset = dedupOffset, ) + .recoveringAcceptedDuplicates() .withSynchronizerId(domain) .yieldResult() .map(_.contractId) @@ -853,6 +867,7 @@ class HttpWalletHandler( body.deduplicationId, ), dedupDuration, + recoverAcceptedDuplicates = true, ) ), ) @@ -876,6 +891,7 @@ class HttpWalletHandler( val dedupConfig = AmuletOperationDedupConfig( commandId, dedupDuration, + recoverAcceptedDuplicates = true, ) (for { result <- userWallet.treasury.enqueueTokenStandardTransferOperationV1( @@ -1061,6 +1077,7 @@ class HttpWalletHandler( val dedupConfig = AmuletOperationDedupConfig( commandId, dedupDuration, + recoverAcceptedDuplicates = true, ) (for { result <- userWallet.treasury.enqueueTokenStandardTransferOperationV2( @@ -1224,6 +1241,7 @@ class HttpWalletHandler( val dedupConfig = AmuletOperationDedupConfig( commandId, dedupDuration, + recoverAcceptedDuplicates = true, ) for { result <- userWallet.treasury.enqueueAmuletAllocationOperation( @@ -1300,6 +1318,7 @@ class HttpWalletHandler( commandId, // Overriden to be low enough (5m) that we allow the same allocation to be re-created after being withdrawn DedupDuration(com.google.protobuf.Duration.newBuilder().setSeconds(5L * 60L).build()), + recoverAcceptedDuplicates = true, ) for { result <- userWallet.treasury.enqueueAmuletAllocationOperation( @@ -1813,6 +1832,7 @@ class HttpWalletHandler( ), deduplicationConfig = dedupDuration, ) + .recoveringAcceptedDuplicates() .withDisclosedContracts( userWallet.connection .disclosedContracts(amuletRules, unclaimedDevelopmentFundCouponsToAllocate*) diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/admin/http/HttpWalletHandlerUtil.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/admin/http/HttpWalletHandlerUtil.scala index 72ada24586..39d4e2e4e4 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/admin/http/HttpWalletHandlerUtil.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/admin/http/HttpWalletHandlerUtil.scala @@ -91,6 +91,7 @@ trait HttpWalletHandlerUtil extends Spanning with NamedLogging { priority = priority, ) .withDedup(commandId, dedupConfig) + .recoveringAcceptedDuplicates() .withDisclosedContracts(disclosedContracts(userWallet.connection)) .yieldResult() } diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/MintingDelegationCollectRewardsTrigger.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/MintingDelegationCollectRewardsTrigger.scala index b2e645547d..925ef6ad08 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/MintingDelegationCollectRewardsTrigger.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/MintingDelegationCollectRewardsTrigger.scala @@ -80,6 +80,8 @@ class MintingDelegationCollectRewardsTrigger( materializer: Materializer, ) extends PollingTrigger { + import MintingDelegationCollectRewardsTrigger.* + private def externalParty = store.key.externalParty override protected def extraMetricLabels = Seq("party" -> externalParty.toString) @@ -149,6 +151,20 @@ class MintingDelegationCollectRewardsTrigger( } } + // Handling of unassigned V2 coupons (no beneficiary yet) depends on the sharing mode: + // - No sharing (no beneficiaries, not external): mint them directly to ourselves. + // - InternalSharing (beneficiaries set): hold them back, assign to the configured + // beneficiaries first, then mint; already-assigned coupons mint directly. + // - ExternalSharing: hold them back and leave them untouched, so the + // off-node automation owns their assignment; only already-assigned coupons mint here. + private val mode: SharingMode = + rewardSharingConfig match { + case RewardSharingConfig.External(_) => ExternalSharing + case builtIn: RewardSharingConfig.BuiltIn if builtIn.automateRewardSharing => + InternalSharing(builtIn) + case _: RewardSharingConfig.BuiltIn => NoSharing + } + private def performMintIfNeeded( mintInputs: MintInputs, couponsData: CouponsData, @@ -162,30 +178,29 @@ class MintingDelegationCollectRewardsTrigger( val amuletsToMerge = selectAmuletsToMerge(amulets, mintInputs.delegation) val shouldMergeAmulets = amuletsToMerge.nonEmpty - // Without sharing config, all V2 coupons are mintable directly. - // With sharing config, only assigned-to-us V2 coupons are mintable; - // unassigned ones need sharing first. - val hasBeneficiaries = rewardSharingConfig.beneficiaries.nonEmpty - val (unassignedV2, mintableV2) = - if (hasBeneficiaries) + val (unassignedV2, mintableV2) = mode match { + case NoSharing => (Seq.empty, filteredCouponsData.rewardCouponsV2) + case InternalSharing(_) | ExternalSharing => filteredCouponsData.rewardCouponsV2.partition(_.payload.beneficiary.isEmpty) - else - (Seq.empty, filteredCouponsData.rewardCouponsV2) + } val couponsToMint = filteredCouponsData.copy(rewardCouponsV2 = mintableV2) + val submission = buildMintSubmissionData(mintInputs, couponsToMint, amuletsToMerge) // Share when the TTL threshold is reached, or batch sharing with // amulet merging to reduce traffic costs by combining both in one transaction. - val shouldAssign = unassignedV2.nonEmpty && - (shouldShareNow(unassignedV2, rewardSharingConfig) || shouldMergeAmulets) - - val submission = buildMintSubmissionData(mintInputs, couponsToMint, amuletsToMerge) - if (shouldAssign) { - performAssignAndMint(submission, unassignedV2.toList, rewardSharingConfig) - } else if (couponsToMint.hasRewards || shouldMergeAmulets) { - performMint(submission) - } else { - // Nothing to do: no rewards to mint, coupons to assign, or amulets to merge - Future.successful(false) + val hasSomethingToMint = couponsToMint.hasRewards || shouldMergeAmulets + mode match { + case InternalSharing(config) => + val shouldAssignAndMint = unassignedV2.nonEmpty && (shouldShareNow( + unassignedV2, + config, + ) || shouldMergeAmulets) + if (shouldAssignAndMint) performAssignAndMint(submission, unassignedV2.toList, config) + else if (hasSomethingToMint) performMint(submission) + else Future.successful(false) + case NoSharing | ExternalSharing => + if (hasSomethingToMint) performMint(submission) + else Future.successful(false) } } @@ -213,7 +228,7 @@ class MintingDelegationCollectRewardsTrigger( private def performAssignAndMint( submission: MintSubmissionData, unassignedV2: List[Contract[RewardCouponV2.ContractId, RewardCouponV2]], - config: RewardSharingConfig, + config: RewardSharingConfig.BuiltIn, )(implicit tc: TraceContext): Future[Boolean] = { unassignedV2 match { case Nil => @@ -436,7 +451,7 @@ class MintingDelegationCollectRewardsTrigger( private def shouldShareNow( coupons: Seq[Contract[RewardCouponV2.ContractId, RewardCouponV2]], - config: RewardSharingConfig, + config: RewardSharingConfig.BuiltIn, ): Boolean = { val now = context.clock.now.toInstant val minTtl = config.minTtlAfterSharing.asJava @@ -508,3 +523,10 @@ class MintingDelegationCollectRewardsTrigger( ) } } + +object MintingDelegationCollectRewardsTrigger { + private sealed trait SharingMode + private case object NoSharing extends SharingMode + private final case class InternalSharing(config: RewardSharingConfig.BuiltIn) extends SharingMode + private case object ExternalSharing extends SharingMode +} diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/RewardSharingTrigger.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/RewardSharingTrigger.scala index c9ed70ca64..40e5dff446 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/RewardSharingTrigger.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/RewardSharingTrigger.scala @@ -32,7 +32,7 @@ import scala.jdk.CollectionConverters.* class RewardSharingTrigger( override protected val context: TriggerContext, store: UserWalletStore, - config: RewardSharingConfig, + config: RewardSharingConfig.BuiltIn, spliceLedgerConnection: SpliceLedgerConnection, )(implicit override val ec: ExecutionContext, diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/UserWalletAutomationService.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/UserWalletAutomationService.scala index 9a597b9da4..23be2f128f 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/UserWalletAutomationService.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/UserWalletAutomationService.scala @@ -177,15 +177,18 @@ class UserWalletAutomationService( ) } - if (rewardSharingConfig.beneficiaries.nonEmpty) { - registerTrigger( - new RewardSharingTrigger( - triggerContext, - store, - rewardSharingConfig, - connection(SpliceLedgerConnectionPriority.Low), + rewardSharingConfig match { + case builtIn: RewardSharingConfig.BuiltIn if builtIn.automateRewardSharing => + registerTrigger( + new RewardSharingTrigger( + triggerContext, + store, + builtIn, + connection(SpliceLedgerConnectionPriority.Low), + ) ) - ) + case _: RewardSharingConfig.BuiltIn => () + case _: RewardSharingConfig.External => () } registerTrigger( diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/config/WalletAppConfig.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/config/WalletAppConfig.scala index 75d179a8ce..2cb4f9ba35 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/config/WalletAppConfig.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/config/WalletAppConfig.scala @@ -55,28 +55,55 @@ final case class AppRewardBeneficiaryConfig( percentage: BigDecimal, ) -/** Configuration for sharing traffic-based app reward coupons with beneficiaries. - * @param minTtlAfterSharing minimum remaining coupon TTL before sharing is triggered; - * e.g., 30h means share when 30h of coupon lifetime remains (6h after creation for 36h coupons) - * @param beneficiaries parties to share rewards with and their percentages; - * the provider keeps the remainder (1.0 - sum of percentages) - * @param batchSize maximum number of coupons to share or assign per trigger run - */ -final case class RewardSharingConfig( - minTtlAfterSharing: NonNegativeFiniteDuration = NonNegativeFiniteDuration.ofHours(30), - beneficiaries: Seq[AppRewardBeneficiaryConfig] = Seq.empty, - batchSize: Int = 100, -) { - def providerRemainder: BigDecimal = BigDecimal(1.0) - beneficiaries.map(_.percentage).sum - - @VisibleForTesting - def allBeneficiaries(provider: PartyId): Seq[AppRewardBeneficiaryConfig] = { - val remainder = providerRemainder - beneficiaries ++ - (if (remainder > 0) Seq(AppRewardBeneficiaryConfig(provider, remainder)) - else Seq.empty) +/** How traffic-based app reward coupons are shared with beneficiaries. */ +sealed trait RewardSharingConfig { + def mintUnassignedCoupons: Boolean + def automateRewardSharing: Boolean + def batchSize: Int +} + +object RewardSharingConfig { + + val DefaultBatchSize: Int = 100 + + /** Beneficiary assignment for RewardCouponV2 contracts is managed by a process + * external to the validator app: the validator app must thus leave unassigned + * coupons untouched rather than assigning or minting them itself. + */ + case class External( + batchSize: Int = DefaultBatchSize + ) extends RewardSharingConfig { + override def mintUnassignedCoupons: Boolean = false + override def automateRewardSharing: Boolean = false } - def allDamlBeneficiaries(provider: PartyId): Seq[(PartyId, java.math.BigDecimal)] = - allBeneficiaries(provider).map(b => (b.beneficiary, SpliceUtil.damlDecimal(b.percentage))) + /** The node performs beneficiary assignment and minting itself. + * @param minTtlAfterSharing minimum remaining coupon TTL before sharing is triggered; + * e.g., 30h means share when 30h of coupon lifetime remains (6h after creation for 36h coupons) + * @param beneficiaries parties to share rewards with and their percentages; + * the provider keeps the remainder (1.0 - sum of percentages) + * @param batchSize maximum number of coupons to share or assign per trigger run + */ + final case class BuiltIn( + minTtlAfterSharing: NonNegativeFiniteDuration = NonNegativeFiniteDuration.ofHours(30), + beneficiaries: Seq[AppRewardBeneficiaryConfig] = Seq.empty, + batchSize: Int = DefaultBatchSize, + ) extends RewardSharingConfig { + def providerRemainder: BigDecimal = BigDecimal(1.0) - beneficiaries.map(_.percentage).sum + + @VisibleForTesting + def allBeneficiaries(provider: PartyId): Seq[AppRewardBeneficiaryConfig] = { + val remainder = providerRemainder + beneficiaries ++ + (if (remainder > 0) Seq(AppRewardBeneficiaryConfig(provider, remainder)) + else Seq.empty) + } + + def allDamlBeneficiaries(provider: PartyId): Seq[(PartyId, java.math.BigDecimal)] = + allBeneficiaries(provider).map(b => (b.beneficiary, SpliceUtil.damlDecimal(b.percentage))) + + override def mintUnassignedCoupons: Boolean = beneficiaries.isEmpty + + override def automateRewardSharing: Boolean = beneficiaries.nonEmpty + } } diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/metrics/TreasuryMetrics.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/metrics/TreasuryMetrics.scala new file mode 100644 index 0000000000..95f26e1de9 --- /dev/null +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/metrics/TreasuryMetrics.scala @@ -0,0 +1,51 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.wallet.metrics + +import com.daml.metrics.api.MetricHandle.Gauge.CloseableGauge +import com.daml.metrics.api.MetricHandle.{LabeledMetricsFactory, Timer} +import com.daml.metrics.api.MetricQualification.{Latency, Saturation} +import com.daml.metrics.api.{MetricInfo, MetricName, MetricsContext} +import com.digitalasset.canton.topology.PartyId +import org.lfdecentralizedtrust.splice.environment.SpliceMetrics + +import java.time.Duration + +class TreasuryMetrics( + owner: PartyId, + metricsFactory: LabeledMetricsFactory, + queueSize: () => Long, +) extends AutoCloseable { + private val prefix: MetricName = SpliceMetrics.MetricsPrefix :+ "wallet" :+ "treasury" + + private val metricsContext: MetricsContext = + MetricsContext.Empty.withExtraLabels("owner" -> owner.toString) + + private val queueSizeGauge: CloseableGauge = + metricsFactory.closeableGaugeWithSupplier[Long]( + MetricInfo( + prefix :+ "queue-size", + summary = "Treasury operation queue size", + description = "The number of operations currently queued in the treasury service.", + qualification = Saturation, + ), + queueSize, + )(metricsContext) + + private val queueLatencyTimer: Timer = + metricsFactory.timer( + MetricInfo( + prefix :+ "queue-latency", + summary = "Treasury operation queueing latency", + description = + "The time an operation spent in the queue of the treasury service. Note: This is only time between enqueuing and dequeuing, it excludes actual request processing.", + qualification = Latency, + ) + )(metricsContext) + + def recordQueueLatency(latency: Duration): Unit = + queueLatencyTimer.update(latency)(MetricsContext.Empty) + + override def close(): Unit = queueSizeGauge.close() +} diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/treasury/TreasuryService.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/treasury/TreasuryService.scala index c1162d135f..2ab576f070 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/treasury/TreasuryService.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/treasury/TreasuryService.scala @@ -62,6 +62,7 @@ import org.lfdecentralizedtrust.splice.util.{ } import org.lfdecentralizedtrust.splice.wallet.UserWalletManager import org.lfdecentralizedtrust.splice.wallet.config.TreasuryConfig +import org.lfdecentralizedtrust.splice.wallet.metrics.TreasuryMetrics import org.lfdecentralizedtrust.splice.wallet.store.UserWalletStore import org.lfdecentralizedtrust.splice.wallet.treasury.TreasuryService.* import com.digitalasset.base.error.utils.ErrorDetails @@ -72,6 +73,7 @@ import com.digitalasset.canton.lifecycle.{ AsyncOrSyncCloseable, FlagCloseableAsync, RunOnClosing, + SyncCloseable, } import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} import com.digitalasset.canton.logging.{ @@ -108,7 +110,7 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.api.token.{ transferinstructionv2, } -import java.time.Instant +import java.time.{Duration, Instant} import java.util.Optional import scala.concurrent.{ExecutionContext, Future, Promise} import scala.jdk.CollectionConverters.* @@ -145,61 +147,71 @@ class TreasuryService( // Setting the weight > batch size ensures they go in a batch of their own private val BatchWithOneOperation = treasuryConfig.batchSize.toLong + 1L - private val queue: BoundedSourceQueue[EnqueuedOperation] = { + private val queue: BoundedSourceQueue[QueuedOperation] = { val queue = Source - .queue[EnqueuedOperation](treasuryConfig.queueSize) - .batchWeighted[OperationBatch]( + .queue[QueuedOperation](treasuryConfig.queueSize) + .batchWeighted[QueuedBatch]( treasuryConfig.batchSize.toLong, - { - case amuletOp: EnqueuedAmuletOperation => - if (amuletOp.priority == CommandPriority.High || amuletOp.dedup.isDefined) { + queued => + queued.operation match { + case amuletOp: EnqueuedAmuletOperation => + if (amuletOp.priority == CommandPriority.High || amuletOp.dedup.isDefined) { + BatchWithOneOperation + } else 1L + case _: EnqueuedTokenStandardTransferOperationV1 => BatchWithOneOperation - } else 1L - case _: EnqueuedTokenStandardTransferOperationV1 => - BatchWithOneOperation - case _: EnqueuedTokenStandardTransferOperationV2 => - BatchWithOneOperation - case _: EnqueuedAmuletAllocationOperation => - BatchWithOneOperation - case _: EnqueuedAmuletAllocationV2Operation => - BatchWithOneOperation - }, - { - case amuletOp: EnqueuedAmuletOperation => - AmuletOperationBatch(amuletOp) - case tsOp: EnqueuedTokenStandardTransferOperationV1 => - TokenStandardOperationV1Batch(tsOp) - case tsOp: EnqueuedTokenStandardTransferOperationV2 => - TokenStandardOperationV2Batch(tsOp) - case allOp: EnqueuedAmuletAllocationOperation => - AmuletAllocationOperationBatch(allOp) - case allOp: EnqueuedAmuletAllocationV2Operation => - AmuletAllocationV2OperationBatch(allOp) - }, - ) { - case (batch: AmuletOperationBatch, operation: EnqueuedAmuletOperation) => - batch.addCOToBatch(operation) - case (_: TokenStandardOperationV1Batch, _: EnqueuedTokenStandardTransferOperationV1) | - (_: TokenStandardOperationV2Batch, _: EnqueuedTokenStandardTransferOperationV2) => - throw new IllegalStateException( - "Token standard batches cannot contain more than one element. This is a bug." - ) - case (batch, operation) => - throw new IllegalStateException( - s"Batch is ${batch.getClass.getName} while operation is ${operation.getClass.getName}. This is a bug." - ) + case _: EnqueuedTokenStandardTransferOperationV2 => + BatchWithOneOperation + case _: EnqueuedAmuletAllocationOperation => + BatchWithOneOperation + case _: EnqueuedAmuletAllocationV2Operation => + BatchWithOneOperation + }, + queued => + QueuedBatch( + queued.operation match { + case amuletOp: EnqueuedAmuletOperation => + AmuletOperationBatch(amuletOp) + case tsOp: EnqueuedTokenStandardTransferOperationV1 => + TokenStandardOperationV1Batch(tsOp) + case tsOp: EnqueuedTokenStandardTransferOperationV2 => + TokenStandardOperationV2Batch(tsOp) + case allOp: EnqueuedAmuletAllocationOperation => + AmuletAllocationOperationBatch(allOp) + case allOp: EnqueuedAmuletAllocationV2Operation => + AmuletAllocationV2OperationBatch(allOp) + }, + Vector(queued.enqueuedAt), + ), + ) { (queuedBatch, queued) => + val batch = (queuedBatch.batch, queued.operation) match { + case (batch: AmuletOperationBatch, operation: EnqueuedAmuletOperation) => + batch.addCOToBatch(operation) + case (_: TokenStandardOperationV1Batch, _: EnqueuedTokenStandardTransferOperationV1) | + (_: TokenStandardOperationV2Batch, _: EnqueuedTokenStandardTransferOperationV2) => + throw new IllegalStateException( + "Token standard batches cannot contain more than one element. This is a bug." + ) + case (batch, operation) => + throw new IllegalStateException( + s"Batch is ${batch.getClass.getName} while operation is ${operation.getClass.getName}. This is a bug." + ) + } + QueuedBatch(batch, queuedBatch.enqueuedAts :+ queued.enqueuedAt) } - // Execute the batches sequentially to avoid contention - .mapAsync(1) { - case amuletBatch: AmuletOperationBatch => filterAndExecuteBatch(amuletBatch) - case TokenStandardOperationV1Batch(operation) => - executeTokenStandardTransferOperationV1(operation) - case TokenStandardOperationV2Batch(operation) => - executeTokenStandardTransferOperationV2(operation) - case AmuletAllocationOperationBatch(operation) => - executeAmuletAllocationOperation(operation) - case AmuletAllocationV2OperationBatch(operation) => - executeAmuletAllocationV2Operation(operation) + .mapAsync(1) { queuedBatch => + recordQueueLatencies(queuedBatch) + queuedBatch.batch match { + case amuletBatch: AmuletOperationBatch => filterAndExecuteBatch(amuletBatch) + case TokenStandardOperationV1Batch(operation) => + executeTokenStandardTransferOperationV1(operation) + case TokenStandardOperationV2Batch(operation) => + executeTokenStandardTransferOperationV2(operation) + case AmuletAllocationOperationBatch(operation) => + executeAmuletAllocationOperation(operation) + case AmuletAllocationV2OperationBatch(operation) => + executeAmuletAllocationV2Operation(operation) + } } .toMat( Sink.onComplete(result0 => { @@ -218,6 +230,12 @@ class TreasuryService( queue } + private val metrics: TreasuryMetrics = new TreasuryMetrics( + userStore.key.endUserParty, + retryProvider.metricsFactory, + () => queue.size().toLong, + ) + retryProvider.runOnOrAfterClose_(new RunOnClosing { override def name: String = s"terminate amulet operation batch executor" override def done: Boolean = queueTerminationResult.isCompleted @@ -239,7 +257,8 @@ class TreasuryService( "waiting for amulet operation batch executor shutdown", queueTerminationResult.future, timeouts.shutdownShort, - ) + ), + SyncCloseable("treasury metrics", metrics.close()), ) override def isHealthy: Boolean = !queueTerminationResult.isCompleted @@ -409,7 +428,7 @@ class TreasuryService( show"Received operation (queue size before adding this: ${queue.size()}): $operation" ) queue.offer( - operation + QueuedOperation(operation, clock.now) ) match { case Enqueued => logger.debug(show"Operation $operation enqueued successfully") @@ -431,6 +450,13 @@ class TreasuryService( } } + private def recordQueueLatencies(queuedBatch: QueuedBatch): Unit = { + val now = clock.now.toInstant + queuedBatch.enqueuedAts.foreach(enqueuedAt => + metrics.recordQueueLatency(Duration.between(enqueuedAt.toInstant, now)) + ) + } + private def closingException(operation: EnqueuedOperation) = Status.UNAVAILABLE .withDescription( @@ -648,7 +674,10 @@ class TreasuryService( (offset, result) <- batch.dedup match { case None => baseSubmission.noDedup.yieldResultAndOffset() case Some(dedup) => - baseSubmission.withDedup(dedup.commandId, dedup.config).yieldResultAndOffset() + baseSubmission + .withDedup(dedup.commandId, dedup.config) + .recoveringAcceptedDuplicates(dedup.recoverAcceptedDuplicates) + .yieldResultAndOffset() } // wait for store to ingest the new amulet holdings, then return all outcomes to the callers @@ -813,7 +842,10 @@ class TreasuryService( (offset, result) <- operation.dedup match { case None => baseSubmission.noDedup.yieldResultAndOffset() case Some(dedup) => - baseSubmission.withDedup(dedup.commandId, dedup.config).yieldResultAndOffset() + baseSubmission + .withDedup(dedup.commandId, dedup.config) + .recoveringAcceptedDuplicates(dedup.recoverAcceptedDuplicates) + .yieldResultAndOffset() } _ <- userStore.signalWhenIngestedOrShutdown(offset) } yield { @@ -1443,6 +1475,11 @@ object TreasuryService { ) } + private case class QueuedOperation(operation: EnqueuedOperation, enqueuedAt: CantonTimestamp) + + /** A batch together with the times at which its operations were put on the treasury queue. */ + private case class QueuedBatch(batch: OperationBatch, enqueuedAts: Vector[CantonTimestamp]) + private sealed trait EnqueuedOperation extends PrettyPrinting { type Result val outcomePromise: Promise[Result] @@ -1559,9 +1596,14 @@ object TreasuryService { } } + /** @param recoverAcceptedDuplicates + * set by client calls, so that a duplicate of an already-accepted submission returns the + * original result. Automation leaves it off and lets its own retry handle the duplicate. + */ final case class AmuletOperationDedupConfig( commandId: CommandId, config: DedupConfig, + recoverAcceptedDuplicates: Boolean = false, ) extends PrettyPrinting { override def pretty: Pretty[AmuletOperationDedupConfig.this.type] = prettyNode("DedupConfig", param("commandId", _.commandId), param("config", _.config)) diff --git a/build-tools/artifactory_to_gcs.py b/build-tools/artifactory_to_gcs.py deleted file mode 100755 index 086baff5ba..0000000000 --- a/build-tools/artifactory_to_gcs.py +++ /dev/null @@ -1,785 +0,0 @@ -#!/usr/bin/env python3 - -# Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -""" -Script to copy non-snapshot versions of Docker images or Helm charts -from JFrog Artifactory to Google Cloud Storage. - -Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -SPDX-License-Identifier: Apache-2.0 -""" - -import argparse -import json -import os -import re -import subprocess -import sys -import tempfile -import time -import urllib.parse -from dataclasses import dataclass -from typing import Dict, List, Optional, Set, Tuple, Union -import logging -import requests -from google.cloud import storage -from google.api_core import exceptions as gcp_exceptions - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - -# Define version regex pattern (matches semantic versioning) -VERSION_PATTERN = re.compile(r'^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$') -# Regex to match git hash versions (git- followed by a hash) -GIT_HASH_PATTERN = re.compile(r'^git-[0-9a-f]{5,40}$') - -@dataclass -class ArtifactInfo: - """Information about an artifact (Docker image or Helm chart)""" - name: str - version: str - path: str - repository: str - type: str # 'docker' or 'helm' - is_snapshot: bool = False - - def __str__(self) -> str: - return f"{self.name}:{self.version} ({self.type})" - - -def parse_args(): - """Parse command line arguments.""" - parser = argparse.ArgumentParser( - description="Copy non-snapshot Docker images or Helm charts from JFrog Artifactory to Google Cloud Storage" - ) - parser.add_argument( - "--artifactory-url", required=True, - help="JFrog Artifactory URL (e.g., https://artifactory.example.com)" - ) - parser.add_argument( - "--repository", required=True, - help="Artifactory repository name containing Docker images or Helm charts" - ) - parser.add_argument( - "--bucket", required=True, - help="Google Cloud Storage bucket name" - ) - parser.add_argument( - "--type", choices=["docker", "helm"], required=True, - help="Artifact type (docker or helm)" - ) - parser.add_argument( - "--api-key", - help="JFrog Artifactory API key (can also be set via ARTIFACTORY_API_KEY environment variable)" - ) - parser.add_argument( - "--username", - help="JFrog Artifactory username (can also be set via ARTIFACTORY_USERNAME environment variable)" - ) - parser.add_argument( - "--dry-run", action="store_true", - help="Show what would be copied without actually copying" - ) - parser.add_argument( - "--force", action="store_true", - help="Skip confirmation prompts" - ) - parser.add_argument( - "--allow-overwrite", action="store_true", - help="Allow overwriting existing files in the bucket" - ) - parser.add_argument( - "--prefix", default="", - help="Prefix to add to the destination path in the bucket" - ) - parser.add_argument( - "--max-retries", type=int, default=5, - help="Maximum number of retries for failed operations" - ) - parser.add_argument( - "--verbose", action="store_true", - help="Enable verbose logging" - ) - parser.add_argument( - "--include-path-pattern", - help="Regular expression to filter artifacts by path" - ) - parser.add_argument( - "--exclude-path-pattern", - help="Regular expression to exclude artifacts by path" - ) - parser.add_argument( - "--policy-file", default="skopeo_policy.json", - help="Path to the skopeo policy JSON file" - ) - parser.add_argument( - "--save-artifacts-file", - help="Save fetched artifacts to a file for later reuse" - ) - parser.add_argument( - "--load-artifacts-file", - help="Load artifacts from a file instead of fetching from Artifactory" - ) - parser.add_argument( - "--docker-registry-url", required=True, - help="External URL of the Docker registry (e.g., myregistry.jfrog.io), required for skopeo copy command" - ) - return parser.parse_args() - - -def check_credentials(args): - """Check and retrieve credentials for Artifactory and Google Cloud.""" - # Check Artifactory credentials - artifactory_api_key = args.api_key or os.environ.get('ARTIFACTORY_API_KEY') - artifactory_username = args.username or os.environ.get('ARTIFACTORY_USERNAME') - - if not artifactory_api_key and not artifactory_username: - logger.error("Artifactory credentials not provided. Set --api-key/--username or ARTIFACTORY_API_KEY/ARTIFACTORY_USERNAME environment variables.") - sys.exit(1) - - # Verify Google Cloud credentials are available - try: - # Attempt to initialize a client to check if credentials are available - storage.Client() - except Exception as e: - logger.error(f"Google Cloud credentials not found or invalid: {e}") - logger.error("Make sure you have set up Google Cloud credentials (run 'gcloud auth application-default login')") - sys.exit(1) - - return { - "artifactory_api_key": artifactory_api_key, - "artifactory_username": artifactory_username - } - - -def fetch_artifacts(artifactory_url: str, repository: str, artifact_type: str, credentials: Dict, - include_pattern: Optional[str] = None, exclude_pattern: Optional[str] = None) -> List[ArtifactInfo]: - """ - Fetch non-snapshot artifacts from Artifactory. - - Args: - artifactory_url: Base URL of the Artifactory instance - repository: Repository name in Artifactory - artifact_type: 'docker' or 'helm' - credentials: Dictionary containing authentication credentials - include_pattern: Optional regex pattern to include artifacts - exclude_pattern: Optional regex pattern to exclude artifacts - - Returns: - List of ArtifactInfo objects representing non-snapshot artifacts - """ - - logger.info(f"Fetching {artifact_type} artifacts from {artifactory_url}/artifactory/{repository}") - - api_url = f"{artifactory_url}/artifactory/api/storage/{repository}" - headers = {} - - # Set up authentication - if credentials.get("artifactory_api_key"): - headers["X-JFrog-Art-Api"] = credentials["artifactory_api_key"] - elif credentials.get("artifactory_username"): - auth = requests.auth.HTTPBasicAuth( - credentials["artifactory_username"], - os.environ.get("ARTIFACTORY_PASSWORD", "") - ) - else: - auth = None - - # Compile regex patterns if provided - include_regex = re.compile(include_pattern) if include_pattern else None - exclude_regex = re.compile(exclude_pattern) if exclude_pattern else None - - # For recursive traversal of the repository - artifacts = [] - - def traverse_directory(path: str, current_depth: int = 0, current_name: str = None): - """ - Traverse directory recursively with optimized strategy: - - For Docker: Only go into version-named directories, stop once manifest.json is found - - For Helm: Regular traversal looking for .tgz files - - Args: - path: Current path in Artifactory repository - current_depth: Track depth in traversal for Docker optimization - current_name: Keep track of the container/chart name while traversing - """ - url = f"{api_url}{path}" - try: - if credentials.get("artifactory_api_key"): - response = requests.get(url, headers=headers) - else: - response = requests.get(url, auth=auth) - - response.raise_for_status() - data = response.json() - - for child in data.get("children", []): - child_path = f"{path}/{child['uri']}" if path else child["uri"] - - if logger.level <= logging.DEBUG: - logger.debug(f"Examining path: {child_path}") - - # Skip if path doesn't match include pattern - if include_regex and not include_regex.search(child_path): - continue - - # Skip if path matches exclude pattern - if exclude_regex and exclude_regex.search(child_path): - continue - - # For Docker repositories, use optimized traversal strategy - if artifact_type == "docker": - if child["folder"]: - # Skip any sha256 directories - if "/sha256:" in child_path or "sha256:" in child_path or "sha256__" in child_path or GIT_HASH_PATTERN.match(child_path.split("/")[-1]): - logger.debug(f"Skipping directory: {child_path}") - continue - - # Get the folder name (the last part of the path) - folder_name = child['uri'].strip("/") - - # Check if this folder looks like a semantic version - is_version = VERSION_PATTERN.match(folder_name) is not None - is_version = is_version and GIT_HASH_PATTERN.match(folder_name) is None - - # If it's a version directory, traverse into it but remember we're in a version dir - # Otherwise, continue regular traversal without changing depth - next_depth = current_depth + 1 if is_version else current_depth - - # If we're at depth 0, this is potentially a container name folder - next_name = folder_name if current_depth == 0 else current_name - - traverse_directory(child_path, next_depth, next_name) - # For files, only consider manifest files in version directories (depth = 1) - elif current_depth == 1 and ('manifest.json' in child['uri'] or child['uri'] == 'manifest.json'): - # We found a manifest in a version directory - this is what we're looking for - logger.debug(f"Found manifest in version directory: {child_path}") - - # Extract version from path (should be the directory containing manifest.json) - # Handle paths that might have double slashes by normalizing the path first - normalized_path = child_path.replace("//", "/").strip("/") - logger.debug(f"Found normalized path: {normalized_path}") - path_parts = [part for part in normalized_path.split("/") if part] - if len(path_parts) < 2: - continue - logger.debug(f"Found normalized path: {path_parts}") - version = path_parts[-2] # Version is the directory containing manifest.json - - name = "/".join(path_parts[:-2]) - if not name: - logger.debug(f"Could not determine artifact name from path: {normalized_path}") - continue - - # Create artifact if it's a valid semantic version - if VERSION_PATTERN.match(version): - is_snapshot = "SNAPSHOT" in version or "-snapshot" in version.lower() - if not is_snapshot: - logger.debug(f"Found Docker artifact - Name: {name}, Version: {version}") - artifact = ArtifactInfo( - name=name, - version=version, - path=child_path, - repository=repository, - type=artifact_type, - is_snapshot=False - ) - logger.debug(f"Adding artifact: {artifact}") - artifacts.append(artifact) - - # For Helm repositories, use regular traversal looking for .tgz files - else: - if child["folder"]: - traverse_directory(child_path, current_depth + 1, current_name) - elif is_version_artifact(child_path, artifact_type): - logger.debug(f"Found potential helm artifact: {child_path}") - artifact = get_artifact_details(child_path, artifactory_url, repository, - artifact_type, credentials) - if artifact and not artifact.is_snapshot: - logger.debug(f"Adding helm artifact: {artifact}") - artifacts.append(artifact) - - except requests.RequestException as e: - logger.error(f"Error fetching artifacts from {url}: {e}") - - # Start traversal from the repository root - traverse_directory("") - - logger.info(f"Found {len(artifacts)} non-snapshot {artifact_type} artifacts") - return artifacts - - -def is_version_artifact(path: str, artifact_type: str) -> bool: - """ - Check if the path appears to point to a versioned artifact. - - This is a preliminary check before fetching detailed metadata. - """ - if artifact_type == "docker": - # Docker repositories typically have a manifest.json file for each tag - return path.endswith("manifest.json") - elif artifact_type == "helm": - # Helm charts typically have a .tgz extension - return path.endswith(".tgz") - - return False - - -def get_artifact_details(path: str, artifactory_url: str, repository: str, - artifact_type: str, credentials: Dict) -> Optional[ArtifactInfo]: - """ - Get detailed information about an artifact. - - Args: - path: Path to the artifact within the repository - artifactory_url: Base URL of the Artifactory instance - repository: Repository name in Artifactory - artifact_type: 'docker' or 'helm' - credentials: Dictionary containing authentication credentials - - Returns: - ArtifactInfo object if the artifact is a valid release version, None otherwise - """ - # Extract version from the path - if artifact_type == "docker": - # For Docker, extract from path like "myimage/1.2.3/manifest.json" - path_parts = path.strip("/").split("/") - if len(path_parts) < 2: - return None - - # The version should be the part before the manifest.json - potential_version = path_parts[-2] - # The name is everything except the version and manifest.json - name = "/".join(path_parts[:-2]) - - elif artifact_type == "helm": - # For Helm, extract from path like "charts/mychart-1.2.3.tgz" - if not path.endswith(".tgz"): - return None - - # Extract the filename without extension - filename = os.path.basename(path)[:-4] # Remove .tgz - - # Try to split name and version (usually separated by a hyphen) - name_version = filename.rsplit("-", 1) - if len(name_version) != 2: - return None - - name = name_version[0] - potential_version = name_version[1] - else: - return None - - # Verify this is a semantic version and not a snapshot - if not VERSION_PATTERN.match(potential_version): - return None - - is_snapshot = "SNAPSHOT" in potential_version or "-snapshot" in potential_version.lower() - - return ArtifactInfo( - name=name, - version=potential_version, - path=path, - repository=repository, - type=artifact_type, - is_snapshot=is_snapshot - ) - - -def check_destination_exists(bucket_name: str, prefix: str, repository: str) -> bool: - """ - Check if the destination folder already exists in the bucket. - - Args: - bucket_name: Name of the GCS bucket - prefix: Optional prefix to add to the destination path - repository: Repository name which will be part of the destination path - - Returns: - True if the destination folder exists and contains files, False otherwise - """ - client = storage.Client() - - try: - bucket = client.get_bucket(bucket_name) - except gcp_exceptions.NotFound: - logger.warning(f"Bucket {bucket_name} not found. Will be created if not in dry-run mode.") - return False - - # Construct the destination prefix - destination = f"{prefix}/{repository}".strip("/") - if not destination: - # If we're copying to the root of the bucket, we need to check if there are any files - blobs = list(bucket.list_blobs(max_results=1)) - return len(blobs) > 0 - - # Check if there are any objects with this prefix - blobs = list(bucket.list_blobs(prefix=destination, max_results=1)) - return len(blobs) > 0 - - -def download_artifact(artifact: ArtifactInfo, artifactory_url: str, credentials: Dict, policy_file: str, docker_registry_url: str) -> str: - """ - Download an artifact from Artifactory to a temporary file. - - Args: - artifact: ArtifactInfo object for the artifact to download - artifactory_url: Base URL of the Artifactory instance - credentials: Dictionary containing authentication credentials - policy_file: Path to the skopeo policy JSON file - docker_registry_url: External Docker registry URL (required for Docker artifacts) - - Returns: - Path to the downloaded temporary file - - Raises: - Exception: If download fails - """ - # For docker, we need to construct the download URL differently - if artifact.type == "docker": - # We need to download the Docker image using skopeo - # First create a temp directory - temp_dir = tempfile.mkdtemp() - # Construct the Docker source URL - docker_url = f"{docker_registry_url}/{artifact.name}:{artifact.version}" - - # Prepare credentials for skopeo - creds_args = [] - if credentials.get("artifactory_username"): - creds_args = ["--src-creds", f"{credentials['artifactory_username']}:{os.environ.get('ARTIFACTORY_PASSWORD', '')}"] - - # Run skopeo to download the image - cmd = [ - "skopeo", "copy", "--policy" , policy_file, "--all", - f"docker://{docker_url}", - f"dir:{temp_dir}", - *creds_args - ] - - logger.debug(f"Running command: {' '.join(cmd)}") - result = subprocess.run(cmd, capture_output=True, text=True) - - if result.returncode != 0: - logger.error(f"Failed to download Docker image: {result.stderr}") - raise Exception(f"Failed to download {artifact}: {result.stderr}") - - return temp_dir - - else: # Helm chart - # Construct the download URL - download_url = f"{artifactory_url}/artifactory/{artifact.repository}/{artifact.path}" - - # Prepare authentication - if credentials.get("artifactory_api_key"): - headers = {"X-JFrog-Art-Api": credentials["artifactory_api_key"]} - auth = None - else: - headers = {} - auth = requests.auth.HTTPBasicAuth( - credentials["artifactory_username"], - os.environ.get("ARTIFACTORY_PASSWORD", "") - ) - - # Download the file - response = requests.get(download_url, headers=headers, auth=auth, stream=True) - response.raise_for_status() - - # Create a temporary file and write the content - fd, temp_path = tempfile.mkstemp() - with os.fdopen(fd, 'wb') as temp_file: - for chunk in response.iter_content(chunk_size=8192): - temp_file.write(chunk) - - return temp_path - - -def upload_to_gcs(local_path: str, artifact: ArtifactInfo, bucket_name: str, - prefix: str, allow_overwrite: bool) -> bool: - """ - Upload an artifact to Google Cloud Storage. - - Args: - local_path: Path to the local file or directory to upload - artifact: ArtifactInfo object for the artifact being uploaded - bucket_name: Name of the GCS bucket - prefix: Optional prefix to add to the destination path - allow_overwrite: Whether to allow overwriting existing files - - Returns: - True if upload was successful, False otherwise - """ - client = storage.Client() - - try: - bucket = client.get_bucket(bucket_name) - except gcp_exceptions.NotFound: - logger.warning(f"Bucket {bucket_name} not found, creating it...") - bucket = client.create_bucket(bucket_name) - - # Construct the destination path - destination_base = f"{prefix}/{artifact.repository}/{artifact.name}/{artifact.version}".strip("/") - - if artifact.type == "docker": - # For Docker, we need to upload all files in the directory - success = True - for root, _, files in os.walk(local_path): - for file in files: - local_file_path = os.path.join(root, file) - rel_path = os.path.relpath(local_file_path, local_path) - destination_path = f"{destination_base}/{rel_path}" - - blob = bucket.blob(destination_path) - - # Check if the blob already exists - if blob.exists() and not allow_overwrite: - logger.warning(f"File {destination_path} already exists in bucket and overwrite not allowed") - success = False - continue - - # Upload the file - blob.upload_from_filename(local_file_path) - logger.info(f"Uploaded {rel_path} to gs://{bucket_name}/{destination_path}") - - return success - else: # Helm chart - # For Helm charts, just upload the single file - chart_name = os.path.basename(artifact.path) - destination_path = f"{destination_base}/{chart_name}" - - blob = bucket.blob(destination_path) - - # Check if the blob already exists - if blob.exists() and not allow_overwrite: - logger.warning(f"File {destination_path} already exists in bucket and overwrite not allowed") - return False - - # Upload the file - blob.upload_from_filename(local_path) - logger.info(f"Uploaded {chart_name} to gs://{bucket_name}/{destination_path}") - - return True - - -def process_artifact(artifact: ArtifactInfo, artifactory_url: str, docker_registry_url: str, bucket_name: str, - prefix: str, credentials: Dict, policy_file: str, allow_overwrite: bool, - dry_run: bool, max_retries: int) -> bool: - """ - Process a single artifact - download and upload to GCS. - - Args: - artifact: ArtifactInfo object for the artifact to process - artifactory_url: Base URL of the Artifactory instance - bucket_name: Name of the GCS bucket - prefix: Optional prefix to add to the destination path - credentials: Dictionary containing authentication credentials - policy_file: Path to the skopeo policy JSON file - allow_overwrite: Whether to allow overwriting existing files - dry_run: If True, don't actually download or upload - max_retries: Maximum number of retry attempts - - Returns: - True if successful, False otherwise - """ - if dry_run: - logger.info(f"Would copy {artifact} to gs://{bucket_name}/{prefix}/{artifact.repository}/{artifact.name}/{artifact.version}") - return True - - temp_path = None - success = False - - for attempt in range(max_retries): - try: - # Download the artifact - logger.info(f"Downloading {artifact}") - temp_path = download_artifact(artifact, artifactory_url, credentials, policy_file, docker_registry_url) - - # Upload to GCS - logger.info(f"Uploading {artifact} to gs://{bucket_name}") - success = upload_to_gcs(temp_path, artifact, bucket_name, prefix, allow_overwrite) - - if success: - break - - except Exception as e: - logger.warning(f"Attempt {attempt + 1}/{max_retries} failed: {e}") - if attempt == max_retries - 1: - logger.error(f"Failed to process {artifact} after {max_retries} attempts") - return False - - # Wait before retrying - time.sleep(2 ** attempt) # Exponential backoff - - finally: - # Clean up temporary files - if temp_path: - if artifact.type == "docker": - # Remove the directory - subprocess.run(["rm", "-rf", temp_path], check=False) - else: - # Remove the file - os.remove(temp_path) - - return success -def save_artifacts_to_file(artifacts: List[ArtifactInfo], filename: str): - """Save artifacts to a JSON file for later reuse.""" - artifacts_data = [ - { - "name": a.name, - "version": a.version, - "path": a.path, - "repository": a.repository, - "type": a.type, - "is_snapshot": a.is_snapshot - } - for a in artifacts - ] - - with open(filename, 'w') as f: - json.dump(artifacts_data, f, indent=2) - - logger.info(f"Saved {len(artifacts)} artifacts to {filename}") - - -def load_artifacts_from_file(filename: str) -> List[ArtifactInfo]: - """Load artifacts from a previously saved JSON file.""" - with open(filename, 'r') as f: - artifacts_data = json.load(f) - - artifacts = [ - ArtifactInfo( - name=item["name"], - version=item["version"], - path=item["path"], - repository=item["repository"], - type=item["type"], - is_snapshot=item.get("is_snapshot", False) - ) - for item in artifacts_data - ] - - logger.info(f"Loaded {len(artifacts)} artifacts from {filename}") - return artifacts - - -def main(): - args = parse_args() - - if args.verbose: - logger.setLevel(logging.DEBUG) - - logger.info("Starting artifact copy from JFrog Artifactory to Google Cloud Storage") - - # Check credentials - credentials = check_credentials(args) - artifacts = [] - if args.load_artifacts_file: - artifacts = load_artifacts_from_file(args.load_artifacts_file) - else: - # Fetch artifacts from Artifactory - artifacts = fetch_artifacts( - args.artifactory_url, - args.repository, - args.type, - credentials, - args.include_path_pattern, - args.exclude_path_pattern - ) - if args.save_artifacts_file: - save_artifacts_to_file(artifacts, args.save_artifacts_file) - - if not artifacts: - logger.warning(f"No non-snapshot {args.type} artifacts found in {args.repository}") - return - - # Check if destination exists - destination_exists = check_destination_exists(args.bucket, args.prefix, args.repository) - - # In dry-run mode, just print what would be copied - if args.dry_run: - print("\nThe following artifacts would be copied:") - for artifact in artifacts: - print(f" {artifact} -> gs://{args.bucket}/{args.prefix}/{args.repository}/{artifact.name}/{artifact.version}") - print(f"\nTotal: {len(artifacts)} artifacts") - - if destination_exists: - print("\nWARNING: Destination folder already exists and may contain files that would be overwritten.") - - return - - # Ask for confirmation - if not args.force: - if destination_exists and not args.allow_overwrite: - print("\nWARNING: Destination folder already exists and may contain files.") - print("Use --allow-overwrite to allow overwriting existing files.") - print("Use --force to skip this confirmation.") - response = input("\nDo you want to continue without overwriting? (yes/no): ").lower() - else: - print(f"\nAbout to copy {len(artifacts)} artifacts from {args.artifactory_url}/artifactory/{args.repository}") - print(f"to gs://{args.bucket}/{args.prefix}/{args.repository}/") - - if destination_exists and args.allow_overwrite: - print("\nWARNING: Destination folder exists and files may be overwritten.") - - print("\nArtifacts to be copied:") - for i, artifact in enumerate(artifacts[:10]): # Show first 10 - print(f" {artifact}") - - if len(artifacts) > 10: - print(f" ... and {len(artifacts) - 10} more") - - response = input("\nDo you want to continue? (yes/no): ").lower() - - if response != "yes": - print("Operation cancelled.") - return - - # Process all artifacts - success_count = 0 - failed_artifacts = [] - - for i, artifact in enumerate(artifacts, 1): - logger.info(f"Processing artifact {i}/{len(artifacts)}: {artifact}") - - success = process_artifact( - artifact, - args.artifactory_url, - args.docker_registry_url, - args.bucket, - args.prefix, - credentials, - args.policy_file, - args.allow_overwrite, - False, # Not dry-run - args.max_retries - ) - - if success: - success_count += 1 - else: - failed_artifacts.append(artifact) - - # Print summary - print("\nCopy operation completed:") - print(f" Successfully copied: {success_count}/{len(artifacts)} artifacts") - - if failed_artifacts: - print(f" Failed to copy: {len(failed_artifacts)} artifacts") - print("\nFailed artifacts:") - for artifact in failed_artifacts: - print(f" {artifact}") - - if success_count == len(artifacts): - print("\nAll artifacts copied successfully!") - else: - print("\nNot all artifacts were copied successfully. See log for details.") - sys.exit(1) - - -if __name__ == "__main__": - main() - diff --git a/build-tools/bump-canton.sh b/build-tools/bump-canton.sh index 87d05201d1..f3c01b1ef3 100755 --- a/build-tools/bump-canton.sh +++ b/build-tools/bump-canton.sh @@ -35,6 +35,6 @@ set_value oss_sha256 "$oss_nix256" for img in base participant mediator sequencer; do _info "Fetching image sha256 for canton-$img..." - sha=$(skopeo inspect --override-os linux --override-arch amd64 "docker://europe-docker.pkg.dev/da-images/public-all/docker/canton-$img:${NEW_VERSION}" --format '{{.Digest}}') + sha=$(skopeo inspect --no-creds --override-os linux --override-arch amd64 "docker://europe-docker.pkg.dev/da-images/public-all/docker/canton-$img:${NEW_VERSION}" --format '{{.Digest}}') set_value "canton_${img}_image_sha256" "$sha" done diff --git a/build-tools/cncluster b/build-tools/cncluster index 23c58d86e0..0e7a5e0114 100755 --- a/build-tools/cncluster +++ b/build-tools/cncluster @@ -312,7 +312,6 @@ declare -A subcommand_whitelist source "${TOOLS_LIB}/pulumi-helpers" source "${TOOLS_LIB}/pulumi-commands" -source "${TOOLS_LIB}/hard-domain-migration-commands" source "${TOOLS_LIB}/logical-synchronizer-upgrade-commands" source "${TOOLS_LIB}/disaster-recovery-commands" @@ -1949,7 +1948,7 @@ function subcmd_restore_node() { _info "Silencing SV report creation alerts for $sv_name for 1 hour" subcmd_silence_grafana_alerts "1 hour" "alertname=Report Creation Time Lag" "report_publisher=$sv_name" _info "Restoring sv node $node" - SPLICE_SV=$node SPLICE_MIGRATION_ID=$migration_id "$SPLICE_ROOT"/cluster/scripts/node-restore.sh $force "$node" "$migration_id" "$backup_run_id" cometbft sequencer participant mediator cn-apps + SPLICE_SV=$node SPLICE_MIGRATION_ID=$migration_id "$SPLICE_ROOT"/cluster/scripts/node-restore.sh $force "$node" "$migration_id" "$backup_run_id" cometbft cantonBft sequencer participant mediator cn-apps ;; validator1|splitwell) _info "Restoring validator node $node" @@ -2337,23 +2336,36 @@ function subcmd_psql() { local APPLICATION="$2" _info "Retrieving DB connection info from pod description..." - local DB_INIT_COMMAND - DB_INIT_COMMAND=$( + local DB_INIT_SCRIPT + DB_INIT_SCRIPT=$( kubectl get deployment \ --namespace "${NAMESPACE}" \ --selector "app=${APPLICATION}" \ -o 'jsonpath={..spec.template.spec.initContainers[0].command}' \ - | jq 'join(" ")' \ - | grep -i psql \ - | sed -nE 's/^.*(psql.?*) 2>&1.*$/\1/p' + | jq -r 'join("\n")' ) + # There can be multiple psql invocations, pick the one that contains create database. + local DB_INIT_COMMAND + DB_INIT_COMMAND=$( + grep -F 'create database' <<< "${DB_INIT_SCRIPT}" \ + | sed -nE 's/^.*(psql .*) 2>&1.*$/\1/p' \ + | tail -n 1 + ) || true + if [ -z "${DB_INIT_COMMAND}" ]; then _error "Application ${APPLICATION} in namespace ${NAMESPACE} does not have an associated database." exit 1 fi - eval "set -- $DB_INIT_COMMAND" + # The participant does not directly inline the db name so we resolve things here. + local DB_INIT_VARS + DB_INIT_VARS=$( + sed -nE "s/^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*='[^']*')[[:space:]]*$/local \1/p" <<< "${DB_INIT_SCRIPT}" + ) || true + + eval "${DB_INIT_VARS} + set -- ${DB_INIT_COMMAND}" while [ $# -gt 0 ]; do case "$1" in -h|--host) diff --git a/build-tools/copy_release_helm_charts_to_ghcr.sh b/build-tools/copy_release_helm_charts_to_ghcr.sh index db84c501dd..1012bd2521 100755 --- a/build-tools/copy_release_helm_charts_to_ghcr.sh +++ b/build-tools/copy_release_helm_charts_to_ghcr.sh @@ -3,7 +3,7 @@ # Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Copies release helm charts for app charts defined in app_charts_file from artifactory to ghcr.io +# Copies release helm charts for app charts defined in app_charts_file from dev to release in ghcr set -eou pipefail VERSION="" diff --git a/build-tools/copy_release_images_to_ghcr.sh b/build-tools/copy_release_images_to_ghcr.sh index a41574a64e..867315d822 100755 --- a/build-tools/copy_release_images_to_ghcr.sh +++ b/build-tools/copy_release_images_to_ghcr.sh @@ -3,7 +3,7 @@ # Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Copies release docker images from artifactory to ghcr.io +# Copies release docker images from dev to release to ghcr.io # skopeo is used to copy multi-arch images correctly # Note: skopeo in nix does not work, complains about policy.json, version from brew install (skopeo version 1.17.0) works fine. # There could be a fix here, though it is a super old link ¯\_(ツ)_/¯: https://github.com/NixOS/nixpkgs/commit/365d07cea0446cbdc3d2c89502ce62c1f283989b @@ -85,8 +85,6 @@ for VERSION in $VERSIONS; do TARGET_IMAGE="$DEST_REGISTRY/$IMAGE_NAME:$TAG" for i in {1..10}; do - # Some images have been copied before from Artifactory, which is not used anymore. - # Artifactory has unknown/unknown attestation manifests, which show up as unknown/unknown os/architecture manifests. There is nothing inherently wrong with this. # skopeo on nix does not bundle the policy.json file, so we need to provide it. if skopeo copy --policy "${SPLICE_ROOT}"/build-tools/skopeo_policy.json --all docker://"$SOURCE_IMAGE" docker://"$TARGET_IMAGE"; then echo "Successfully copied $SOURCE_IMAGE to $TARGET_IMAGE" diff --git a/build-tools/dar-lock-checker/src/main/scala/org/lfdecentralizedtrust/splice/build_tools/DarLockChecker.scala b/build-tools/dar-lock-checker/src/main/scala/org/lfdecentralizedtrust/splice/build_tools/DarLockChecker.scala index b8720005b3..26c2a2ba26 100644 --- a/build-tools/dar-lock-checker/src/main/scala/org/lfdecentralizedtrust/splice/build_tools/DarLockChecker.scala +++ b/build-tools/dar-lock-checker/src/main/scala/org/lfdecentralizedtrust/splice/build_tools/DarLockChecker.scala @@ -121,15 +121,7 @@ object DarLockChecker { val currentHashes = File(outputFilename).contentAsString val lockStr = getLockStr(checkedInDarMap ++ darMap) if (currentHashes != lockStr) - sys.error( - Seq( - "Error: daml lockfile is not up-to-date", - "Expected:", - lockStr, - "Actual:", - currentHashes, - ).mkString(System.lineSeparator()) - ) + sys.error(lockOutOfDateMessage(outputFilename, lockStr)) case "update" => // Check that the freshly built packages either match the // last release or have a different version number. @@ -263,6 +255,33 @@ object DarLockChecker { .sorted .mkString(System.lineSeparator()) + private[build_tools] def lockOutOfDateMessage( + currentLockFile: String, + expectedLockStr: String, + diffColor: String = if (sys.env.contains("CI")) "never" else "always", + ): String = + File.temporaryFile(prefix = "expected-dars", suffix = ".lock") { expectedFile => + val _ = expectedFile.write(expectedLockStr) + + val out = new StringBuilder("") + def appendToOut(s: String): Unit = out ++= s + System.lineSeparator() + val _ = Seq( + "diff", + s"--color=$diffColor", + "--unified=0", + s"--label=$currentLockFile", + "--label=expected-dars.lock", + currentLockFile, + expectedFile.toString, + ).!(ProcessLogger(appendToOut, appendToOut)) + + Seq( + "Error: daml lockfile is not up-to-date", + "", + out.toString, + ).mkString(System.lineSeparator()) + } + private def getCheckedInDarMap(): Map[(PackageName, PackageVersion), String] = { val checkedInDars = File("daml/dars").list(_.extension == Some(".dar")).toSeq toDarMap(readDars(checkedInDars.map(_.toString()))) diff --git a/build-tools/dar-lock-checker/src/test/scala/org/lfdecentralizedtrust/splice/build_tools/DarLockCheckerTest.scala b/build-tools/dar-lock-checker/src/test/scala/org/lfdecentralizedtrust/splice/build_tools/DarLockCheckerTest.scala index 85efab7cb2..fa503d98e0 100644 --- a/build-tools/dar-lock-checker/src/test/scala/org/lfdecentralizedtrust/splice/build_tools/DarLockCheckerTest.scala +++ b/build-tools/dar-lock-checker/src/test/scala/org/lfdecentralizedtrust/splice/build_tools/DarLockCheckerTest.scala @@ -3,6 +3,7 @@ package org.lfdecentralizedtrust.splice.build_tools +import better.files.* import com.digitalasset.daml.lf.data.Ref.{PackageName, PackageVersion} import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec @@ -13,6 +14,52 @@ class DarLockCheckerTest extends AnyWordSpec with Matchers { private def ver(v: String): PackageVersion = PackageVersion.assertFromString(v) private def key(name: String, v: String) = (pkg(name), ver(v)) + "lockOutOfDateMessage" should { + def lock(entries: String*): String = entries.mkString(System.lineSeparator()) + + def messageFor(currentLockContent: String, expectedLockStr: String): String = + File.temporaryFile(prefix = "current-dars", suffix = ".lock").apply { currentFile => + val _ = currentFile.write(currentLockContent) + DarLockChecker.lockOutOfDateMessage(currentFile.toString, expectedLockStr, "never") + } + + // The `-`/`+` lines of the diff embedded in the message + def changedLines(message: String): Seq[String] = + message.linesIterator + .filterNot(line => line.startsWith("---") || line.startsWith("+++")) + .filter(line => line.startsWith("-") || line.startsWith("+")) + .toSeq + + "report only the entries whose package id changed" in { + val current = lock( + "splice-amulet 0.1.0 hash0", + "splice-amulet 0.1.1 hash1", + "splice-amulet 0.1.2 hash2", + ) + val expected = lock( + "splice-amulet 0.1.0 hash0", + "splice-amulet 0.1.1 rebuiltHash1", + "splice-amulet 0.1.2 hash2", + ) + changedLines(messageFor(current, expected)) shouldBe Seq( + "-splice-amulet 0.1.1 hash1", + "+splice-amulet 0.1.1 rebuiltHash1", + ) + } + + "report entries missing from the lock file" in { + val current = lock() + val expected = lock("splice-amulet 0.1.0 hash0") + changedLines(messageFor(current, expected)) shouldBe Seq("+splice-amulet 0.1.0 hash0") + } + + "report entries that are no longer expected" in { + val current = lock("splice-amulet 0.1.0 hash0") + val expected = lock() + changedLines(messageFor(current, expected)) shouldBe Seq("-splice-amulet 0.1.0 hash0") + } + } + "detectBumps" should { "return empty when branch and compare base match exactly" in { val branch = Map(key("splice-amulet", "0.1.18") -> "hashA") diff --git a/build-tools/lib/hard-domain-migration-commands b/build-tools/lib/hard-domain-migration-commands deleted file mode 100644 index c17efbec6c..0000000000 --- a/build-tools/lib/hard-domain-migration-commands +++ /dev/null @@ -1,137 +0,0 @@ -# -*- shell-script -*- - -subcommand_whitelist[hard_domain_migration_trigger]='Propose a (nearly immediate) hard domain migration start and vote with SVs 1-4' - -function subcmd_hard_domain_migration_trigger() { - _prompt_to_confirm - _cluster_must_exist - - subcmd_preflight_global_domain_upgrade -} - -subcommand_whitelist[update_config]='Upgrade the hard domain migration config' - -function subcmd_update_config() { - local migration_type - local migration_id - if [ "$#" -ge 2 ]; then - migration_type="$1" - migration_id="$2" - shift 2 - else - _error "Usage: $0 $SCRIPTNAME update_config [] []" - fi - local migration_config - migration_config=$(cat < [] []" - fi - git_reference="$1" - shift - migration_config+=$(cat <] [migration]" - fi - # yq doesn't like the version being unquoted so we quote it here - _update_cluster_config "\"$1\"" "synchronizerMigration.$2.version" - if [ -z "${TARGET_CLUSTER-}" ]; then - cluster_directory="$(pwd)" - else - cluster_directory="${DEPLOYMENT_DIR}/${TARGET_CLUSTER}" - fi -} - -subcommand_whitelist[update_active_version]='Upgrade the version of the active migration id' - -function subcmd_update_active_version() { - if [ "$#" -ne 1 ]; then - _error "Usage: $0 $SCRIPTNAME []" - fi - update_version "$1" "active" -} - -subcommand_whitelist[update_upgrade_version]='Upgrade the version of the upgrade migration id' - -function subcmd_update_upgrade_version() { - if [ "$#" -ne 1 ]; then - _error "Usage: $0 $SCRIPTNAME []" - fi - if [ -z "${TARGET_CLUSTER-}" ]; then - configFile="config.yaml" - else - configFile="${DEPLOYMENT_DIR}/${TARGET_CLUSTER}/config.yaml" - fi - if yq -e '.synchronizerMigration.upgrade' "$configFile" > /dev/null 2>&1 - then - update_version "$1" "upgrade" - else - echo "No upgrade version" - fi -} - -subcommand_whitelist[update_config_to_migrate]='Upgrade the cluster config.yaml to migrate cluster' - -function subcmd_update_config_to_migrate() { - local configFile - if [ -z "${TARGET_CLUSTER-}" ]; then - configFile="config.yaml" - else - configFile="${DEPLOYMENT_DIR}/${TARGET_CLUSTER}/config.yaml" - fi - - yq e 'with(.synchronizerMigration; .legacy = .active | .active = .upgrade | del(.upgrade))' -i $configFile - yq e '.synchronizerMigration.active.migratingFrom = 0' -i $configFile - yq e 'del(.synchronizerMigration.legacy.releaseReference)' -i $configFile - "${SPLICE_ROOT}/cluster/scripts/resolve-config.sh" -} - -subcommand_whitelist[update_config_to_archive_legacy_migration]='Upgrade the cluster config.yaml to archive legacy migration' - -function subcmd_update_config_to_archive_legacy_migration() { - local configFile - if [ -z "${TARGET_CLUSTER-}" ]; then - configFile="config.yaml" - else - configFile="${DEPLOYMENT_DIR}/${TARGET_CLUSTER}/config.yaml" - fi - - yq e '.synchronizerMigration.archived = [ .synchronizerMigration.legacy ] | del(.synchronizerMigration.legacy)' -i $configFile - "${SPLICE_ROOT}/cluster/scripts/resolve-config.sh" -} - -subcommand_whitelist[update_config_to_remove_migrating_from]='Upgrade the cluster config.yaml to remove migratingFrom field from active migration' - -function subcmd_update_config_to_remove_migrating_from() { - local configFile - if [ -z "${TARGET_CLUSTER-}" ]; then - configFile="config.yaml" - else - configFile="${DEPLOYMENT_DIR}/${TARGET_CLUSTER}/config.yaml" - fi - - yq e 'del(.synchronizerMigration.active.migratingFrom)' -i $configFile - "${SPLICE_ROOT}/cluster/scripts/resolve-config.sh" -} diff --git a/build-tools/lib/logical-synchronizer-upgrade-commands b/build-tools/lib/logical-synchronizer-upgrade-commands index 60a27537a6..b7d696e061 100644 --- a/build-tools/lib/logical-synchronizer-upgrade-commands +++ b/build-tools/lib/logical-synchronizer-upgrade-commands @@ -40,3 +40,108 @@ function subcmd_retire_old_physical_synchronizer() { yq e 'del(.synchronizerMigration.legacy.releaseReference)' -i $configFile "${SPLICE_ROOT}/cluster/scripts/resolve-config.sh" } + +subcommand_whitelist[update_config]='Upgrade the hard domain migration config' + +function subcmd_update_config() { + local migration_type + local migration_id + if [ "$#" -ge 2 ]; then + migration_type="$1" + migration_id="$2" + shift 2 + else + _error "Usage: $0 $SCRIPTNAME update_config [] []" + fi + local migration_config + migration_config=$(cat < [] []" + fi + git_reference="$1" + shift + migration_config+=$(cat <] [migration]" + fi + # yq doesn't like the version being unquoted so we quote it here + _update_cluster_config "\"$1\"" "synchronizerMigration.$2.version" + if [ -z "${TARGET_CLUSTER-}" ]; then + cluster_directory="$(pwd)" + else + cluster_directory="${DEPLOYMENT_DIR}/${TARGET_CLUSTER}" + fi +} + +subcommand_whitelist[update_active_version]='Upgrade the version of the active migration id' + +function subcmd_update_active_version() { + if [ "$#" -ne 1 ]; then + _error "Usage: $0 $SCRIPTNAME []" + fi + update_version "$1" "active" +} + +subcommand_whitelist[update_upgrade_version]='Upgrade the version of the upgrade migration id' + +function subcmd_update_upgrade_version() { + if [ "$#" -ne 1 ]; then + _error "Usage: $0 $SCRIPTNAME []" + fi + if [ -z "${TARGET_CLUSTER-}" ]; then + configFile="config.yaml" + else + configFile="${DEPLOYMENT_DIR}/${TARGET_CLUSTER}/config.yaml" + fi + if yq -e '.synchronizerMigration.upgrade' "$configFile" > /dev/null 2>&1 + then + update_version "$1" "upgrade" + else + echo "No upgrade version" + fi +} + +subcommand_whitelist[update_config_to_archive_legacy_migration]='Upgrade the cluster config.yaml to archive legacy migration' + +function subcmd_update_config_to_archive_legacy_migration() { + local configFile + if [ -z "${TARGET_CLUSTER-}" ]; then + configFile="config.yaml" + else + configFile="${DEPLOYMENT_DIR}/${TARGET_CLUSTER}/config.yaml" + fi + + yq e '.synchronizerMigration.archived = [ .synchronizerMigration.legacy ] | del(.synchronizerMigration.legacy)' -i $configFile + "${SPLICE_ROOT}/cluster/scripts/resolve-config.sh" +} diff --git a/build.sbt b/build.sbt index bc7e72cc19..1fc2d2e91a 100644 --- a/build.sbt +++ b/build.sbt @@ -25,28 +25,16 @@ lazy val `canton-community-participant` = BuildCommon.`canton-community-particip lazy val `canton-community-admin-api` = BuildCommon.`canton-community-admin-api` lazy val `canton-community-integration-testing` = BuildCommon.`canton-community-integration-testing` lazy val `canton-community-testing` = BuildCommon.`canton-community-testing` -lazy val `canton-blake2b` = BuildCommon.`canton-blake2b` -lazy val `canton-slick-fork` = BuildCommon.`canton-slick-fork` lazy val `canton-wartremover-extension` = BuildCommon.`canton-wartremover-extension` -lazy val `canton-wartremover-annotations` = BuildCommon.`canton-wartremover-annotations` -lazy val `canton-util-external` = BuildCommon.`canton-util-external` lazy val `canton-util-observability` = BuildCommon.`canton-util-observability` -lazy val `canton-pekko-fork` = BuildCommon.`canton-pekko-fork` -lazy val `canton-magnolify-addon` = BuildCommon.`canton-magnolify-addon` -lazy val `canton-scalatest-addon` = BuildCommon.`canton-scalatest-addon` -lazy val `canton-ledger-common` = BuildCommon.`canton-ledger-common` -lazy val `canton-ledger-api-core` = BuildCommon.`canton-ledger-api-core` lazy val `canton-ledger-api-value` = BuildCommon.`canton-ledger-api-value` lazy val `canton-ledger-json-api` = BuildCommon.`canton-ledger-json-api` -lazy val `canton-daml-adjustable-clock` = BuildCommon.`canton-daml-adjustable-clock` -lazy val `canton-daml-jwt` = BuildCommon.`canton-daml-jwt` -lazy val `canton-daml-tls` = BuildCommon.`canton-daml-tls` -lazy val `canton-base-errors` = BuildCommon.`canton-base-errors` -lazy val `canton-google-common-protos-scala` = BuildCommon.`canton-google-common-protos-scala` lazy val `canton-sequencer-driver-api` = BuildCommon.`canton-sequencer-driver-api` -lazy val `canton-kms-driver-api` = BuildCommon.`canton-kms-driver-api` lazy val `canton-community-reference-driver` = BuildCommon.`canton-community-reference-driver` lazy val `canton-observability-metrics-testing` = BuildCommon.`canton-observability-metrics-testing` +lazy val `canton-traffic-enforcement-component` = BuildCommon.`canton-traffic-enforcement-component` +lazy val `daml-lf-transaction-test-lib` = BuildCommon.`daml-lf-transaction-test-lib` +lazy val `daml-lf-data-scalacheck` = BuildCommon.`daml-lf-data-scalacheck` lazy val `splice-wartremover-extension` = Wartremover.`splice-wartremover-extension` @@ -141,17 +129,12 @@ lazy val root: Project = (project in file(".")) `canton-community-common`, `canton-community-integration-testing`, `canton-community-testing`, - `canton-blake2b`, - `canton-slick-fork`, `canton-wartremover-extension`, `canton-community-app`, `canton-community-app-base`, `canton-community-synchronizer`, `canton-community-participant`, - `canton-ledger-common`, - `canton-ledger-api-core`, `canton-ledger-api-value`, - `canton-google-common-protos-scala`, `canton-observability-metrics-testing`, pulumi, `load-tester`, @@ -166,7 +149,7 @@ lazy val root: Project = (project in file(".")) BuildCommon.sharedSettings, scalacOptions ++= Seq("-Wconf:src=src_managed/.*:silent"), // Needed to be able to resolve scalafmt snapshot versions - resolvers ++= Resolver.sonatypeOssRepos("snapshots"), + resolvers += Resolver.sonatypeCentralSnapshots, damlDarsLockCheckerFileArg := { val darFiles: Seq[File] = damlBuild.all(allDarsFilter).value.flatten val basePath = baseDirectory.value.toPath @@ -1182,6 +1165,29 @@ lazy val `splitwell-test-daml` = Compile / damlEnableJavaCodegen := false, ) +lazy val `lf-value-json` = + project + .in(file("canton-fork/lf-value-json")) + .dependsOn( + `canton-ledger-json-api`, + `daml-lf-transaction-test-lib`, + ) + .settings( + scalacOptions += "-Xsource-features:infer-override", + libraryDependencies ++= { + import CantonDependencies._ + Seq( + CantonDependencies.canton_ledger_api_core, + daml_lf_api_type_signature, + scalatest % Test, + scalacheck % Test, + scalaz_scalacheck % Test, + scalatestScalacheck % Test, + ) + }, + CantonDependencies.excludeTranscodeConflictingDependencies, + ) + lazy val `apps-common` = project .in(file("apps/common")) @@ -1189,6 +1195,7 @@ lazy val `apps-common` = `canton-community-common`, `canton-community-app` % "compile->compile;test->test", `canton-community-testing` % "test->test", + `lf-value-json`, `splice-wartremover-extension` % "compile->compile;test->test", // We include all DARs here to make sure they are available as resources. `splice-amulet-daml`, @@ -2052,9 +2059,6 @@ def mergeStrategy(oldStrategy: String => MergeStrategy): String => MergeStrategy "Log4j2Plugins.dat", ) => MergeStrategy.first - case (PathList("org", "apache", "pekko", "stream", "scaladsl", broadcasthub, _*)) - if broadcasthub.startsWith("BroadcastHub") => - MergeStrategy.first case "META-INF/versions/9/module-info.class" => MergeStrategy.discard case path if path.contains("module-info.class") => MergeStrategy.discard case PathList("org", "jline", _ @_*) => MergeStrategy.first @@ -2067,12 +2071,23 @@ def mergeStrategy(oldStrategy: String => MergeStrategy): String => MergeStrategy MergeStrategy.first case PathList("com", "google", _*) => MergeStrategy.first case PathList("io", "grpc", _*) => MergeStrategy.first + // slick-fork + case PathList("slick", "jdbc", "canton", _*) => MergeStrategy.first + case PathList("slick", "util", name) + if name.startsWith("QueryCostTracker") || name.startsWith("AsyncExecutorWith") => + MergeStrategy.first + // community-base + case PathList("com", "daml", "nonempty", name) if name.startsWith("NonEmptyUtil") => + MergeStrategy.first + // Multiple dependencies ship this GraalVM metadata with differing content. + case PathList("META-INF", "native-image", "reflect-config.json") => MergeStrategy.first // Copy-pasta from Canton (DACH-NY/canton#31788): Remove this merge strategy once zipkin exporter is removed case PathList("okhttp3", _ @_*) => MergeStrategy.first // this file comes in multiple flavors, from io.get-coursier:interface and from org.scala-lang.modules:scala-collection-compat. Since the content differs it is resolve this explicitly with this MergeStrategy. case path if path.endsWith("scala-collection-compat.properties") => MergeStrategy.first // Don't really care about the notice file so just take any. case "META-INF/FastDoubleParser-NOTICE" => MergeStrategy.first + case "META-INF/FastDoubleParser-LICENSE" => MergeStrategy.first case "META-INF/license/LICENSE.boringssl.txt" => MergeStrategy.first case path if path.endsWith("/OSGI-INF/MANIFEST.MF") => MergeStrategy.first case x => @@ -2331,7 +2346,6 @@ lazy val `apps-dar-resources-generator` = project .in(file("apps/dar-resources-generator")) .dependsOn( - `canton-util-external`, // We include all DARs here to make sure they are available as resources. `splice-amulet-daml`, `splice-amulet-name-service-daml`, @@ -2366,6 +2380,7 @@ lazy val `apps-dar-resources-generator` = Headers.ApacheDAHeaderSettings, libraryDependencies ++= Seq( Dependencies.better_files, + CantonDependencies.canton_util_external, CantonDependencies.daml_lf_archive_reader, CantonDependencies.cats, ), @@ -2384,6 +2399,7 @@ lazy val `apps-app`: Project = `canton-community-app` % "compile->compile;test->test", `canton-community-base`, `canton-community-integration-testing` % "test", + `splice-amulet-test-daml` % "test", `splice-util-featured-app-proxies-daml` % "test", // necessary for token-standard-cli to get `npm install`ed so that TokenStandardCliSanityCheckPlugin can run `apps-common-frontend`, @@ -2392,6 +2408,7 @@ lazy val `apps-app`: Project = // scalatestplus-selenium is lagging behind, it depends on selenium 4.12, // but that's fine as it's compatible with selenium 4.44 that we end up using libraryDependencies += "org.scalatestplus" %% "selenium-4-12" % "3.2.17.0" % "test", + libraryDependencies += CantonDependencies.scalatest_shouldmatchers, libraryDependencies += "org.seleniumhq.selenium" % "selenium-java" % "4.44.0" % "test", libraryDependencies += "eu.rekawek.toxiproxy" % "toxiproxy-java" % "2.1.4" % "test", libraryDependencies += auth0, @@ -2502,10 +2519,7 @@ updateTestConfigForParallelRuns := { val allTestNames = definedTests .all( - ScopeFilter(inAggregates(root), inConfigurations(Test)) -- ScopeFilter( - inProjects(`canton-ledger-api-core`), - inConfigurations(Test), - ) + ScopeFilter(inAggregates(root), inConfigurations(Test)) ) .value .flatten @@ -2615,11 +2629,6 @@ updateTestConfigForParallelRuns := { "test-cometbft-full-class-names.log", (t: String) => !isTimeBasedTest(t) && !isFrontEndTest(t) && isCometBftTest(t), ), - ( - "tests requiring Canton Enterprise", - "test-full-class-names-canton-enterprise.log", - (t: String) => isEnterpriseIntegrationTest(t), - ), ( "tests to check logical sync roll-forward upgrade", "test-full-class-names-roll-forward-lsu.log", diff --git a/canton/community/ledger/ledger-json-api/src/main/scala/com/digitalasset/canton/daml/lf/value/json/ApiCodecCompressed.scala b/canton-fork/lf-value-json/src/main/scala/com/digitalasset/canton/daml/lf/value/json/ApiCodecCompressed.scala similarity index 100% rename from canton/community/ledger/ledger-json-api/src/main/scala/com/digitalasset/canton/daml/lf/value/json/ApiCodecCompressed.scala rename to canton-fork/lf-value-json/src/main/scala/com/digitalasset/canton/daml/lf/value/json/ApiCodecCompressed.scala diff --git a/canton/community/ledger/ledger-json-api/src/main/scala/com/digitalasset/canton/daml/lf/value/json/ApiValueImplicits.scala b/canton-fork/lf-value-json/src/main/scala/com/digitalasset/canton/daml/lf/value/json/ApiValueImplicits.scala similarity index 100% rename from canton/community/ledger/ledger-json-api/src/main/scala/com/digitalasset/canton/daml/lf/value/json/ApiValueImplicits.scala rename to canton-fork/lf-value-json/src/main/scala/com/digitalasset/canton/daml/lf/value/json/ApiValueImplicits.scala diff --git a/canton/community/ledger/ledger-json-api/src/main/scala/com/digitalasset/canton/daml/lf/value/json/JsonVariant.scala b/canton-fork/lf-value-json/src/main/scala/com/digitalasset/canton/daml/lf/value/json/JsonVariant.scala similarity index 100% rename from canton/community/ledger/ledger-json-api/src/main/scala/com/digitalasset/canton/daml/lf/value/json/JsonVariant.scala rename to canton-fork/lf-value-json/src/main/scala/com/digitalasset/canton/daml/lf/value/json/JsonVariant.scala diff --git a/canton/community/ledger/ledger-json-api/src/main/scala/com/digitalasset/canton/daml/lf/value/json/NavigatorModelAliases.scala b/canton-fork/lf-value-json/src/main/scala/com/digitalasset/canton/daml/lf/value/json/NavigatorModelAliases.scala similarity index 100% rename from canton/community/ledger/ledger-json-api/src/main/scala/com/digitalasset/canton/daml/lf/value/json/NavigatorModelAliases.scala rename to canton-fork/lf-value-json/src/main/scala/com/digitalasset/canton/daml/lf/value/json/NavigatorModelAliases.scala diff --git a/canton-fork/lf-value-json/src/test/scala/com/digitalasset/canton/daml/lf/value/json/ApiCodecCompressedSpec.scala b/canton-fork/lf-value-json/src/test/scala/com/digitalasset/canton/daml/lf/value/json/ApiCodecCompressedSpec.scala new file mode 100644 index 0000000000..5936fa38c5 --- /dev/null +++ b/canton-fork/lf-value-json/src/test/scala/com/digitalasset/canton/daml/lf/value/json/ApiCodecCompressedSpec.scala @@ -0,0 +1,407 @@ +// Copyright (c) 2025 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.daml.lf.value.json + +import com.digitalasset.canton.daml.lf.value.json.NavigatorModelAliases as model +import com.digitalasset.daml.lf.data.{ImmArray, Numeric, Ref, SortedLookupList, Time} +import com.digitalasset.daml.lf.value.Value.ContractId +import com.digitalasset.daml.lf.value.test.TypedValueGenerators.{ + ValueAddend as VA, + genAddend, + genTypeAndValue, +} +import com.digitalasset.daml.lf.value.test.ValueGenerators.coidGen +import org.scalacheck.Arbitrary +import org.scalactic.source +import org.scalatest.Inside +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks +import shapeless.record.Record as HRecord +import shapeless.{Coproduct as HSum, HNil} +import spray.json.* + +import java.time.Instant +import scala.annotation.nowarn +import scala.util.{Success, Try} + +import ApiCodecCompressed.{apiValueToJsValue, jsValueToApiValue} + +class ApiCodecCompressedSpec + extends AnyWordSpec + with Matchers + with ScalaCheckPropertyChecks + with Inside { + + import C.typeLookup + + protected implicit val cidArb: Arbitrary[ContractId] = Arbitrary(coidGen) + + /** Serializes the API value to JSON, then parses it back to an API value */ + protected def serializeAndParse( + value: model.ApiValue, + typ: model.DamlLfType, + ): Try[model.ApiValue] = { + import ApiCodecCompressed.JsonImplicits.* + + for { + serialized <- Try(value.toJson.prettyPrint) + json <- Try(serialized.parseJson) + parsed <- Try(jsValueToApiValue(json, typ, typeLookup)) + } yield parsed + } + + protected def roundtrip(va: VA)(v: va.Inj): Option[va.Inj] = + va.prj(jsValueToApiValue(apiValueToJsValue(va.inj(v)), va.t, typeLookup)) + + protected val decimalScale = Numeric.Scale.assertFromInt(10) + + protected object C /* based on navigator DamlConstants */ { + import shapeless.syntax.singleton.* + val packageId0 = Ref.PackageId assertFromString "hash" + val moduleName0 = Ref.ModuleName assertFromString "Module" + def defRef(name: String) = + Ref.Identifier( + packageId0, + Ref.QualifiedName(moduleName0, Ref.DottedName assertFromString name), + ) + val emptyRecordId = defRef("EmptyRecord") + val (emptyRecordDDT, emptyRecordT) = VA.record(emptyRecordId, HNil) + val simpleRecordId = defRef("SimpleRecord") + val simpleRecordVariantSpec = HRecord(fA = VA.text, fB = VA.int64) + val (simpleRecordDDT, simpleRecordT) = + VA.record(simpleRecordId, simpleRecordVariantSpec) + val simpleRecordV: simpleRecordT.Inj = HRecord(fA = "foo", fB = 100L) + + val simpleVariantId = defRef("SimpleVariant") + val (simpleVariantDDT, simpleVariantT) = + VA.variant(simpleVariantId, simpleRecordVariantSpec) + val simpleVariantV = HSum[simpleVariantT.Inj](Symbol("fA") ->> "foo") + + val complexRecordId = defRef("ComplexRecord") + val (complexRecordDDT, complexRecordT) = + VA.record( + complexRecordId, + HRecord( + fText = VA.text, + fBool = VA.bool, + fDecimal = VA.numeric(decimalScale), + fUnit = VA.unit, + fInt64 = VA.int64, + fParty = VA.party, + fContractId = VA.contractId, + fListOfText = VA.list(VA.text), + fListOfUnit = VA.list(VA.unit), + fDate = VA.date, + fTimestamp = VA.timestamp, + fOptionalText = VA.optional(VA.text), + fOptionalUnit = VA.optional(VA.unit), + fOptOptText = VA.optional(VA.optional(VA.text)), + fMap = VA.map(VA.int64), + fVariant = simpleVariantT, + fRecord = simpleRecordT, + ), + ) + @nowarn("msg=dubious usage of method asInstanceOf with unit value") + val complexRecordV: complexRecordT.Inj = + HRecord( + fText = "foo", + fBool = true, + fDecimal = Numeric assertFromString "100.0000000000", + fUnit = (), + fInt64 = 100L, + fParty = Ref.Party assertFromString "BANK1", + fContractId = ContractId.assertFromString("00" + "00" * 32 + "c0"), + fListOfText = Vector("foo", "bar"), + fListOfUnit = Vector((), ()), + fDate = Time.Date assertFromString "2019-01-28", + fTimestamp = Time.Timestamp.assertFromInstant(Instant.parse("2019-01-28T12:44:33.22Z")), + fOptionalText = None, + fOptionalUnit = Some(()), + fOptOptText = Some(Some("foo")), + fMap = SortedLookupList(Map("1" -> 1L, "2" -> 2L, "3" -> 3L)), + fVariant = simpleVariantV, + fRecord = simpleRecordV, + ) + + val colorId = defRef("Color") + val (colorGD, colorGT) = + VA.enumeration(colorId, Seq("Red", "Green", "Blue") map Ref.Name.assertFromString) + + val typeLookup: NavigatorModelAliases.DamlLfTypeLookup = + Map( + emptyRecordId -> emptyRecordDDT, + simpleRecordId -> simpleRecordDDT, + simpleVariantId -> simpleVariantDDT, + complexRecordId -> complexRecordDDT, + colorId -> colorGD, + ).lift + } + + protected def mustBeOne[A](as: Seq[A]): A = as match { + case Seq(x) => x + case xs @ _ => sys.error(s"Expected exactly one element, got: $xs") + } + + import C.typeLookup + + "API compressed JSON codec" when { + + "serializing and parsing a value" should { + + "work for arbitrary reference-free types" in forAll( + genTypeAndValue(coidGen), + minSuccessful(100), + ) { case (typ, value) => + serializeAndParse(value, typ) shouldBe Success(value) + } + + "work for many, many values in raw format" in forAll(genAddend, minSuccessful(100)) { va => + import va.injshrink + implicit val arbInj: Arbitrary[va.Inj] = va.injarb + forAll(minSuccessful(20)) { (v: va.Inj) => + roundtrip(va)(v) should ===(Some(v)) + } + } + + "handle nested optionals" in { + val va = VA.optional(VA.optional(VA.int64)) + val cases = Table( + "value", + None, + Some(None), + Some(Some(42L)), + ) + forEvery(cases) { ool => + roundtrip(va)(ool) should ===(Some(ool)) + } + } + + "handle lists of optionals" in { + val va = VA.optional(VA.optional(VA.list(VA.optional(VA.optional(VA.int64))))) + import va.injshrink + implicit val arbInj: Arbitrary[va.Inj] = va.injarb + forAll(minSuccessful(1000)) { (v: va.Inj) => + roundtrip(va)(v) should ===(Some(v)) + } + } + + def cr(typ: VA)(v: typ.Inj) = + (typ, v: Any, typ.inj(v)) + + val roundtrips = Table( + ("type", "original value", "Daml value"), + cr(C.emptyRecordT)(HRecord()), + cr(C.simpleRecordT)(C.simpleRecordV), + cr(C.simpleVariantT)(C.simpleVariantV), + cr(C.complexRecordT)(C.complexRecordV), + ) + "work for records and variants" in forAll(roundtrips) { (typ, origValue, damlValue) => + typ.prj(jsValueToApiValue(apiValueToJsValue(damlValue), typ.t, typeLookup)) should ===( + Some(origValue) + ) + } + /* + "work for Tree" in { + serializeAndParse(C.treeV, C.treeTC) shouldBe Success(C.treeV) + } + "work for Enum" in { + serializeAndParse(C.redV, C.redTC) shouldBe Success(C.redV) + } + */ + } + + def cn(canonical: String, numerically: String, typ: VA)( + expected: typ.Inj, + alternates: String* + )(implicit pos: source.Position) = + (pos.lineNumber, canonical, numerically, typ, expected, alternates) + + def c(canonical: String, typ: VA)(expected: typ.Inj, alternates: String*)(implicit + pos: source.Position + ) = + cn(canonical, canonical, typ)(expected, alternates*)(pos) + + object VAs { + val ooi = VA.optional(VA.optional(VA.int64)) + val oooi = VA.optional(ooi) + } + + val numCodec = ApiCodecCompressed.copy(false, false) + + @nowarn("cat=lint-infer-any") + val successes = Table( + ("line#", "serialized", "serializedNumerically", "type", "parsed", "alternates"), + c( + "\"0000000000000000000000000000000000000000000000000000000000000000000123\"", + VA.contractId, + )( + ContractId.assertFromString( + "0000000000000000000000000000000000000000000000000000000000000000000123" + ) + ), + cn("\"42.0\"", "42.0", VA.numeric(decimalScale))( + Numeric assertFromString "42.0000000000", + "\"42\"", + "42", + "42.0", + "\"+42\"", + ), + cn("\"2000.0\"", "2000", VA.numeric(decimalScale))( + Numeric assertFromString "2000.0000000000", + "\"2000\"", + "2000", + "2e3", + ), + cn("\"0.3\"", "0.3", VA.numeric(decimalScale))( + Numeric assertFromString "0.3000000000", + "\"0.30000000000000004\"", + "0.30000000000000004", + ), + cn( + "\"9999999999999999999999999999.9999999999\"", + "9999999999999999999999999999.9999999999", + VA.numeric(decimalScale), + )(Numeric assertFromString "9999999999999999999999999999.9999999999"), + cn("\"0.1234512346\"", "0.1234512346", VA.numeric(decimalScale))( + Numeric assertFromString "0.1234512346", + "0.12345123455", + "0.12345123465", + "\"0.12345123455\"", + "\"0.12345123465\"", + ), + cn("\"0.1234512345\"", "0.1234512345", VA.numeric(decimalScale))( + Numeric assertFromString "0.1234512345", + "0.123451234549", + "0.12345123445001", + "\"0.123451234549\"", + "\"0.12345123445001\"", + ), + c("\"1990-11-09T04:30:23.123456Z\"", VA.timestamp)( + Time.Timestamp.assertFromInstant(Instant.parse("1990-11-09T04:30:23.123456Z")), + "\"1990-11-09T04:30:23.1234569Z\"", + ), + c("\"1970-01-01T00:00:00Z\"", VA.timestamp)(Time.Timestamp assertFromLong 0), + // Ensure ISO 8601 timestamps with offsets are successfully parsed by comparing to (epoch - 1 hour) + c("\"1969-12-31T23:00:00Z\"", VA.timestamp)( + Time.Timestamp.assertFromLong(-3600000000L), + "\"1970-01-01T00:00:00+01:00\"", + ), + cn("\"42\"", "42", VA.int64)(42, "\"+42\""), + cn("\"0\"", "0", VA.int64)(0, "-0", "\"+0\"", "\"-0\""), + c("\"Alice\"", VA.party)(Ref.Party assertFromString "Alice"), + c("{}", VA.unit)(()), + c("\"2019-06-18\"", VA.date)(Time.Date assertFromString "2019-06-18"), + c("\"9999-12-31\"", VA.date)(Time.Date assertFromString "9999-12-31"), + c("\"0001-01-01\"", VA.date)(Time.Date assertFromString "0001-01-01"), + c("\"abc\"", VA.text)("abc"), + c("true", VA.bool)(true), + cn("""["1", "2", "3"]""", "[1, 2, 3]", VA.list(VA.int64))(Vector(1, 2, 3)), + c("""{"a": "b", "c": "d"}""", VA.map(VA.text))(SortedLookupList(Map("a" -> "b", "c" -> "d"))), + c("""[["a", "b"], ["c", "d"]]""", VA.genMap(VA.text, VA.text))(Map("a" -> "b", "c" -> "d")), + cn("\"42\"", "42", VA.optional(VA.int64))(Some(42)), + c("null", VA.optional(VA.int64))(None), + c("null", VAs.ooi)(None), + c("[]", VAs.ooi)(Some(None), "[null]"), + cn("""["42"]""", "[42]", VAs.ooi)(Some(Some(42))), + c("null", VAs.oooi)(None), + c("[]", VAs.oooi)(Some(None), "[null]"), + c("[[]]", VAs.oooi)(Some(Some(None)), "[[null]]"), + cn("""[["42"]]""", "[[42]]", VAs.oooi)(Some(Some(Some(42)))), + cn("""{"fA": "foo", "fB": "100"}""", """{"fA": "foo", "fB": 100}""", C.simpleRecordT)( + C.simpleRecordV + ), + c("""{"tag": "fA", "value": "foo"}""", C.simpleVariantT)(C.simpleVariantV), + c("\"Green\"", C.colorGT)( + C.colorGT get Ref.Name.assertFromString("Green") getOrElse sys.error("impossible") + ), + ) + + val failures = Table( + ("JSON", "type", "errorSubstring"), + ("42.3", VA.int64, ""), + ("\"42.3\"", VA.int64, ""), + ("9223372036854775808", VA.int64, ""), + ("-9223372036854775809", VA.int64, ""), + ("\"garbage\"", VA.int64, ""), + ("\" 42 \"", VA.int64, ""), + ("\"1970-01-01T00:00:00\"", VA.timestamp, ""), + ("\"1970-01-01T00:00:00+01:00[Europe/Paris]\"", VA.timestamp, ""), + ("\"0000-01-01\"", VA.date, "Invalid date: 0000-01-01"), + ("\"9999-99-99\"", VA.date, "Invalid date: 9999-99-99"), + ("\"9999-12-32\"", VA.date, "Invalid date: 9999-12-32"), + ("\"9999-13-31\"", VA.date, "Invalid date: 9999-13-31"), + ("\"10000-01-01\"", VA.date, "Invalid date: 10000-01-01"), + ("\"1-01-01\"", VA.date, "Invalid date: 1-01-01"), + ("\"0001-02-29\"", VA.date, "Invalid date: 0001-02-29"), + ("\"not-a-date\"", VA.date, "Invalid date: not-a-date"), + ("""{"a": "b", "c": "d"}""", VA.genMap(VA.text, VA.text), ""), + ("\"\"", VA.party, "Daml-LF Party is empty"), + (List.fill(256)('a').mkString("\"", "", "\""), VA.party, "Daml-LF Party is too long"), + ) + + "dealing with particular formats" should { + "succeed in cases" in forEvery(successes) { + (_, serialized, serializedNumerically, typ, expected, alternates) => + val json = serialized.parseJson + val numJson = serializedNumerically.parseJson + val parsed = jsValueToApiValue(json, typ.t, typeLookup) + jsValueToApiValue(numJson, typ.t, typeLookup) should ===(parsed) + typ.prj(parsed) should ===(Some(expected)) + apiValueToJsValue(parsed) should ===(json) + numCodec.apiValueToJsValue(parsed) should ===(numJson) + val tAlternates = Table("alternate", alternates*) + forEvery(tAlternates) { alternate => + val aJson = alternate.parseJson + typ.prj(jsValueToApiValue(aJson, typ.t, typeLookup)) should ===(Some(expected)) + } + } + + "fail in cases" in forEvery(failures) { (serialized, typ, errorSubstring) => + val json = serialized.parseJson // we don't test *the JSON decoder* + val exception = the[DeserializationException] thrownBy { + jsValueToApiValue(json, typ.t, typeLookup) + } + exception.getMessage should include(errorSubstring) + } + } + + import com.digitalasset.daml.lf.value.Value as LfValue + import ApiCodecCompressed.JsonImplicits.* + + val bazRecord = LfValue.ValueRecord( + None, + ImmArray(Some(Ref.Name.assertFromString("baz")) -> LfValue.ValueText("text abc")), + ) + + val bazVariant = LfValue.ValueVariant( + None, + Ref.Name.assertFromString("Baz"), + bazRecord, + ) + + val quxVariant = LfValue.ValueVariant( + None, + Ref.Name.assertFromString("Qux"), + LfValue.ValueUnit, + ) + + + "dealing with LF Variant" should { + "encode Foo/Baz to JSON" in { + val writer = implicitly[spray.json.JsonWriter[LfValue]] + (writer.write( + bazVariant + ): JsValue) shouldBe ("""{"tag":"Baz", "value":{"baz":"text abc"}}""".parseJson: JsValue) + } + + "encode Foo/Qux to JSON" in { + val writer = implicitly[spray.json.JsonWriter[LfValue]] + (writer.write( + quxVariant + ): JsValue) shouldBe ("""{"tag":"Qux", "value":{}}""".parseJson: JsValue) + } + } + } +} diff --git a/canton/.proto_snapshot_image.bin.gz b/canton/.proto_snapshot_image.bin.gz index 36562efde4dbe146bfcda28ccd945716066d9a01..e53644f1d6855bdba0070734f732e74e87906db3 100644 GIT binary patch literal 517511 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 f}!Xa-1)fuVcE@@X~Dmk6YQd@mm4en*g>c( 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

XnQlCi;*t^xyd6#yuA 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%0AZ0pTyMK)%NTF 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

|^d8*xHg5w8#u7raF|0giw0JVK;@ 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

AW=XD&jg9BWCMNO| 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

fkE;-k|9M~tIXD~!WbFx+ z_ljC^BStAVBw7Iv1g+1jyMXVm{WWk;f@SAcO_;*wq;2$}=`~58=gm?9RSvBG7-dQ% z+~XJ`&Vo31g1`ewo{VU}f>eYjrD$|aJv8J*_hHxt68Wh9$n1^F&S1==rygjq{3I|C zlVB4aF;t@<-7d&)1=bKOmn0Epio%!qul0ZpQK%`1ePsN~l=~vR;vnL|jzf-EcOZdfF?bMEhCi^7 z!S_oJGTF#y9`DNV=TDfd-X&}79y%i|`}HUj1{lwZ;jg6e#NhI(yb-TwDrV5-pUh|X zc;0mP*3Ak6rm+WX3RkbKZCY`Rwk$i)h#Vh2CBE6?^O?-$tKkZL#u?(p zho}La6TG%Lk0@V7LxSG7i$7R-KCSkPggd-IKKE;U-uo*E3a|O-tfwm0aLka0ufO!d zEw&=?bck!&kva9uYe)4^X!P!_J)uJgx`TD#?AqaIv-3eX8(jw}1z~Rm57itIiyZhR z&=+7B^GM~Rr_MMx3wlt3oJ?sZB5{Q#K>TUSrLCx-M=r-8X}C;5tBsnLe*x4) z_xs@NJLX9a09?|5AH*}%;NNky*qs3xnpOX-fDHRNhalq}*wPjZgI1Tl4TxuGasTL@HJ+KnF^c=_3d>^;hJi z15p4Y>)d-1VW9YU-uRJK$D$fQA}#bV92?zV=V9HB%2o=?MTN{(VE z#U_@PeqL^#rL6wk(q*YM(Ya**A3Rcw*D=fa3gR3(v{JLEYMS&DnF^ik+Ipx8{e_Z@ zeV-CW0E5J`fm4@y%FOa^Ykza!rr=XAm7`O|$Gd}5p9;L~Q= z$jLD_#%m8 zB@^?m&mTX=H}U}oQvW~%+qV@%g@S-!pyENJ)Emdhf!P(hJJ+^Og$I^MwFm{!`uIdz zU=UOh`mE8nY5u-M5^9a!2;*-wvZnfsrP-XN@!ns)KWn&+Z8!Wy;a*7Oxko~ATa8_d z!TQ6&bATu6VKfSVwQg+hqmR#^uL?}6y@fqPk2M!F;gg~7_jZ()p>O6l`Z#^$NeXb5 zn9wP4dKj7boZrXnYJHsFjB-X}XK{7*v_q%rZif5|rk9~z+5O8P5T5hb!DWfHGtJ5| zwSY?TGHSe`X9+WZQz3?SWqnG?1oYhdIQmH~{gi$V!1y_MMjGh2hx;O?=vJJ=Vo2Of=YCu@tBQ4awfl)lE6o}PO3Fp0LunfV=^9L5<) zj-!Bu=wuYb>o|+NDg7i3^M4JBG|RFj({+-Jr29x%zxyecj54MNr0*p2+-aRO442!7 z4OqXdXN%)*#Z9xS3SBogsOYSFMDVJ@TXrI0>@t#vuFpGjTo7HDrDO1GAsxhHe7)hx zJU5ybi*KMeDB+UyTc^+x90ygeD8kTMX zXH}2I`7a61lBGsJbrm%VyHmlAG%JAi8wdn9XobCZAnHa|e>~0Ba2c;kV$=&+e2U)$ zDSJ4JF52}3=_oG|WN1$?9`@AC;7KH(U`X zF5R0=qIpTrD}T9>I}=<1OR5!XS(v#Z`9u@HAu5* zVff|+7pu0dcL{v`Vi75V;q4FHVUpsP7RuWWM)`I|1T4a-1?6J>%}SNIOvB>1KY2(Q zm;?onqDiX)l3IixYI+?^>VG?4YllnH&|$DT^BxWVUCvLRg?0o9LO}LMpJ1exszH}Z z%`nvJcvc%!Ky!G8)nK&yRK%;=^6&$^xGz&ip*vju9Z84cugiNenR{7U7R0MF8d__1 zYn`ex)~Ti1ZBrtG?Y(c_-!Cq82<1^TeN9YkIvMIi*G#5Ce^o|w3}>ytfVjjzK9G|l ze{3|?@~BaghC5IOS47jqp;=Io`(oSA2?MY>mCW3|oM2h;Vk=0@)&3d-cpg>-2F?Bz zQ-42>eN63I4cz=Xm^fOPIkfAg_-&BA|JmER>Kf#d{;l0v-yfV=NjHK}7PJKjBr9o{ zi(SYvP(81-6`K%vNRF4JC9g=-o$IK-lPYh^$i6|`_?}^p-&NPb<&bD{L5|dxQg@Ga zU?m>#g0k!QnL3)DADzcc%umdh2$k|)IQ8cXoAi!lqRb?NIlq5xA>9A1#~6S381l{C z*>iWkvJP|XrFe-0I67H8dmOh#{}F%rKS$f^DSh;=REF~FLV&TCX3m)WF4qU>50(7k zd9uG)&i#zpzj5VnO*CseR<5JCO66}2rwIb(vQOu6s-NuNNBo%?DP=ydT>S`dM^jB= zcckvg11|zSU!>oP9;fo2xC|#R2{8Ape*MM6%2G@Qf5^%lHMNHhBx6S~l*A8Ugzgdv zVi-`tB_*vCb(rJO5gcw#jWpG^xlmHVq^SVK7fiNfZ+@2u5S8vAH4_0cN*SY(3skF= z8jJvGV?4``-pD^iOUXuU6#J{th2iclIE3d6*Kt(3NT=h*E+XEJ{+DBl_msp&iWiZd z-5%LHX3YuukPekdlVS7JDMia@OOOt0XkHqnK-Y7|L5b`wxvcU~Qk8U>J75wPZt&1o zX;i^l^6150QMQT~<4I&rN+CO~OAuDRs~58xwBhCmsNYzIoL_|w4Z8^5vzYs|gKv=3>mcv)Cw z=<^4JpK~(eu*B|Avl8ibLZV2&vA6oa`&4eGUPuh#(zzE(&C&j>HKTa4oq<5UO7K0gyU4aaPTzaaexA>r%Plmw8S4KSk*^ISv=cPSZW+${>Y*if88lg_bR6(7G9@p03$^gz zVR1lW*>a?8l**q*bWk`r&`S&&H~&QTiWVhI6_=}$<)|x{qa-Pid24}+klMfGEqcYm zhn~P83Kyo{?XMy5CM2+0)6D$Lx>CgHtRWwLog1)QnJ5Q%wIR~5K*_VY>5E>T4ibtr zaauPG2&EO|)QRk$(VAx;scD z)Z}UHm=K(rj4s|j;V4iOY4=Lj*cXo9!YNoU;Y%UpU9`2bB7%S?ONQC^6)#%_CW?cG ze=TY?A)q0YhSen{JmV`^sdS~Bx6xJGu_fQT_tezGeIu600x%zxq+`(>tz^HF|h@sH1EZ7;NmFf_U zv>}jLCTaQIg%k2p53^nw3%nDmw39JMSDP7hBmf3ZmFFR)r&It_1={*(sCA-8+D(R=bLGr!{(Huof(vEGP3U z&`s?;4RGPs)!9OG_%-|`MVtg(;mY*EFcO?n=6!_a;)Pjn>BYl)pP}r^$y3oNWKty` z0i&V11kim{bMpKo#1y{l*%8Pa@P9E_#bu#z`HxKIc`5lic4* znDJ+-WsSHq_!+E(8EJ|?P3WWmZPgj$Jg3EP@i56K`N}8JRi!h>v)3-jjb|YaPneW< z3tSuer~oHsOM%8ReR4VpbntOb_I2T_8`kRs+&-F@RSg|7=lIK{;6-DBMEq1qm_z^} zW?@w+cby=Rh`XF=8)i~EDlx^g#0Nb}l6?#({<4JGQt-Zy=$<*~vgBDD?!n>?Y|Bw= zBCmiIYlXRF`i=eq!@is&DxVDdFF0w6b(tPa;vd5fFiXZ4N0JBQFh!oBzu^DQietqfn^8iLJpp}O+h9mSCxr<dl6Z!jBpu*#&;fKTmo`Ds5ZuBaS)(LqDLP9 zXdViVoM3WxPTi3S!$Q2w5w7X3vVO|#je3OF$KLyv65d25>AtMaerE}sS z?P0|X@E=>mg;vY!vY4qlOtHHZ`WLzG9LEAQVqv18hTw0V146E>IGDN`ksaZY44f6v ztH3{g*q!ff^`>hApZY-i{wemF-DHNOx?)rkmci96-C{g@Z;cx^D&O&~XSwnrFvn)N zkTWvI`$U2xTWhcb8o{+bT(zmK*=9>m2M!R=`kxxsqK@@s8#waf8u76R?(3*}^9~X7 zYJ$Tz)0}dx?l@Q%7^KE)V;a4LSd(HS?_dViGwBjE!RvV(Q$lwrW|?a9YoynK111AI zie+qPYKm`uE*dP4t+TTp*^n(~j4nlHeQ+VI!ewhAF=}*cxOQFGQIr^tLP>VVUuSN< zhmq{R0n~hG_fEcEBEmu`^VEvILlXn_zWrm*{a%Tp`Uk!6G&H84j_AGy zBmHDdmzvkqA1lK`Im45rA1j7LM^A88KM(Xz3=~grdLqjlG7|e+FpC@&Us;4x@Kx_d zXtBtUvt#zy$N4fwIeQ>@S_*kNL^+YNvi2Ng-s_%KSMzU;wYvm6xB;ynk zFtDL>?5|etyhT&JStg)JicjF%;?TW#nBUYag;FH#Cc7@)=2Lr=(=LAd)P^f%W(v>v zR&XWzjgxZ8h1Z(kl7a1=w^XtwwL`Q415){@$@pZ!jV7pnYMYMUa0WQ_f;3X!9&u5_ z*hrmF+OvZ`jv$~sf)rHQ1G7cPprw9k;S6v3#_oflT=9$(uXQCxBXh$BX~aYM%kP}k zawOzgc`4Z%eex}lXP3JFjKKRdsF3@e6if^Wr$qdrMnO;|$FG#(QOWSwP$8d)m>9Aw zVJ>JU!B<>IGG{hc;{N3)l>$|Q=cr~W9!F?(0B@+A@ zm6-jw4V%idy9F7L<-Q18jI@En&r#hJN8=06)wg9lJDwWkO}8yek-*5!V2A{zu2Y67 zwj$)6rq;zRX;!&p*)oSC$}gkjRa_#ruTBWt{3egbd&pPl$rFj( zj-?WE4!OEeyUSM38|2cX+)G^G>>x81#0zuKVfXYRO44%)7_$b5L8y5Bmut#%#7pPP zekHoTkn2vINRJ|ch6x#3G>~5?_>7x(6hhIuu$C63tU$qlV5f#wj?r5yI82C~ixKHc0 z6@0=&a+#dBDysjWkzMDSJef<(A55fXj>iWG`N|8Z`xlPy_R&O?;pNn)G$!SJwp`!Q zJ%5*kKQt@JGUbDnWEuh1UGJw%PNLNNJm&Zeu@NvKtAZVhz2}my>?QUOI)(O0Ma#}q zzF8+K-ld{7C$ab>JBew~95ly)`#)=grRe}Ia~kVwAE6r;??++r7gLtKzkpNZExWCwQQV!}2NhdSn*UNYu zAuKbP8>#)78)NyvjXS^6twyaS2Krt*ZPsRH4V&F-2doG(KV# z>~dRl&%2DPH{3``jA;%~efY0s84He#^mkpzVH& z_%@H|ALKVU_8Du4>Ter9-Psa`(y2HaC?LZ8kXW%v+UO$1>D)6kt+;k?Sj*F@f2 zLAQOOL2(Ny@VicDaRa^Y;Zv65(a4W{@0U5NBqRdcRB-B25S>p_K;=f3-ldqV04yox zV+8NEXOl2y5_0OUZ7DBsoCy2#*hWZKC{rA@`GScKR*N)GzgO^+sC zBeH;lmDma*%7dZ!EDshJ!?I)%=qbv`i(p__){>vA2K2WuZG>f=^V@5>!6xr2|3wu5Vvd?})aS zK&i?sFti%p(=}v!wS#7GQgTb!MW6Dabs5IAE(dC-;x;ao%a{v;(C!g3@WIS#+YZLh zv@|)r&Uc?5B^ZO|GdUl-hu}i=yj%F`Sffk*f;8?i#7IVAG61xVIGrbA-g{MU$IPu5DoxrpOik`8P1Ywf^Qi4 zezG9d1ixgMwD{aTuJW{-szjKO!dObD@Jnf(!Y`$D3cq9&2Jr}rdJ|tUFbV>XIx&HA zk`|K4xj)_`$b2yB8YV^-q31Ql!>=Tm7#1W8gB?m5e{hN%-&;4Q)7#%E(l?9tN!;bZ zu=F}$Fk{pZ{sl@ULY{8~PTZx8sg;@c<13GKDPzepr%2z;-3r-hKtio2T_)QGsB58q zS?DC`oeqLVmm+x$&N7O?T?+MT2u<(AZq%jBK2c`++jen-*#DBCzf=Xdc%P~Oa@-!s zi^BhMg5;<@nt9zIe_TL~fYA)S^gD%z8UYVA0v>7vJk$tys1fi`BjC+x1TeXe;T^kD z8)l;*`iM)+G0j9soyJ5Quk}_#M8?=!bL;#aRRW3RGPByn#nCF)K89rqTLvi|UurPS zqKly+U=4t!#`8B?UbM;9+<4n{<-#1fzVLXMG|8=*>RJSM57!FK97a6VM6OhWj`$0s z&n=uhe`CFcK^1Tr@%(lxJ@MAP!x~QQHW9esg%+ag#%?VZ;m82JRNzdZVH}ko5Cw;A zIZYl83{ARN;7f@hY4cxILD%;E;+1PvlWyn=2MPrHmSRBZ`-j z*Z-$dum4Z=4rs6cPYqJy@;8}xWTuVcE8RPQ&H(O>em7NDMpyWDGlk|a zc$k8^DeU=U&6G09%wX6={jp}MFo@Wj)V`W|VOQ+YuK3>ACkKTDdu1grqtaa!*ke?> zd4)YTk;0ybmYWyY^U!khc|Ek;d|?oo$D;O9rm`#ZwC)rAoA<=uGN`pAn8yPA->sOZ z)!6~GKC<2|mm@;c8_<76 zC;#P3DeTe2#d<}MA1z$0R|NUdz{Pq+kUvZ9YG!Ru^4|~oH}8pm)}U5ZkUtCfJ1O$F zbs&o!a0L0Y*a1h7KZ_l31}O;$+RANYUNefd?j2YQg2uhkR|nSw`PDdtBy3iz>rNxW z1q?cpGpMzZA4F^D9CXAp2Zc2>G?wZ{4!hxr_rDx=!|T~6Qx>(n{RJF{9%=`faMy9* zoP6(&Xvgbara_NEtw4-J51{`(H4eJXs^~xucHn(t9D1+=?-S$DgB^IEmx<6wv{hqN!XhUfZmmnvy0T92`bQAI@lQGLj`xsd0t2jc5V{cBqo=`Mh^;GJDOOiM$oy)3GV_qZA? zeTEpM_GOr4V=r#F!uW+JXqgPD5T7DQQq`-E9Kxs}FP(UD;{66*GN6-@^pgqhRcb9k;4e%Yzl@f zRuQE%aCEEvBlcIUy(7gg+`~UrWEm(f*0zx&oy8aUr;02?uMm7m(y z%x@SEqx?RM^7|G=`4#p%l;6<6iBkmkwM=Rai=P&?4)B{5%U0ogtt_<;iJv)Yjbujg zXAiXw3;(HkY83_CUTPi59LJx8T1Q2-K588kaM3t*0)O^X>qO=e{5e1^mSOmFKebL~ z&fw1iwMsnLur)}nQ=$N+)@jl35Vao3oW`HS)H;(HRTf1mnDM9zW;~W$B#o(yq;njX zy!a`du&Q9jkE&qC<4G`M`1!B%c)gTef@xa(jXYZwYr?8@ED;7lEMC&JmyM|#^ScC! z9TnL`G(Ud>TKXf-P&UAs=H%;2McfYnS4q* zG-6IN33%Iy(GVVHxG#>a05u{NDaKJ#2Sa+M!Kd84w5xT1l$dB_zz!rw0se7)jhfSN z{kjvj`Rft@fO3;644^0q<9si@5e4?n<+7|Nuu7C3A0TX|4o{Ep8M?5l;RsE#8#)RWw30zQflz`HDM?c`Bc2OGyW1h^1|L2F|ZS{7zRd_-Y^*m7>WENThko?zRD1te{k!1eipZQ&aZ zpmZ3k7Pf)Ec}n%9iwURHPY7`SwhlBcAx+%2moF=AFxK?Z=kH<`0UDMR@-z!rxv5-6soNI zjUCnqw%w4`oXBMjH-?YntnO`iaWu}(md}o}v(>X$`s~D6VONUwlVvu)PIPNFR4NHk zu&iwcL4*qWLV450SnAMM*Qu9v`ynS~KjifGY4$_TK!JR#B@Ku+&C%k2F||nWDdUz1 zq_-f{426=|*Gte(e6IwS655H1-EVcd!FaW6CJ$>b><*On#M+K@#iUCKuv`S;xZK!7 zcgyimHdIbgx!QTVRUu*XvkR==3Z(}nANI|L_j*d{6-5r=m?;bcbgjr)YDd^f=++MW z=~cP_69IGKMBIRuoec*DrY)Eoq5-Fh#A~{ZgP>boZT214tgbdsoYJhWHqV?T-(^X& ztI#}G{2P7noJONvx)931ZRQ-BAXX*})uy-&v+LK~I;(r3Tf=bnA-1uwJfm zwr#}$2p~A6NTN*0*maITB>hK)E}pHlM%G#}i#8o;O~GrbMOt{;RFQ`FeiEmC z|9Sfuy3*a_J%)~XHo0@qhsjZ6dj^}$Sf64u&Fv%2XioRd0O2|&DWkB6qgo+ zJqEQtR5&J67PUUaPLgv7H!r;C9z|$E+DsEbsJdp%d=3lu$|O3rC4(|49owElz`f`% zwop946==Id?9E}RoJ^uu*`7X`M6a>~`_b)c#w67K4#ONO9%Oi>x(i5>!(u%$X+SdH zFOwD|3sfN4hb~$lDIQ=`t$1@6h@Gm}<*=Zn+ITJ3#FpFw9fM&lznEg4p4^@pcg2X6h2S%(w{vci5PS1wMBk z$V-vrHcGP$NgsW^sBOAS5rX@|P_k)*C=Z7sc0cdNfQ@*oWDb&jLo#VVa`>1`T99NV z@(gx)Ke}N(R4jMtahvfQ8en0pde!oN?!{G(=6>|zsz!4^dT|{-DEu`PsQsfr-BC7+ zz2HP>1RMH3(q#+_Hda*093=ZjWYU1-VcETc1xZ#CV(%cef7~#~ijSt+6!+zOH#=x! zU6ss1vhRpY8jw7CN+vBxo*5;7$T^8p`zH+Zoy9kdbi?vyovGXT?nnr;isV{R3BtF; zEkHTB2Unt-c1fUr<`;6RqPTVMT1$RQ&)$U9sEr*{$s8p6&d8(z$+MF(X+iSYcZelo z2pzt{5+U9Tc3C2Z&=ss=sv&d*tC(sCUBM0?6hMcm{XcM^;#FZ6pu;&VD1iLG+9lUFY(U<9^;tpF{gM)bNmf36|-zCb@9Ez)KZaNX1NTufy zsSlaH#t=ruwP361HQbOnjcsQqA}n&_NON_Lc4yM7DjtEs}xs z2U;X~d4nb1^;McO(x+V)iWh!8YqC!EgK%0+x%SUsq-1S4cP&J zndD90CIwWN(VIEBd-&N0YHL42)*9O&1ss39v18psKtEVR2vuuni%@YY)tfre#c3@T zdZ5Mz1!S$c*T^8ZOx0WK^i9jXeLPTO1rAqh%OwL&)7!gSV*h)fminEhmt;-f?s$vc zpL~_ivG2J5=hy__r<0%ZA@;k9Any-kf`{+058q+m>UUTfFmgEaqI|1_pFHzG@>Y33 zy;UB}T##>-@RMgAO5Q3Dskh3J!kVQ-r#{6Cwk8E-#Mtj!5dKd z=s5pvQS0RSCj|J@nX??cNv$*Y0&kksI^A=M|IR@L4&F4Vbw+?UEoz-n;KwrK9K1!X zN6pE-!CNM^#(Ey%zjIK5gSQN7J$kgve*>V4PYLkl%ta2~rq%`XslCD5Cbh~vr}^(3 zRN&xkgIX7kPVnCrwJuzEQh>jlxx&F`sr9&dWpD6VlUkPr_Sqa%;NY_cwH`luj{ml( z_4vf&p7tM&vAYdC9eBZ75BJ-90~8}8k%qpTAG z`jN|xjT)YRJAG>)`wbE=n@X}BUx>$StJR3TW}^#m?)%f}fTc%I*a#hTRp!KOD?s0B zoq)>hdS@F%_CUN2@d%pjO{p8ssMX|e9m4a1pOUsA!@|?d3`(u6Cb;8=_{k(?XC*WD3AF&Hav<+OG zS|Eg7b3=DL<>CZwl3QyHuh!V%{YpW=LEy-=ETcWgrajW5$RWa-ZcZMe2+(s#*tXESW^j_^9)QqxT9X>!lW=j`( zzInL#_ZFK%hhfe{gj;12pI^OZ9X(Eu! zkm&agYYr0qLn9rR9KU&yNK=YhzSzL3fnm<)zKc4t-%ujU?RZkjgdKg1D~|=|wCK&f`kSL8if=6M;KU z=mo8aDB?#9h>v{l)M6i1T~g8BVi{~Rajl+)OXNNlhu$3zMcBlrOdwzOxsSUb1R$%g zId`10vmKXlU0DUhUU2R>Pcttj!m>UJb_aQL9_yWr*y^L$)0ez>v*Ok#aUGn5g>Mqq zvQ?tobo$I&T7DXsO8CcltrekquouS-mu&~(ZB&I5eQ}-0YwQR|KiuDR91!NPndN1+ z8g8sPqhqVm`ck03jQ7*(8eVBduqG_(<*QH->K@{_TLF&!vs#(*rV~v;?&z4VLd)Kvm)US*Q3ihEw%%5Jl3grcNIZ55 zM|I41x7*MiO;t;`QE}^|W8-@ixC*1OcadqtEnNb8Jtm#pN&ls7ROQ5r15U~|8t+0* z*ioNlqnH?D>3%%jRu(y_TWQg$_VU1T-HP)LwwJEEez4{F=w9<#cIolQVKihB>_d6F z86s2bP9Blhn_T6n!zHQj?Ys7mD#5k`b(!D6}E~mzQ6vyr&Ez*UP z-g$n)*X``8m0D+`ywpjgPs)3upb50cE4HDMNV6R{-itcC7ktW$%E9Ced9Us>R$c?R zqhne>KG;6RQo|`LB)rc5iq%A0lDws5cAhP*y*4qEtW2pzN6ZVcE1qj#XU^@i(4?lu zt)vX(y!v!|F*z&rf|%D?R7?oHBxLCYlW{u7XNxmRWwlSRk`D#Mg-HJ?0dcWFE0Y1{tJj*VWpL*)4 zr`r3a!NA325}8Z5!hq4`iveE)Ufr$l5{lD`gV1fbPLxI@HT$0?dVMw7(*g*WBh_S2 zN-+lVJD<)g!Aul!DnHBCTRvWhjD{O))eg>qI-y&CK6GmAS*D7VfgwKd0d_&3L{Ju~ z-BFtJOtrw;;EhH zbv*TyS6GX<@*|D<{h63@yr!_81bvDIg?fp(@CF(7r z6Cw62N|ih@ic-fj&#;SE#@Gj#5G$T}hCK<%H2DC^rhAwB#o;95K+18zGe<98Nf*aU z1ApT^ZWsW$uCR#&Lb>7k8}VkUe4A*+Zk;`KHkq|>#2$rW9($Huc$#_anN$l;Gw;zy zyU8VTb8ohy&C#3(Ih2vh1bLxVqT#)9O) zezGhK(d7FKEY&lu*};(xTc+qlrF#VW`JYCz(7CVj@|uOjLK zrRm*u8)de_V~>eY^y?T*1V71Jr9|sc=XoANWdz6h{EMH&~ zQuaN^M%SF$?Ts*Kp@{+%T9tPkTdw2F_YXn4U>(FVElxgO12`-S8lI>>&Ye>_5z%X1 z=vE8wW8K(o?65d={D{A>isJ*~KzN8XN5$fo=1rj^oaj1!5&(qDk5SEOI3bGyjyhUE z64neN{_ePovrH%L{!*EX^VFJ)_5gn5#$|l7QGxNaQ5IJ3csu0RT^Y?^Ck|nwP6T@s zUI-1SV<<4F&{b8@q+Q?|6-HG zA5}9tUvt7b+6p_NaEnEU)Vo|WDr5egi^bjEJxaevh&|(kb@~j+ZMsg~<@%L2Wvm`HV>_u4nle^bn<|Qkt3VjbbAbwZzt$d(AMTbyXEU^!BryB8!dp9ornn8L557L7vw(nI8 z{W$5_Ml2*hAK3%biY!YR>8k~ehSS953R}-t^ep-uInwgAjH;+wt-=0>Bc81sP8H#e zL^DvJxXDL>GbKGq_Hn+h;=8iwL?6#vfGqk1$p?P5*6?b#aY#}wkhHEW`7= z#UC@s{x)`{(`29-dRtDoQ(bGtao~&9qC0h_u}Gy~RN+bbJSjwOE%56a6!YFt_Nz)f zqjZDpOHK4+XJ(al|3sW<51+(-$JtW76jJ}Hu02iVc zs*TWTZVGahx^@(p^M)*WEmP@_^$dE6WRZ;pv5H+`;GDUZsqmtJK;5kcs3yTDgtZ3( zv$7~)0tnRg3_4Bv_;6JlUKERW>l1s7*@71V7_z2P^c{pCMKJcEST2w6N{x9abuClr zjb(x^lKou20hpdc;U4c!b%nAl%SuoKQ7VTnkwV>x-D>P@xz(muyRDYV?f_?<*j@3q z+#6UF0M=7E^hweq_AuciulsZpta~j}k;UnGVsC_AUHHs9+_l=((5qj|RIng4RSO!e zEk6=o^SU=R!^=QZqBvb6!<#|qy&m|n)2KQPZ^Pem{TMv}4qQ6F*Q$I?)ti#lDnpx{ znWa;tSFPZbklgaSZ%$R_3K{r4Ghdlmm@62iTV$vUKzf?6YfF{+_be^0Of6KW7Ur)m zUY}cBX`6t(B)hyaRaq$*2yY_|xQ|oMZrkJk< z{tT5rD+(8musTM12E9V^u3zUg@4E-hVJfYQA(qdQ~A^f(h8l~_OBJV)p&tM%PVN}UJK`Jh{WpRag=0WKtU0K|wxD0QYWseri(YAp^NrQQX!k{sgf#GIZY}6e`@SD^}<_9JvP{j(3wGbPVP+ zs!?lW!;Sc%hR+?=eG5#=Rj8<}<s{x52(PLr5atz(IkpEfSMGg;-g( z^o2YbNXsS~}a4Q9n?>$%Uhwe8- z{RAqs)$GVEe##*IsX^){?7WHXh!B=wl#%Uww1*DbFCl6u}C$w!G39DYTb^)7;I)cP%cxw8#&6H<#hG zINeYBG~`O})#!|?SZaUD&;r?4nVOwns!rcrSy@`-GOF0xOxW6HD@!+Kp%Dwp;(AwI zo?DokSt;1lMKY4&V!h@6WRijOG2{W(ju%pEM|a*!18+-e3&?3Qz%8D)qN>{rYMaOt zR%IXNUXSJ=k6s|daJYIs@ZD;#z8<;Q8@`2RD^z}4!cWlFTeMe}<_PUX9rl*lsg=3v z)Z%P)W&S#BH~pj+vz8VYUg3fUeBep5^uHSU`l>+&5RaKKX!08- zxsw}v?BwYxNm84LC&@nUb+(G8Lx`hM>En47$SS>$^rvdl6XeMJ^{K0K)tSoDjq1uP zH`=eak|VpJ9obDfObV5`nUyIN*k*1nuPj|JSTL=U+{dby3wG%*4bnG_W>mbh??`p_ z(e83mQeOPly7@TS7sX+4+vV@{V#;-!LVdg(x6(L8`fEYxyJ0nS>Ru}ngSC%mRxqzL zOGwflJwgsmFI8seD%}XvPkI-Zs{FfH)>kS^@0u%^rQcUW9BefM-}PhtI>BqZPViDJ zZl+m$nvhx;G{sARwa1lInn5bPHN{>tZ~_mL=M;O7V}eF`IHRjX=`%L5uZCWImn;7# zr4-5U$-CbyLVf(7E;hDfXT1sRfc5#MmbgIYGp>*4;gxx6A4fy9wl9xz};2(wx z{_#}$I(Dz2k-Q*pWfdW<-QG^@wzrdcl503SK`Rz7V%=AY6qVJF1*}*R07?}ykp4m$ zIeN7+KUXZ5M2sx=;)i2M#XRhmG*$5eaetu!0I`!hz{KC}q!d!Lv z#?%bR76%E%FW2W6=da(qF7nON%Ve;F&1s1oe{rs|GC#A&vq16|V9*|7tt{Orn23dAlvy@yUixb9PFPZ9iFACFVNN><|fpL8K5OA#frqXo%1<${IEmO${;x~Pf zj9~5hj_+)FHH0D$I@bN5aR92%^4b7V+@PE%HWU?{-2E(qZ}^!C7F1*V=LX43Olg5w z$(KtjMDQ&A-s;9vSqu>l9s-tYcNeSZcD^PM<|+|p^1<1j(^ zdj94DXS6~F&gx4q&Q%tsUMU!*5+Bwe!ZZBh(#o~@#jBhlDsu}TGpBDPGa*7M~*f z+qwvRa@*aJd~(~}>3*$LIz#$Xoq@qwzA?YJI9K5pEQ?Ec#rrhr)0zhv9L>I`W>)5x z7Tf0de$uyeV{WlJJ3qCsbhThi|D-_KeVgtDo|iV#Pu#Dktwg=*Y54m;nnT>^ zxjY51QC;T6i(i(x?}gm=LhgHCy!aR9L7qD~3E7jAkUu&3;>8!f_D^kYVg0-NA6i&H zw6K0lEUb?v7S<<~h4lp)S9)0a-8zt2Of0My_qMP;msnVzQx?`25)11K%EFpwj`5#7 zvzR%LnG^iyA0Fmr*B>@Ae_tRBe>+vL zPw*HyFvBA04Tvj*qP8tR^^7yMmfdmL&BRpT1z2HcEnJ!);z?K^!Zx^yYs#@|7;IqxBi;_%XnA!W(Fg&?&TW@D%y;j`EQ;MG zbm|uLimh=i^TZFTQ?CbpRNjbMUhL}rP+Q(@FFr5Ij}gLF0@iSy&}W;$HbJ9b_{Mv| zgAbv)GJ3!P9!z;ppCp8xLz8oTJYZquRd3Cr>AO(W7H3=$iXF$7+d6!(ZFBPHJX(!pabA+&yx1=>u4TR_#n$(B&2P6$= z$MamNwdqxrHG8?~Z-_y!g{~93(z`gesH3Rj#~^;=Vn@&!;f3X;MHv6p)m6gHDN5M$ z7cZ42*)6`tD=Q29G+!@GvJwyO!UxN%m&Q{?N|U-}Mv1FhrZXWQuRH+QvJ-7OrL-Y( zN#2QI0kQhCIe)|RT{mnmEcJFulj;07L-6;tyxMJi?V50!%?am@6FVW$ylbPV+c3#W z{Mxi@5uuL?rKS)VvJUUGZqlaLElHi}WEjN1_qQ3pCgd&6aBgGs{(8`c35-mlXZ@{PI5^3vj)>+>_~fwbUw_PwQ3PhM~@J>kB$ z^xTr)@O<}~^YHn(|6b^DZE11t&5_~Ji){4q#~&YKk3atS#KlXOAA4)NoLnC5-e0LE z(rpUu4!W%$%bQN=BLi8!8 zUavaBG6)6QTf0fqcw7O*2x4~sSh#m%*RPLF3bQvWgQ0@RF!>{cLeW3V; z!7AeT&pq1mn26$fK_?yepvx2JcF|#IL&Y}n?R}DY>ufEEH-Ty#2w*RzMs#N?PT_8S zoVy=_qgTXQP4v?UTU{Bg1VyMk3C4@`ie8*l!{(q~R3>CU>ES)gV%M|`12XywNrnX( zL&KyYh+$Cs`wV-e_+B0fLZaM-2!Nl4jMHqoPKXYIJvf1F`-l!Ne%Y{!|Jp#m&ovb}%kg~S22VUF0a8Mk z7na1KtIT5wlP``KhmHqN4f!pVgO1O3&gygR8?(Xy5C4=w2FVXtNOuEPm|ryPgT)V9 z3}j5@5*8;6N0h}ulV4j1a+C>M<~K=^G*7;^a(zK_ItOD`?i$mX2(NLXEXJ;M@5Z>C z6L#zQbavay|DfH+WuCV*A^rQF!bLu5evF7XPf63Hz3q-GwXg4zL@>=Md>Pd+QJjme ze_>p-3Ajl7S5mA4xJS$T)t2{E;b8GpYTXu%f(SGV#peCa&T|059e4T{}zT z&1I65r6RWJ)m;_^TRaSTqanM>U9a;?!x{<@O=0X#R1C~H%3IX_l3|}K{*{SqtfYs8B50ge_l!Mn~rHX6SNhM`4Vi;)J6FE@a&U2@r66^sfmU5ckjFH+Bf` z>Z(>1i?7CE%hksYcF8Xpc5jklKn8gv$-to+9V7n=cPBeTbH8txe_~k0&vOYw*hVtc z_erjn%WS35A0X4BHIv$ZY}iA^ODJ@@@i_AX1zGo(u}3R_@*f-aK$3w_ z2Pyd^KQ@^(_os&W9}KJb?WTMla@3?lhZ{Vlg=JQcl$;~<$w;{4{5t`UX%W`h7QU>C zxVHjWq@sG%-G&>xvc@=rwV*+gbP%$j=}O--94x+6t%bpMgrZ?5SKaqsn(&d>NfUVn z2=}LOV#3j|OkkuxGwh?qOE^7Hr=N>7;t4I1K|}?;wp_o(yx84R^U}n<_s^2O*F={4 zGo$ZNl3_tc@d$ZFG;2`%&tZe(apG6S{m80pMFkC%W0r0Zw$wj2?7k!e;q4nvGAzhA zG(vtTi~L~G+}8~ATTH9?9a%QTgj$2^$80o;z1r=aF~(hPNNKZEsN+^A`soQqNkkrG ztD57DRb(X)+QjAr1st}T@>{TqJ27FaZGH$=S@VtTAh9`Lq88!aL_`Zio#B#j8? zE8n0y4A{fIW{|@q#;IY!jQkro*wt}r2%Zs7%98u^LFZeq_`U^#>YB?OE|K$G85GLMVi=ZnVB3mt7RCFp-z4l&f&j-liwE%fvG|5Z#HdK{HNmdE*W`GWOv7w$n0IQ zg&m`FzHsq|&kBTyKE*-eX`69ccVPwjW>f2~ft~3)l4Mwrar^}NNiIqlH20mR`2(g^ z{62%tcb;_AQ!z=z)`)>fE3WFe$_Q6Wc)Qkj8@lnGrVH zg^!r*B>6i-Kr^WQantTEe$~)#&GZYUy%tTvBCqbQIpH{KI$_M+z1M;}F1r6i5L^nw z12li~=b)q5sogHK8F5(PBCKe=88oA$eUvU$O4OUzZ@hT4R9+c2b}girf z*U?F2a029$3==Z?_VGJ68&7WEZ`y_8dzCDOx88h`N#-Dbn7$==`b+-=!o zMFGzz?fpscfdTn`Q*MNif$b0MC*N%-w}7TOQvBd9<5gx;GPf1A#DJ))n}+Mu(d|86 zBB8H!aeG;2+;3APj|_a=J1#)PHrX%3904}_bccdw|B&ub&^&mE{FW&_3Vzx&S@9Q5 z4E4U(ZBm)dIkio7tb!pH_m<;Alyw?J-E}U@Lou9Y?s%fxN%)_&9gG&>tJ6aJ^*Y&O z$Kp}AlXDxA;Z5mf?7eAmeWa>Wm5$DW^{Ru;xe~8%hK(dt$s8p6sOC-a)8^0-&70z< zVZFO7y(xayG{=gMbU8M45zK`{#2nTd(A*_{*4%$ubC>v8^Xw@3o2+z~_=ahoE&ivh znjB}P#21y>lr~CEU5_!r3&ugvSaU*@NZ*F_v`cJ^lr~Ee>Owun=pr)gVT7&9z|^Za zdsdfSzs{a_-TImX(k|!CNGZmYYMN&``AjF?;!2tmE_{s@kmbW$w>QSF4~pvbaM9Uv zcY8tHaC}=lc8=(;kPWbeM_Xvdm4Yn-IZt> z{r99?_gCt@o}||$*sfR+_NsX06FwPjiYu`bHkY?)wNv{`)wCeFKg*W;Tc8 zIw+F{BKwKIy=k5-{^Z``wTxJ=lzdq;1e{{ejg*|Znszq1`nK_T zj$&Vu9#Mn1|#njUKZN7F17|Es}6>4+WxXQDTlyK&2DFyR=6Z#vKs z{m~FM8N8Em2-E!EC=a(#rxm#&GN+j6s6)Dqs=s3AM)5h=a+}VEON0`#yzbR8fe&vR z?Rs)Z4k-@y7?A8gCX*H<*-7##10!GN{?g3+RMsed%;3VFl%=dl8l?ygQNkV%ttL-%Rqqr1LX6Pb{4fiVcGj&TkGBgh@^Xv zDorWUL8>-roCJH&Ow=Ro6_M6SM?eDogk|?6875?qKJx#ANs-Uc+$SybC$d&CNETaI zQAOa@JFXt3HaR+>5q}x2zohsiG$2WtOj?jUb((yaDS|)Szhv3N#sAyT zc*Re)ka;0^F0g2Fj59eoW8rw=5(}AiW(^=uNt1dxQy9! zqH5&&_3F0U070gTov+40HPWq@O45;DX_BowjY!(qlyq09F#N*bX-Ql{qz50)TwhFP z4a|!#CbI^%t}nu@`6>^YZc+O$Ec-<9f3vlS`a(c{knxVU1656&_roYAoNySeMSHta z#}a-*$3k+}i4rKx2Tj*Up|9cjx7Ez+w2kOAg-Gsvx@+^7@Bymr9w&ko+m$q@&o90>zmhT&(-(!>rdxwkC6C@- z>VijCgN0p9H}<*~>u5#0_1i@cp0l5oLw*~ONduDmi!zDac)%(quDo^*`L$mr4M+}*$fO0yBS*=<tFtO-!E^mNP&sm+9 zkTSP&^+RNZNCv^i1z|0?>qg@&bn0G^Tzv2i3qAxPtA|0e9&GbBjt5%wopjxHR0*qX z20?@mXWr{>GLh2Wh&nhB3b!-bGUV~?TkL|;HL7H|G*5MI0L$9yubwZWEvM1YKD6(^ zyDilc==E+ux7@nd+UgD!Hh_BpUFZh1;e;FR*MaHFU7@;na=O2PT%dqDwuOt6OI6KK23V&Vi`iN2EC=(|zd4AzFPhX+Uz{kW5;TJa~wF zUmp9Ar}p32_E7P|dA$!Cp;wnTglXLZdELOKc%evM9XS4urUNcQpdgotg*L4u9tPVH zMx9e?K`Lfn3&Ys01uefW-!5cD1SN=}%JXkTYql)0!!BIBAnE}!mlSH7PQ)HZJlcw1 zB)gWs!pq<5ZkoKAyUXNm`T_VtqL&)6$n!T)A0u|Rl05@TB$o)%xx*Ag&5SOCEas2q z_>*}3+vwq?LJlZ5pF@eRG}mGrHH z6RqPE()9$=e)?#mR4M%opI$=pIb0R;GHF1vSBhnM3z7v&zL>)$q?g*C&Dw{HU&tj( zNUI4ugPJ_Taz!eJI$TdOS@eqwtYvu z(EvuQDQOy|Ow&Kuh0kBzO@jJ=8I6|toy*rpq^_c1Zw{xD+`4-WNUE*7*Mj815%M0K zc7)oW%bMql3vcP`8*hr_5cmO^G$48KoJ?AfoG6p;%o5W!Y3^6EnP1Bq#sAA@Qwb^L zyRiuEE9HdbhP;b?KDmd=yAE5uPw>h7fbR%Sy*>feCUD2-rui=0ss`&-)fRfgmCE`L zjZz=%HRSD-nfi!jn+COiHJd*`9wWAmMm%50nzVR^yE>4pcvfl}3wu_zu>f;(vz;$w zg^m_C+WA6O?0wv1=L^}wAo+Qdn6^c8|00|D{j5>^jLD{a&8$4-$AIh3-mENe&3(t` zFM*EVVd5jF1W7iyg#CS?P4E_Tx?jZ)Z@cyrjF4qe`(I@9{p82s1cg=|U(T9`ivQSX za}t0w+zcY2)YR<_N^RM1>06Pu9$Ux@UzWVUjX%De?WHnlLGr*savl3(Q2Q&;mv?Y2 zCPye7!SHIG;1UbwvN-fMHqbF)V$@ZZZ5ug+*DI3^pSt_p-f%GHF3__yGA0i&8? zn){1v=67>O@wcr+iy>|PC|)GvO=U?SpDR-qpN_Ddbk>49E&-t<@s+~2M#pDfEx^Ti zJq-LT|lD>h5z+)!uvUwO4uA$flrO%NHTVR6dEg{b2I7CWpbc$72_@sLEG*0W~?W~vp z)fXX+?Z{k69SWh-eC&Z1!1!9;_j``o$fUtr3j(0-@jL-@qPdfci$kIQpP-D@ z_d$+ty+@d|;g5bU1df8R3%89|xVSpM0=nT@W$CzS34 zyXYcZg7S7VhZAQ+DF$iIbFAm9W1GC$j>Tdb6w+&|)f<+ZI@J0PbPO@HQ}Y;n`@40A zN~G!hXk+!QFvItX=wdq5_Lp#C9ojh<>0Y>FaGq=^C5Qc>x?c|>Z);g=NWpX?YI{RgnX-<;iyStu zr-+g*&~TsYISD&WTjxxaGYpz`t{L182_H3)v06)C9XXQG`mCEg|4B)E&*vkk&I2xT z!re&_T_^{|hbBl~k2D4jft}i&$M%7pbqo_eFeYr68vHrU38=nE^dH)U%f`8pI*f>p zSCwH2F&%-2qKxFjL#Gm~#(SEH^np@fYkH(c2$XZ(VUadVeE0Zl;11Us7ARs%>-IwB?GRdS&jawGf`{+-^PX3|cal zarLFH$#o{J`K-7H(fg*j^%B+}%99-q}H_P?OlhetJP|KrPZ=lmD*<-&{*JYj_1W*9pSy_ z)_-3gN^`A9ku1CRN#*Fi$QDx6#d1ZpVY|f9FaXQvBq`WUfMN0&)jM;}p-+0UxBSe9 z#f+0NA8j-KnHx*ta5*$gL}9gD5=H0cd!cK{F7nE3cA?i$wq_nbDuZbbG1N*>;B2fC|}&fazAJ4HR}4wwAn>)cz6 zI2Je7d|+BBim}=(ezQ`f<-fy&ZoLo9G-Q#CL{vv6Xky7?*?QIaB2&kr?({`Vgb+sQ z*%bx?OavFI4FsJ$QhI)l?&{2bn$}+M@`kLeB`f$I{w;NiRG(iO#6=C+tW! z$n5pF0jczNVLO31IzRC{G4%6%Wzvl!tvYT5YSQ>ojESdUxxcky-tcWr`G7F8hD1A# zK|OvH0oX%FQi*_sjSAxa;4Oj1^G43_9BvUx4Gh1H4b*VbiqxSl%fa(6aX{B|-e}yH z`%SXRA{EPInD8E>^Q#~LlPK-zl7aG>o%7+wDeYYm_4kX}v#QQ|dfKt}kIh4H!-Q+; zlrpGSYD?Fo#8{JH;q`bT4JJLiXBqmoezx?*8m>Zg7`&AGeV#BnK30FTS{^~#6v(P2 z_{hPCf%VyOCHX&Kb9fu-Ul3?Po0i%kXCZ(D3+r`JwUD;6PtgvX6KhbN+d+ zTQC!z0ZWm^arWN<>B9T|$}n@BDi6KTppTS}>L5pb$v?xl!l$GwH7v_4*VW8RrC;c4 zT3!1nm@?{{Ic`?6mq?G6BQBXUZ5pm2tH!vHFUbWbDCWZmL%(|eGfzN5HtGH;a!JhA zbq&dUAW2%Pgn2*&Bqfwgq6|T|m`w#DON=0D(bqv5N(mVz+=c-})K-y7fRjCVKDH$F z!GP#DEcri8)6;tSg`{8j@8rD42+zdDU=}V9qJoQfHZKH4hK`JqK_a~d8tX#xU$I^^ z3B_y$P>S0J==@?CMxos3WKtc-GExBxt%*fqL7%B}ASr3-k5pX!QQkl$#9rrepS}`v zlN8^mQv*)Gq}NoTzquF>4sCfW<0KU@G@bNXimG=w4My-LMj3RwR0m{$*?r#Sx?U*^ zfMRc?XR{|#>Y-VeWuI5)4j~)@i*ZZpF^&`%eNE*n@_QoJB-)I>UE(`7KdF;%>qBG|Gt;e=m%^ZF1$UK)%O_9jYSh%TPHkx#rAe)zOhNO_I!Dg9oFwE3o{@b!myl zHI%?&B-=3;6ANsO0wBr@HSx%qq^Vspiq2o-p-9T{Tm0qUR$ik9u9fb(lf4bW`)Pl$F_}XW zhiV~OQh0XQd6pzxLlQ%LxC?6CZqE^Dy;!5T`}j$ybmvk3+}yMEK%!V1BcXuA%7cT8@gr4?xXa22hR^}>jSOqXjU&^?^BCZqN{gvOOyvC2%@ zNUI18u4SDgxzqK%UXp}dY}?ZnpnGBhi4}Xy%@d0JU5AKHyis9A0=ul|m)CZdIA1fx zLYEZcx4WM%xo#*4%ZYI*k>q8#99=c=$v3DJ<<73uKGWG~g#zGbuf&*D^moHp>J_Rvt__e(g8V@WQi&P+e1a10`$Q;n>4mEykFD96LQ$e=2UXjHhAZnIlna<+7%< zAP*E0%ANU(pkfus!IEve>}3EpRJG$G1XL{6fvpLM(EYR z^(TX>qY~21&d1C2UI^&=;_zi><}v7tB4y-c-uwxara1xzG^kYef=;V;0Ci5Y4PLox zV_x0-3+Ao(&)fgr*m(l|JYGJ!czmL!8Uv+$t10=1{qWb6;+KyO3C@`L7mYjYf4!{N z+a9@Zxtb(dY)Z%njg&*?Mi87C8ruXM5oO*uIptqAe0P`O$}(mca{ws4*ND6WqB3;j zKF{*D%Dn{_Z>Ug`aOh&eTs9EkgorGd5UkEh)k&EwHUJ_xraEg99~-5Oa8FQeN%GH! zUQPd!>kv)<^0ne?Ff!1OfI6?Ki*0iFIia1J%mWM3Sf-qw{5Ku|?fjoGLbXs>10*Xj zL(m?HQ*d6h^w`*PU`_oI+*d_%&{iDx~`plV@C%IedUY%dHqYnNvHsM>lF6dx1 z_QzG4o1ISIN@qqdB06vi)ACV6ggNf|XXD(LUS^w~8Jl&1Gh7qq!q+1WZOd@#p#RMt zT>8k)$Pt*ISo7}3F~m1Lv=Eq=0nPiCEB*VZo!JIB<#Qdct~~?R^^RK`N4!*@iby|7 z9_Y!*i1uWftsDVY4UX@H^DEWL-&6J>^FkXE?RX70!`H}U>*EQ{mfsBkOAOD-L(FTFW&NN)XCVvYz<>sp^1zcY6zk+Dew0uI-pW4~skJ>qt zT`)oZP*0x(u9vR8{o{VUIbGRRyG#FZ*BEdB`T&RG>)ZmiG;6wY*QTBitM_T|7$CdR zV*1DXvsdtrc&;x9a6Gui$&&5`g&1yA!6KA&=wD9m{ats*> zT$0Xnjn8vmBDePog4uC&Pjj$*;v(1_d?tmGeq8N{iMpx>B42O{#x3JN@as2>}BW|_Drof7`5eU0kAYUdAXz`JXof1P<%!@0ndqm?js65K-QA+dip{b!co z_OBEI?ZWj^+r+oZOl8>w6KobM^~9`a3(g@Lff=u`FjenT-^aEzBNi0cs= zi4B&}H?WlmBRzO4*{$@>n^sC&*E>2HQYR+4(TK9+$Tr|naAffOh)ST(Vi92I$g%1# zBMii<)?4bta?Lbu;QEp*5#`Zng-UZpV%zr*imm(-7*P!O(v5xc63`zres6)wf*Da5WK)PfLV$E6;3MiS*u1e4h>=PViO^|`UsnKG9?uWHZ~Hb?JoWKCYgwYyBnw?*f9vl5aa9p zQ-xdc-V3{XHb1$8wK^Vh&V&_w)V>hpytE=`_#wSeT%rkXY^~y1ITkC}s&_j5XcltZXwcssa#T?EgerS26H^my7Eew-NmNVy} z!xyttyK)u1%ff_sp41T1OouxcP)>u^^d)!Wo%ZOR9QS3m_vhTX;zBc_%XVs6g*y3+ z_GBCU`hLWoyRd)gkQ~n4@k?!XRn;ezVlsr`Q?SI^jF)?aiVgA4o{crbhA_G0NHEn% zV0Mi7#GX)`I}(2=J@C_bkg!SRLDz7?k` zFx31~{)^}2f7bAc3@qV2P8rL?s}s>8a=D4)Mai-x)q5D4z@RP@pv!|uh4rIRAqcx4 zA|@Az;1=ZZ0Af@lCkH1LNsb&Te=tW{Q9YxEiTFR2#f-orsqiLYsJK6s{pJk$1&GYb3i;%z}d(Ow99~B>P8JyvV4lfC9d>zYKue-0+BT0oE|c8W(?Ge5r{W z<4ZaWXKhToieUW=PdWW3IuADV4_+I^dOJX$lW#D%cD;~o1JA0^&fw09ebky)14&|C zpHA6bwopA2b*IDSE&vMNV~qZjm6_RQiR|gI%h`H>Ap-B|7P$7j>ct<1HRx4i1BG)+ z>{*%q5i%dnD2RwF4ougl9DS-GA{cc=;kEsAZJm) z{>CJ1SFNc%QP}p?S{+RW>NPC`b+o<5GdON6Jp6_7j_LYYWvz!CB(a~sB)h~*%-o&U zwqJ-`Fhw&dz4Y&Fu2oWCR{G)H1c-JFWUpOV*dF4&o5LInKARr@7E6l#v2Jd<9sDUB zz`L$tQHrI1rw+Z6z!RRgb88H@R-&K8#@n~V;BQDJ)96U8rf2oC=w<)w;N}RS!KG^0C+{s%mPe>k z0^XDnps10clMf?FBKlOf@wd@Ho}0Z5@(dR(QR9pc0zYmlun)Ig63|mkR=$*F`)PZ8 z=+`p`loH$fN|~0o4gYaviZw@z9QvKaNOo363o^%fR7J0VNQ2otfwki5Y8%DMpNe=| z&|+$jt)&eZq#+S27E99H4rK>7(jS45R0*yI9GTXyQ7Aw%dmRM_hXhx$^=K*l9uRFP zC{WV{`s`CeMp4VLGY&8#ys7FxYMj)_qm28KR$BAmR&mWZ0Ckx6#h2Xmp4g{peacKb z%Jo}yIJpc|*#?8l$$QDN3V*YxTv(!S7=& zOI0Q;3{5uS{N?>6`~q6jQF+%$0#+`LXllabP$0x>R8GLFg5|QtD*?XPzzl`U?DaA7-`(8R{bDrFnt#4~sn{%}7nE>h#e zZxfg~^g+~Ts`X_K;WwTUupB#CE)Gm$9*dizV6Mxh!zsf38kxnIx=1088unYpw%qKm z-I0IZsfuj2BpPu1422)w5wu;z42+s=cSvJTGSvwkliyTxbVIGp%1GJotCq0&Aq$>B zk>eY=@)H__6xz`JV2zAeVBp<`xKA3GWxU?I^pps<8~wto4?Ma5ZB~wIN{?bu3W>=lsmwcLy{#Mm32L1Xq?0!*bCsS@MKw9<%87O)5iFDRg~=A zJom(A)~?_DQVl1j6DhRM)8E8ABSH?7?Q$dY(eLNK^A^A$dYY6M>hMrLWMQN@$G9+q z#NsFKNibpS%o^0UJ^382Seed;Y`auEhuGGoXvyGm17i4C%C!*?mH(e-iv z`GZ^FFIm*s#6uNXWg4+Ek8FW8)7=YHn`Oqf0L-RsvaSFaI4ud)S{v+sgC(;3sXgYf z@ghmy+zvy^+q(U6gL$cOUtVU{Hv3WbOSHzd$AebYt0q(eluH_Vos%Jb98QQ@d~_cT zonrq8wY)~hDXV0_9x@e5T6)R-#kP4lYD~3VpUjWcSUWfc%-C(FA7F@^2&1=#p0O+zmIR{i+#SQ=1G;q;|X{L zgp5h@#aev0hMwi+|2i2Y$(ute2&J$7mD&&1AO(3J@tG0u1$GRbXhB7jTrOYcFjWaz zO)ERrjbMg#$2)A=0+CTKP%hyM-;mb9u^(xp)s0+dL{G%`OCOh}1&AZa4nLl~_ zh`@79P5C}x6zmeV7A||K(`MY4npu2(lbIX1P6!*7%lMX+9rl$m^_EbgTH z9p|MNO9MZrBHDfXIPv3s6#b|xGUJ{IGV-oQ#vlP*(a47um%vaTG_qlJyeHW8lYh#A z-_rGmmGI9n1$$Bv>mS&sP5~nt(mY`k=&<4G$Z>@vhl7MZkL{Enp2~H5Yc~e3dH~X? zR__O3jGR05LD#fZVJo1;Iuyq7!|~cO@oC?8fLpUSzV_H#@YILDN%uKjrCpR4D9MG6 zQ|GfCC;nyJekFJ6_|cQ#EvQrHS;)AALw%WUeu6oZn>Eswx$kERp6hvZGdjzdX1Yv} z`@9Y;xQ){zzCqCFXq+^Pt3CEg5)ny!|D00$=L{hKgE(80ea8h*aN|A|&Eh-qRTR;h zia}27c?YFg5U1~GM8@3a1(=|kp~(DGhsLDuj1?8#Sj%7c!}LLGQ|1HX(L%;gmRBdD z=)~CLRLC87M~MH8*%{n3A=H==_la$GOz-kkpCxVwy%Z=WZZkdzHkb!_o& zI(9Suk(kY?eX`*v!j;3P>vj+}v>2_8ORGrVv3L8pE~W)5)aNyns0lP6@L@K@2*@3g|CX@5J zH^-(hm7~%bhEXtJR+STXfdK_YKxjnTM}zpu%NF*x_n%41V@$c6ELvr9bi{3^pZNT- zFHy5q4erc`Sp1x&2?+%De8o!wmw15bMBoD?{Qt1TOt|-)E$JvADg~2lNZTc1_nI-K zblwxxvS`EX_j3C9D0{DajEEy_TCxk{c;(kO!HCjMX4BEKRz=g?Pw`s~zizWZ2S&CN zXUU30jNMe>=wS3E(Q-j!v?2D+_+xIbSUp`fm}AYH10WssY6wu(nD!;=?P<2)x;BrI z!5fyAfBmZ=5t19fIH^G-8?QPUg?A*$hik`t#Ph>;0luJZKXe&N@MDlB{?lu^3MfQm zba6`-d*)X^;ZmHruTZ6@fXYPPK&bYqVUxg@(xs<`#@&N>RGW?wTG8~s77_v)sTmQW zULA89h>M5YCEOYFkZAlV^%#k@tfaM88;!jcR<(>_hxky*q8X}MGB|ALr;p})4oPxPaH zs5T4wO)uN@-4WhZRessVif%!p;%&{+?2*OGCaeYfL3 zeItF;qlMs~U#93s^CoyRs#1(gxLs)~e;j!5Rc7qMI#Z^ZA%~s~zmEKFD!WU;E5pGk z2)Z-&IZmr6GlL|?Ga?^q@UO}sG`0UvOt-NsXDe9r0Wmu5UFv`a<%*_XuOBUaF7bT8 z2#6T=J0%SFSBbuRxt9Sn!t+<_oyUzU$b8EE+AMCyS!Bkrb|&r6Q?cVdYm2sTuC0Bc ze$j#^c<^}ofdP2tgsL_Q7+d}kw*{)`0kjpEwG}YSSD=~fk1f9GS2G&`12g-;l`qXALvLxk09O$QLXM=*I*x~(>?{%_4^%Xnbd(+4Vl4txgnDM*M z{D&2C&!{$5HWuRg%C8j)BWhmkoEk#T&68-};IZ|$$R^)Hi-*yDo6n$s@AQ*_=xKvD zVwa=}F#Ov=yF!r1n-wv7(Q<`Iy`KDcXRu6a%V%6J>wd5UX)}=`0M}jHbqIfG1@7R3A%^3*_ErFak#9*oW}hx<3}xH z;(RCJN5o$F5GVn+l+A2jK4+dI!1X57>_3{M<({t|B((G z4=2c0yszv4NXDhSr-y!RZ5J0kVefLKkUe_Q!B)q@3dxMxhqUFAfsn;{H{;e|cWQzm z9++t=ow0I3vRfjtu}sAAZ4$n%g0-T0 zs`p1emf`y7g{izJ1q@+|F6qr{7{se4*QoRyrxtjqR!tQ;8dr6K(oAMbH8riguF=IY zq3xS!c7E;V67(1^et3z9CO7h)q96R+$0qFntyo#<2_YP*%$|72%#<6!PU{Pu@c$M; zOaHXiIzdwoq~Pre4@NnUqkvz)iIQpZVTJmX(-zJ^s`x@xB$XMBzZ*)C%*G;TI-*RD zG<30;d)5GB`T{?E2b!emgip}Qa2&2bH-$w-BrhOtvTCkr?tBOZ(2`5hn2d5TQi=O9 za3ut_<0}=AN;)>RiZ}O7GqVg+vF3d}0^hv{4%*fQg4V3{t(CtT7Y{leuPDEJU0N}! zLvi_(ygr7vGJkh~D-x@@F(S*h`&os=pZj^xKrx=?;@S_lNGnLyJ%dlbT@#%K^Z>m` zW+Yh18Xy7SuiisbPue?Q@l%0lNw&iMdR7~{p$5%}GA%MEli9$LVbEzS^e202+ikL| z&CnimgopIal_|nIeee+x{NN3w_Z9!ym79OB?92A!K9Lofk&o2MGh0tVnB2gBOJTo| zu(;(`E}(B{YZ*Ro2&I%3?Z6pQC9EOcK zoQ2)BNIMTLP0caAQ#YUmgdsj}#Jw9sl;JK4uSfrQdZEGCS5L5bU`QxzVsWsjU=rM4 z5opI7RDVaAPVU-YwxZQL0!6!J+bu~Z(hCM1zSz^#1|sRjYw7>sR;DpK)B`5G_k$il z%e8D4M^vxX`FPW5Vy}a#Ezy~O_o`bXT(zAA-6XKiF-zB`&tlf)Qrznb*&S|kkrK2_ zx(aXjL;P_-K%ejo-9S4S)bkuUBQ;y(D9zZva+Scj&az5}0kX!g3Rl`1i1DI@14pxW zfrOwTBuglrecb5U#%=IxSPYFL1HY1UX>@)u6rh$+m~1X93&$mD21E=i7i9p|B3 zbGO3<2lYj7dWCp!JJgrd1RywMa+_8!5gvuX{iY!Je3dBSvCeyXTj&!d{;O$Ip$1#E z&+veqCNsFiArgBS{dToy(AX8}q?$kC zVozFWg>ZQJmOSCi#nw&;-UKqMVew#|GYLq3caJcGi(u1Wiclbg7B z_;J+_{O`8&)lZ}=E=4FW*J|ey5r@i8GJywKemNZIH&$;$0fMk!8XDdDSLj@~L=4+? zfa7pel+j+}n4VAxLvBEE50FUV3c*Y`A{rDj>L)y`CRhncJTU=ZmnQo|Fk{RdT*h%G zCbUq6K<)E=sfMza%*Fbla(N0&u(g}faGEZL3jz0NUT;s7Ct~yP|F+qO^M4Z+0mmdi zZAZs@O6x8N-{78GL~{&(vrC!_wrE3Dk)d7$D>Stu*Px?t1|vu(r)tGK9@%Em7Qx_= zh|yg#4#Jwl&fs4O-8Pc$Hp*xQBTSs`U^T)L@<%fBAba7kMa({k22TpM(`^)s%U|?j ztSXVhdD*a^=~~d=Te0$Eh*D_huPr=nSs_tYs71eoEg6frRfw3}*w}=ik|COHtd~L| zcl~v_s#@h7kAFY?v#_MAd->S7J^qV<*h~}T>^eEnT5>UD{Xa>a8i-f2|K3ScW&XTC zSG2PkqLT|Q^EWrTLVZmO+@^+y>zJQ)k~LQ=_pIM~zRID~88Wx;h%XC+kimRY-5(k8 zR1D8?R-%mIiSj(|b98dd%b6j&^Pw&!6g<)aY{nShXgB@IV%}@6mZ9$d3eO3Bzrxo+ zNQvUyW#_ZVGRh`ND*8IOC8~c;7wl82Z@?|B|3wj+m63n&qRGyHuX03F$qz6?kbkh! z-Fj=?^hRjQ3Ng?fbXq9JPrr!>K4)tsd^wYhvaQCrK7cp@=bdN9duR21^#jOpMB>e% z>hGBQ^~^f$zyvj|74O`T1|EzG`rbbI+64G+m~cu2^SzYvy|2D?Zu7H@EI#y?(bC8> zlq?shNoPGb-5ZF9ZFpuU=OhHA2yorD;_3|{s4xEhJZN3$v7e*XEfDt+aG$mP1%_0qOupwXXS_eC1g;feEdFy}k35KT7Jvp&?et>;6g{ z7>d`k%g`IKYW1iKEbCuQ#-3@egx6Q&Ib#j0_N|16+)$moQ8hpE-tjevUV^6daiZ|aAxesF(xTD{PuFuB^C zmHXd9tgdf2$GdVI4dNo}B8ge{i_}7Fwi+b3@?sgd{GJmUjH+YG}0?l-;TcXi$oC=#Ra~F zRqpIZ?4ay#0bPUn$|aNv^HUeGVuhhj)Y zgJph#@C);73HfI@`Z=1wPu-v32CzPOR1Rh55l3tBk4Mj)ZMWX1xkD91>!p|*H@Te# zNA)z~vn)i`7HxT<@OtbscIvwI`0vWTuNPa{gzDX8)mGf$=AjZabJkOGH|;O7^3m)&V}3|8u2GOutvA7a&yfD*WH4Z)Ewh)Q++?s&V`to z#_2%|hQ$h6&Pt^lshNOk{P%yOA^Z)AGeNMOXM9`_-!6_97gOZ{bw9>@yTsQ7a)yn8 zh6R?T)O@X{$aJXTyLHwt%KOb}_BpFBOYRn{`BggSrjd88(SOtYW`?DHfLHuqce@VN zr!k<1`~=*@qE9@X(s2x6u+X<+9&Z9PRWaAIz|!q@%OGC;$1$f%@%PIMojT}xb$}=y z&2p)7=uS@$bQJ4mfk#z7Dx0Noc8{hQ0?S+2KMswUn<@6%G;a zZ`qlj+zT8`K|rfQUkGDCfWHZm070mXi*h)Yss^ik`^j9w-r5exQOB^2Hx$C*4Iq8Oxe7mewfYQ5gI2Z~gk0ybtc^-UYJ1HZ8xa1{Ts#OXn z0S%QY1kGR3(j#O$v<4^cGbzb8_Pv>{->k=Ook0&u+?D?@f^P2&3q`m+S6`_S7%E&` z1G8eVN!+>scvNkmyFw`q@O!S4)d`k(t;Ua{5k646wkC=FaaK=MPf0K|6XDI-2u`87 z*2sdU6^3H!7>pI-6b;-YL;ZZ2foYIBmL0mmMV13{@*Eesr>kl-ge%rJ+Q=}B@&_W) z5mw)6-IxTY<#1IZg2a*(-^QIo)pW*2iMDN157MR zy@CKBF`BLnyC%nbI!t=KNB^>Z_$Wf z8_ZdzbXBo;UenUZ=cW70ye@%oA0|SYqo)jVqS{JX`~ZS!}{J=b?uhcC;EHw=(5&p^gsEA*p0*S0V&m(VBqq zz0{`@2(H}q75Rg&6G(LU^@fV159xAuMk>N2Pk=B#iha{@o32*TeOy%~s|RTP1)NoQ z8U{Z75^;)~#gY@l7_7VtqtpkB{_~&}eBIs8t*S_FY)Um2!4q=hZ%qSjbFR_uOGr{=qKUxyWAQ$ z@s_I|@$4x{k{#*_IeC!70u$HH+tLl}YJ-ckMX%haTF3FpGaN zc!i9k`)_Z;Atj1LAlU|LYu&Z}ggrMC$*634@*2cM-VH&WfvyJI_o{F_yhk$q$5Mx- zLVmFr>r~C!rcK$ z2rNga^MAz9W&$&OF@h2FLHc?`N_eUr?7?ZpvGYi9_~#299Q$Gmpub?rTa)q4fwM&Y zQ)!&Q3DLa$dyuKjqNE#P#Ai+oImcVXBg9YA=^ELv>0PNpV*+S0fN5yr`RctjeTH?? z>lQ7A)fKlEj2Jt*I#UDiL0M;!wcTF0?uX)jZgBzpSFiDp(WIkZ?Y`%s79`J8T+EQ= zg8ko$#|q@)q%*rDH{?dN#{!z$vUr+ zpGE3i5bJH97%5JUV?(WoNmL?01VLX(eQakZMQJ)Bp|1~GX_{|7186H4)_t!L{KUq@ zxV%c$QU%E$G{c~=NAv*6oH>|7<2^+?KE8cNUwzv+plKl(|+(Ax3URlK)>bGVBRNoE{ zExYt`Va~B(K}~9yOkUwWFD5J%yreZmj|=tEHMj{`N1>Eo7hng~8KF`kEwAn_>Khdl zbq`5NRR?Tt<(`#PkF}7fCB!eZYk@Z(NQiry_Ic>@khr9%18)8f`K_ST0F%6(e%@=J zvl!_D);pVIvi)Az}fVZ}SU`wwh&_kWj+3 zNu{8El`(;O?jex{Wxskbqx`KTfU)Wf;2;vca{H*>hM zwnOk={*u36$Y#{#vh6Y3d;1=hfluL~fU4)WzJNhYLD6H{SK#(P+X~DK`5!g7ry2KE z?YL||AH8;SoHcQa=@JK03nF@&dLv!6hR+X(Lz#x$B;AIiuxr;b1K8(&cl;it7_mqS zu>!#i=?}vi7y5%HFwyMOyW0qp`Ey9>dhbi{E#`Yj-e=w)BzxQhH zM4ueb=45J9(S8}bl1h({1FL)TkTXTXezqp}z#Vxkx?RQtvb(nZzY5X5g{i+-N|8)G z05^&e;_Qv_fN0EM(o-S8{MUlqKiu?UBUOhtZsBxn^lPB;LDyLKGWWY|072=g-BD4r zk1_Xt;TM7$PDLM`6UCkID6($kMXKaobeFPEJh>W9m9k#D=*aeV4ZkYxAv}@a&hi^K z??Qp0^@7Vi7fslg zHs`D(Bm*k!Ny8^`MsMJB-g9?0#IdK302XsqE5rxaqb&8{)XohGrR`lBj;!g!7CCgF zsD3u^e9cf@h^Rbmmo*ltRsHO*hW$#z0{J+DYqCS5c7O{7m%{s*Z`2OmWOfnw^N$w_ z|6jP_a|XIGG8gz4*;XT1Sk175Mjn)Alw}8W3=9>V^R54o`C%M&(J@RGe7goN*Tp*j z^4Jx-Zgpu4SgU{`5hXDvYu{E)6xh#y566pcYKHwBaZ1g?v>`K+y|Kvt7CoeaEy`$0 ze?kseVu6U3`qSAkpf|x2>UsU$su8~?&@NMHm8p`em{$D$Ly$bt2}2W+S5f{Coer&h zPm(W}C#A3Sh3HC%^@8*&xZ$1sn09;;n!BDM$ak0=d8(`b&}goT1`@Rf(yaZNb}i~BQN?)rUm$DgA3 z$uV|4J@f=MP~`zqDmzJSg+*A;N@4<<1;tQk^oZP1zp51*OwN}+B0GLYJBK64%l-T_ z8~zfa^ZHq9V@a&#G2In$ooL-4@7E|8K}-ig^Z{>x2M_wsNGFGE3=@!EPsu~FO z$v0|_`TDWX!Oz#l(Z@0Hvi%_Kp2yoae3PfEk-^*ai@$Abmf;rFd^RUXKa1bnEHS?) zcQ(%x!TnG#CpDzuy{iE`5=}^ z5)>Rt50(wI;e1p4*SJO=cBwJEi|!E2RC6cZgBLQ*~l3 z*sbc{1~-?hz^M;{w`7*lPOA*f5`JoD^HmNr7T}5PT5Trf56R3`7g@tnL4QAnQJJpE zv%HX&Yd;=wxuZcp;vZyVYPltP8ltm5JB(kWVWXI0D-*WDJH`O1jl*#!z7jsDQiMBZvC~SuKt%UvM4Ly*1H4 zVZu}fx;7Oh-+ODZKf?vuXZklug@8=Fr&|rLa~Ae z$#|Vq`cSV`_o*a*=@gmJ3aZHddC6>&b{>5hPs>k0tbHY>;_Lk~G$y}0s})z}AD>$($=F}^X+yVoh`?Tt#MZqaJ)8M_8C6Y2sgUXH%0HVcSB@H zD90Kl#~Day)mhBo*dGT;BHeVZ%5%LGP1HgKbP!dV!_l!S&mKq=6*sEnrb{1fECy*U zJ6tL(llSO#5cg5-TWHoYI#uPrt01ghhjyEr_cZvm@Mad6Uhs`C5y_ctMzfmg90|#c zayVR_0*#53`ur@?ySC(<;J3o7V&V?4uG*Ijl~SQq6q?E=>HYhKYE#WnSJ zq;>;#l^WIhEsdORLC~@9S`s(6nNa9~e@Dmhm$Y^qYulnJAxlCi(Cd|X(2Iufv&2~7 z0<1ef=wybV(C0Y)Q7(wcat3{hW)EVj6t&j&01surPIQ7_3tKQvtf zaAaMuj&0l6*!ITS*tTukHYc`i?Z&pX!N#`p=705G)znN))xEcFpFZew`s?r8AArbL zSRg4XMi7{touu9tEZJpKBmLiVw>bU;rU<*l!b!tjdgi-A#kBK=9`IIuiqYeD=ud$V6SZ+D+Tf;I? zvWa_q{kSUu^Vw;(Q&3s7II`F!>L|e+wv=N$x>gdcXOq`eZVoD{1UK7;nl3H_hV$0< zQy$`C`pH5t6TJl~P#Z&OJ7;j*^M!a8b?j7T!gHZDle%&kQU+DrTXawrV0Qf+tb+Fd z9gN4*7p$=OJk2Q;ZS)q7?!np{dD`AmSr$M3kvLjZYs0zbo4G5bHkwE~{!!N_!l(VL zkQv~9$TzJLr7LxQel*2x44dFEV|e(8+iNgDlwbV_zjol#Dh_dR+eu!0_`GYD_3JtM z)qBu6zz3g^RBCw7G|)~piIkCT-Llf^xcR6yVQt&voz9o0>u-ebx1I+Rp<)*pC`(ze z55-B)zz;Nk#7}8*L&AcMB04O{nq*??Xi<<$4b7;}?5H8mAkmslzHP4P$YZV5Pg;%g zO$q~&GJ3F|RMLY9uV2oI6#u}{T%NuBDb0KXc&uttksX`pWf5o-cHN$nJr^X2ZHT2u z`uFeeRI_fY60!>n04PSTWfKMT>L1JkhY^nIs&2M)T9einwV;xyN=uNl7=?0>b2)gZ z)T&}c6y94#TAKX^z9bcWhPsSs=sdGs|9M$kV{=+;rWu$*`}A%nY1q?zUO%{+^;kR^ zTtYSiSxD~};z=ZhY_G{BDbX)7NX!K-jEJ0l{c2jRkn#2)X{R3>s|U-r@e2@wiZKcM+ zY35o{Q(ZaoB*eQ+WZA}b-^Ilu5DnU&`J;HtUR zWaHJrYGa0Mx-stx{3YN@2tT@1j3t~=jY}2IOf2V}3JD$d7&BZHJ3n6~|C-6?d6kdR z+o;O$gWfk&UTm{l{?r`AWo)*xJkNUHTFI64x5rG&N{WfE@6k&X zm!%dvnh;jq2L+_rP-ydY7+yDs`_0cdy#b@=Kfq}!r;Q}<-&#HA1^)KzrmqrNn>r;| z8pP6!5FoU{`>@Td=VJge;2g*99Xr))8{T*!knLVYGwrU!Qbbeb@XDNm(A?WU^8-}yZ_cPk>qjI9Ye~_`a z!|RLX!Z)`jsH!ASUO&$)?PNW+l(?t$Gb@vfeMaS&{V+QIHKFaCcAPnPTx`(;xm0ZG zCRrVg^aH?s;waUtfVDRvX>Z0cvsD?ot|n8rcv-R$P^b#n8KKa!vY*L{npRYO=xsMt zq+(KP1>q@e%FknC4JR6SjkDL>PQSdb_oCI568^}9T1P7P-kcV#2;838>fM+cN0T7Q z$qW2l4l#35{HwMCUv{$SqQp=E;m`UgO!&2-jZ}3<-9?q}O`?^spDKuF5j#5%R^VM( z(m_N@`NN)1CmWylGxwj1g~D{c=gi&5_)+>VA&b|gt>)ujaTfQMizN@{xZ8>IT&X|~ zSe9J*8at7ZN3J>aTE5f=!3GyM2 zpan%KFe5y2ijQy&3+=e7pLeVfR4u2=hGtxDAs+)>^qS)i?7)gl-YKRRoer5c93F%E7vI5z)xUo$Rf{@ki)_l9 z5J`3@@@tpH!W2cwgv^+!{dI5WRz1W)LJvH=tsVrQ(7qK>sQfb_tQsm6g0lu2FUMFB z#&9-M{sV9HWdJai8VKGs5PjBqdB{gz!&E$>S$==MKO`ocBrXsgf!mypSpUa}6aA?L z#d(NeB&I$F%-L}j?D<_bEB3L;EHn{t_gYOx_!)Y{!3Y zHY^}mZZ=eY`ttG<{Cv+H}D=QnSRigE|6HF9+zxMj!tFz$)SR>ooJNJtqCo>C1~78JIidP^6qPIz6cR zFvT&X+8u{Pg6zd^B)f6Tm`%V2(xmy%myVIWOBez_xSxRLQ0skIW}wJF(Nq5L;53z= zmYXR+q^s0uFN-e_^ve9w2?}6*qjQQ5D8+2?=BRp^y~h_Z^G#-Y6Flh3!|^PP(D>F* zjx^L8SJjq9qOq8Ct-c~I;zHW|V)okg?o!%3Uq0I-<;fKl;UGONJ1f^$T0~BO=Kp-1g+?Lk%04*_)eSsHM7z#?Y|+~8dB7A z(1B+p3Cosc`cXm*ol}rK#zjAUp-fwB+zs0&tppN^QiNEQIY@N$Us4lpU0Sb&#ALwe z8+u&7EyZVzx!+RL{($wGHO#tn+aS8yQ!t;^DFQBfRYLXA23Fw%5qSi_Zg5|H?t_*U zI4fk!3~kWrp~WdB7XVk{U)8rdtb;sFBW!XLQCm^%s-{056JDW&5B?!$aH@1+=o*Xe(h z1SAnkaZd33{|_ki0-gLk+h$|G;TqdjKg?jtpH}LzB-H{vQbk0s;HsU{q>lY0CeOf( z#tT=WOCMM~<3yHM4j7gld3KjA~ zfT;Jy1aJg*xRd?Y;jFViX&UqGsh=?iedk)Xo*jzc)QZ*|EPg8IWw(2 zU$dZKGa7^G84$+IW`6Nms_|@0jB0wA;x(0(&`bL$96iF5WaLqfZAlN6FIr-h_3Pmj zzGE{?Q{X1(?Fhbwm1CUG>*zQoavK9h%TvTiU*)C}mcrf67miE=lbd=HFgpIM94;?o zs(8yGGOUKtEWVRZDiPtl1oG9+06_I&!(F=2z# zF5;o=EM?4EJ86<|s`6OXCy5_fcaFpXBsx#n!3}T0PH{f2?nRl}U>gZGsbH;dqw;ae zIEvd#Kf*|t#_qCS;fY(3J00A2REehV1AErjpOK=i;@VWrMKd-@V*`|+_t z5gM7yg79Bo3)kY<9|~uJYLDvujCEp<>i$fw_@8AzJBcS2r49K8*O!FpF06|slG?;o zZ50k;=CF0aj6x>^w>YY`zR{b=7r0sbI@1_)w919lbQs7*Mub>(}S%?5%3L{e;!QBFvXlqbHO1H)z zLteD^Z)87G^DI()U=)fgBz`JKlZnGvYvMtFmCg2gQc9UH?I_PoyZ%I}5KzRJ=FN*$ zN}UJ2PT!Y*UGgKZiniDcJql;6D<_YQYQiI&WPgUY4pM-cSLLXpP+7<_rrikT*f$u8 z7!m1khZ*sbQspk~_0O7!+y;m$oBM!4MqK{2KmYJE_4!cd|a$&%7iTa$NZSKT*sI(oj7|4w#Ghge=Ax!N+qZ1@U`r?Cs zJyI+j*_te?QRK)G$;B#03d?^5i@AV3>vlLEJ#glHh#)7yb#G8QEF8Z5&A4SH3?v=vO^26rD94N6(t$-7S<2|A50*kv7E(oM81ontwHwj4>#tOZ>5 z)pX0^m`om$|H<;o!t@e|#`R2M3~+|JzcMzLD*%&pNC-{ssHg~|{=_JI{n3uLKY1?2 zLT$YYXu{|8(tqWK5QX*)5P8e}R2r?!lAVv5M+J3FCTDYA)8b(Tf3K5(l9_%tG(}z* z146+Pt*#uuBS;JSS;@yt5N4|bL+^TH6&z~J693#_(2?tHGK-kLDsswY79nQ|!~<&# z32@EY4}Jh^W5(Ela3Xf`8B0I+;y8v2&zuD95z(@N+A>XXZB6RCj7lYrZE* zIhQGcHoam~vGXPCW^n>LD`FP;GYq ztL9Mppj?}rEST-KTCEf-o<$j&9)rbEqeToG3ub}x$Ph%7nqp>re3)uv3lngq$30tH zk_tft6P=+#AkE)FnU?dhHdz6Cw^2JHLKxu5D2mfr)u#Qw8lQ0CuT*+gboMzaHFA>quNrB)2E}#|*ZdaV~%n zl+M*^t$=QT&Dq1!?9^IP7{0eO;-5ufad{EPG4D#XkK}m77a20~+qIFnGh-ndSVve> z!+`$Eswp*^!?}+c6_z&^zG~Q#$-9wnSHPz!A1bEeZyF_cBNF>@ra4!E&me7?AVw#paZ4m<{KeVx4KAp(W|$r5k)NEoL<_s6OQ z&C7z-?0;K>5}_Jf<|xZ8(H_PT&u&#XpIdEt43j3yqK{8<#|Zc+5x<+*;bB@5djZ&B z=>iPVj1QyJxHEE4q#TZ?RvMN{lI$%Nzhwhb_I?r?c`X|pvj`?b0ydjI!-$^_A_$#w z<8w@a=vM09CHw%uaPE`a-wTt^v)4h^u1YxmsN{*f5Z&WMZGzJk)-d^K;~+Mc3V$sO zA?{gI@>6dVjf`{sTzdwKOo;?gLH1AsKuE$2&04X}dL}`K;Yl8u5c&FR5qP*Mv1ahudwAm+zvBrzOfn=ZV5H&E0?8i7 zvQ?9YIx`+EJ~btz@VTL6A{_eYp;*0s`SrA@J!8u}mOZ@OYgKlJHb>~W*xT^yjO?Ed1NR=9$x5&FGYzuwnE`_8 z+&tavvi&-WL@5u=0$&Z|Z#pmGE{JrhJ=)U=8JLXPd8|k0Gj>y$LdFg81NDqtXhQl= zD<905DXk$g^wD6)=t6m6`2c)PX@*tQc$xe$Oi=7cX$=6oRYxmrW*HGdD8wBa= z6=mQjh==tU`3_>$KWvQnO~Gp|SZ%Xp$m2hm)hxVO%}EZ|0$)VZ9msKpQZy!HsT?9Uvb}Zydhnvvm+N!x7Jb%& zGi@0guM92QL5ST#<_(2Ndc7ZPhurvy7n%V7qte+B|J$G~?<<#e#Rx_|m#O(vvc#)c zSq;I&xH|}c$DT2`<~w@)Zw1?m5R&~DTMLYO1`jOjis2AI*`96BigX=r9Pd#=z_w{I zK*-77NWjgZ8p~^W6lc%roXH{%QfqC!9(HvsI|)~bl+uV?LvLBu+Xij<(kerU(M^ut z%O+G~c_Y3(ox5DCGAt0n&0ViNJo~JCNH1Yi_NYHXtCU&N zh#g9!_EX+wU072nzSvW%V}uifyMK{2TpG6i$|egA zgqFMkMerC;v&z{1Kq=%J=<@n#&$h+C%V(NsXCypJmns>@ZF_rZnqAA5tg5zed?ZjIea~z>t& zd&Sd8tc-pXk9c#ukCfqiMZ~52J?s+j2!SMtVWU?>M|~n7wHVb9W}XLT72%{Tz_9l) z6i?u=Z%g+ZuZu*^o+wW@*pBM3Zv2(`_5h=KBuA)5+{Q+v1Ii#b^3SrNyzkw`U<&ok zj4uleQ>^S8&Nj2PG%>iPB2GHZWFyiQjtLXj@MnPeP-`Zg`8Hr zo98iyt;Ck3rsQ@4I?sg2cIzy7wAZJUQ8Ui&m#QG`?RqOkuL-o6WO|H6~e5f3LUNjGQL#4swVk9t!vL!I=;iRy11}K3YJ;_ zU|&*V4R6|2!6!UF^;f2K%;wihB5BG76vrm1y0h=93Fh-*)DoG&n#-~G)svf?2n`Ud zIl4;fqT7l7z}9>elUSwQs+AKTsEO70oHXVc#gC`%Y0j?kFsP)P2po(lJ?T1)n-Fq! z`lP$do0x>vLCn8Qnh4nq6+U1;i^-K_3c>Fdn@yPq-x{j%)u{<}HNFvLu@ann?xX?T z7jMuVL5Rx5x{})|2~rw0isS6yuz!y1mk>ki{IZ+~Y&#Av`s!J&cn*(C``Gg(vnTEq zz!B-QwqUmaXH-oqHFOwd{uv9w`ApI$s~OJJrcN}c1~x;kXb5Jq|ZE>r*W^xF;U6C3+sun9vZ+m$RL13N~Y2F7BivIE+7K;x@) zs}woC=rVH16kif;)|j=GmxuT}2oh=kgd=(-D5moqi`N?#F#CnmD-9en+L^iL=-gmO zn#a?AS450zuBiVy{t=+=HW6SDr_u~I&hQ=lXVfdcz3FZVTg{2)b2e9(0R`9G1(A}d zJh=nwCBj{j&Ugmqx``HI34)jUNYlcPeGbC}^iXf5b>5PlArf!3RuRD^VY2@87YGh& zlVu4TAXlVP?2ZCl%Z~>x)V+kRC8>`W#Uoxg_K7PZB_kAjlwG#^NlikZo}Ri)@FrP( z4R(U_9e^(Ib-O&S5uJm*2`9Op-Lgquw=U4wDDtWzFP{csyDnhQra{;LK|JEc|GVz_ z1-$yMzCWoqHd8j=1oBAmS?IPI;)9YoK6PZNNhhse{0KaVgz?6SO<0k7io>Xwl5nWr z8KJk-dpjefZ%qFgHy*P>|5+;@Q#XLz2lKJ(P_yur{aGAJ2v)T=G2@>XYL^^3E(Hkl zzU8l`YF3%OaFiD~!fE<{1)|BA=?J56l9ZFQz`J)E6w2=6F|YdLJ_9a#5nTz!-H4*= zu5c0HK0xs)U^vIO_Y=DzItKnm=w2o3UNL@28N-61fH;@xEoP3$fm=Gw31nZ9VreTy zWAZ(@q>A}B0i>E9GpL7?^0<;N*3vJ2H0`8ZY`?rJj87z)9c!*M6V?`UOU7S3lOF@R z+N-*}%|VJ?0^;PcU0b6J zYOhTPo;slp7QIqGDkfK$dR8y%h&ypp^mFY7GpFUudW}pSvHCIkuNNY`+C<_07710a zBe#99t?Y9i@$nx-WO|@_9bI=RMb}4o%k3|IOa24_KG4fF!W+<3gsjY9BPlfNs_|}d zg55bz0zn=m*(if6?5&W3RGs~Ypu|)i!d_OhjiZ%4@*nUZ1pf&CbkG-wm=5d6?X+2w zwhcisg_GAH1`gra?vYVX*>h}Jnw4au*r(Jhrpa{BORI$04c22qJ zRm$vkI0!hm3J{v@cT)$w~)m2E3_Zb}MSQx_>g}9T1A}pAyg1lj4sO$() z7n>W$u-G(6;@WwqLfUOM+Dqmt z$co{Gz@=uf28kwt-mnPgf`umx5eFGmb36&{GCaFk5YhR-3f`&VW;{l_j+9l})ScMX}ybDLug~RdU=if(i7Z*NpZ3Zj|iAw;?_)jyitLm_5`e{=k472f)eWJIZ+Zm506zk~)c z02C}gE8wlb9t+|+_Y7c#3CA#+`F^6f{}WqN_%1Qp&NoHLOA`8pEglta=Qq~S1%TzO z2V@m0*)YDOnrun7(gto0YU_DRFR^>jtHlJBUP-gU%&u81T8Os}wS83@U3%2nt1wvC zqh*9?s0oAuIK@ou#{$nT?0HG6xa`hN!p-=~uNQ3Arqd+7MLB$<-ZG6Ye*qtHvQb{u zXnUUTvTv^u*WtY0i6=Pv)%i;<<|fvU^OLae7;Oe4pH(X1-Z-QQn^2Y_dW2;Pe@Ad+d`WM#3+Nm_$nZTZkA96kUcZleF`5^|Bs@}Ou zcq9i>LDx!o>?gD^GHA$GJ;K8IR(YVo-+deJi)_x*!zD1GgMc&)yMa|gAh7q&bXMdI z0Yl#Qd=cu@-TdVYAtJURyuqzu2IM<8S(LA)tTJF5%*?BM z_YIMTI7H@-FyVHFJgl-UHGyc~nuQR!e#x8XRFHd`Vw}g*CBu?H{TcOX>?lbj<5O(t zQ@)k8_5OOo6{IddcF0#dA>*cXT#U#|RV*MD^Vjk=0mNnwm1WFW1b#9pk^GHe7Uow% z#2P^q)?*%soy^TapJ?wo1AIomWUxrbvHuqU~mgYGjNR|Hy7#dbr`0ZD=byeOo3MOxtVI z79WcBy;<>Ky7WL4@0{p&9^{t8+6pmV$?Plq(#ilBbXkG?RsuAe{C?`+ipqQ0jZOiH z)ia16RZYu10YXDKbjA_T+f_oE+tI6ohA4#U5fX3~aD&759YW^?R=#12dWq<^@UT(S zr|igKm|_f;W)0k4m~v>IIu_h93}HrLoodAVA1Toh>v-A^2-q{2Gx2@V5|)osa?OJD zWd?1=HQM4kL_@1g4Sf-zuW~f>K31X0RnYbNhW4#z8M7)_y?!P(iZ{3M^2&6oO7DSj zYSqr>oy%4Gc#KXTMt?Y=uey2Rl!Z;!})(UZqp&lbk!HqN|nPCYyG0>Y= z_&Ny0?n6ij21%;NXZVUN9y9i~kLz^6?@T_a&?+bJ$kBrz%|Al0h< z!E9%x7(+*#{>305(C!Gttvf<=kUW)-zub7emrINS1?{{Rc;o`WM@L&~9q>&jUJ0;= zAV&V$kVJ5#Q4BE$VI0Y}kRvvB(Yz^==N@~AOW5ocs#@-ODylAqOj_-7juikGEG`sDA7m(eC3LD>U&R3wyMcFJB8L^ZoW5jj_wY$z(5#E0s? zr9@tD$7p)}O{J9!Mp+2t3?mW(Pjp{8X;d83Db;y&in~d3x>O(WBEQBd3YY}gsZs)a z*?Gigm5sIBpWdZ56Ho_FP1qQ0O+$Sv3V#%Mj!k|DCZCrQ@0-AKCkx3*<=>N2go&eY z{H5rqZe(7)&%2j#Bb*ZE(m-owk+D%i4FY2xFf9<#Fj!3lP+}%2Qs-=;{))zhZ-6HK zRgp674@0f;j2(BLu2CPR=HR#(v!5iQfyW^|1(t=h6}MciAMrDC4(oE>7SPlzQ z?@tO_NIpaO@pU6A9X`$mCmO694)`n&^+gzKUD`Kqg6u>H94?eR)jA3KJ^l#X?DHaX zJ{NQfFKdDu(g|j>V_m)0BqR?46?ewj!X;0*UTYva#<5{KcV81iS)4@5gtJCM~r~A25uZ1eXBEdIIxTNDEtJ4HN z`~X;Zb>+MLbv@JE6y#x#HLM8E*DoCE8Znm1gyMuB*pbQPs2>0F_i*Fl0;u8vY0OK> z88%#;P4tvfaY|KpD+4Rm@}tVZ6ZQT;D(3IHr$hO#v@8NkE@^vW00b0^?YRXHU7q~( zr(kaVr?=eP(dG{?t*juehvp%mzN(`2bpbG-;o<+uB%C(X=AWm*v*!JEp^IK{NzHNl z^zEyRo{mw z?Jn|ODuhQ*Q)jbLin8G5$lORUAU%?Ahu(40V!Tl4zX@fp`2mbly&Le!Z}rZ~k}E>^ z`IASy5x+L}TI_ri*iHGzwm!04LqbzWdOQiDRYqvc=uC-$+)W8zV2s7$1D zul?-!%YLK4r}z<*yniWt%aklCM+M4t^a7hTb$bZDYKc89a!2hf+zD+lms$jH*_92I z;}u#hjzslE1cFVhQDbv5Ja^u0~i4c{;l z>=Wbl{x8TAjh8#}yt@fKDGS>6jUvD;7qa@v6=mlamB_{YJ-5`=d06L^j@8ilPr^`v zstYk<+_7~}bs_-=M_d_i^!L+VT9}>gMIDVF$&QFDwZ%9*dTwy^C+QrAsr*tg_g1Fj zFP9`V3i#BjBUxDdp%jv>RJ)&BN}$>C6U1XcVm-4fyVd$Y4;7P$$;a#sC8vTE!NJ(Q zO)_CLDzgqNfHW&0NQAxOvSB0?7o~c|W|>)8WA3tn%0DU*oYm^aboT#df7Yyn@jfq> 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 z�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 'label' - from __contracts - where contract_key = jsonb_build_object(...) - order by created_at_ix - ``` - -### Daml-LF 2.3 - -A new version of Daml-LF is released: Daml-LF 2.3. Its main features are: - -- [`DA.Crypto.Text`](https://docs.digitalasset.com/build/3.5/reference/daml/stdlib/DA-Crypto-Text.html), - originally released in 3.4 in early access (alpha) status, is part of LF 2.3, - which means it is now marked as stable. -- Support for Contract Keys. - -#### Targeting LF 2.3 - -If you want to use new features available in the LF 2.3, select it explicitly as compilation target by setting the -`--target=2.3`, either as direct argument on the command line or as part of a `daml.yaml`: - -``` -sdk-version: 3.5.1 -name: some-name -source: daml -version: 0.0.1 -dependencies: - - daml-prim - - daml-stdlib -build-options: -- --target=2.3 -``` - -After changing the settings, the source code must be recompiled. Please note that this will cause the package id to -change, which should be accompanied by a version change. - -### Logical Synchronizer Upgrades - - -### DA BFT Beta - -DA BFT is a new ordering service as part of the synchronizer that will replace the current single-leader CometBFT ordering service on the Global Synchronizer with a parallel, multi-leader consensus architecture, enabling significantly higher transaction throughput and fault tolerance. - -As part of this release DA BFT is ready in beta form for early access testing, but not recommended for production or close-to-production testing yet. - -### Multi Synchronizer Alpha - -Multi-synchronizer support is available in early access and has to be enabled explicitly. -This feature should only be used in test environments. - -To enable contract reassignment across synchronizers, the flag `PARTICIPANT_FEATURE_FLAG_ENABLE_ALPHA_MULTI_SYNCHRONIZER` must be activated on all participants hosting a stakeholder of the contract on both the source and target synchronizers. For a synchronizer, it can be done as follows: - -``` -participant.topology.synchronizer_trust_certificates.propose( - p.id, - synchronizerId, - featureFlags = Seq(ParticipantTopologyFeatureFlag.EnableAlphaMultiSynchronizer), -) -``` - - -## Functional Changes - -### Party Replication - -#### Offline party replication - -Concluding an offline party replication by clearing the onboarding flag now includes two major updates -when using protocol version 35: -- Added crash resilience for ongoing clearances. -- Automatic scheduling for clearances when a participant (re)connects to the synchronizer. - -These changes apply only to the `participant.parties.import_party_acs` and -`participant.parties.clear_party_onboarding_flag` endpoints. - -Note: The replicated party ID must be included in the party ACS import call to enable automatic -scheduling. The original behaviour is retained for protocol version 34. - -#### Party replication onboarding topology event is exposed on Ledger API - -The `PartyToParticipant` topology "onboarding" state used in the process of replicating a party with existing -contracts is now visible via the Ledger API when a party onboards on a synchronizer on protocol version 35 or higher. -Starting with PV=35, the newly introduced `ParticipantAuthorizationOnboarding` Ledger API topology event signals -the beginning of party replication and transitions to `ParticipantAuthorizationAdded` once the party's ACS is fully -visible on the Ledger API. - -#### Preview: Online party replication - -- Added the file-based online party replication command `participant.parties.add_party_with_acs_async` to - be used along with `participant.parties.export_party_acs` and instead of the sequencer-channel-based - `add_party_async` command. -- The online party replication status command now returns status in a very different, "vector-status" format - rather than the old "oneof" style. This impacts the `participant.parties.get_add_party_status` command and - `com.digitalasset.canton.admin.participant.v30.PartyManagementService.GetAddPartyStatus` gRPC response type. -- The participant configuration to enable online party replication has been renamed to - `alpha-online-party-replication-support` from `unsafe-online-party-replication` for consistency with other - alpha features and to reflect that the default file-based mode is more secure not relying on sequencer - channels. -- The sequencer configuration to enable sequencer channels for online party replication has been renamed to - `unsafe-sequencer-channel-support` from `unsafe-enable-online-party-replication` for consistency and to - refer specifically to sequencer channels. - -#### Minor Improvements - -- Onboarding party submission prevention: Ensures a participant does not submit a transaction or reassignment on behalf - of an onboarding party. -- Upgraded gRPC to 1.81.0 and AWS SDK to 2.44.3 to resolve Netty 4.1.130 CVEs (CVE-2026-33870, CVE-2026-33871). - -### New Transaction Hashing Scheme v3 - -- A new hashing scheme version `HASHING_SCHEME_VERSION_V3` has been introduced that includes the transaction's `max_record_time` in the hash computation and covers the new transaction node and fields of contract keys. This new version is avaialble from Protocol Version 35. -- See the [hashing algorithm documentation](https://docs.digitalasset-staging.com/build/3.5/explanations/external-signing/external_signing_hashing_algorithm.html#summary-of-differences-between-v2-and-v3) for the updated version. -- The `max_record_time` is now enforced by all confirming participants. -- The Ledger API and Ledger JSON API prepare `InteractiveSubmissionService` has been modified to take in a specific hashing scheme version in the request. -The default hashing scheme is `HASHING_SCHEME_VERSION_V2`. Integrators are encouraged to move to `HASHING_SCHEME_VERSION_V3` for synchronizers using protocol version 35. -In particular, usage of **contract keys** requires `HASHING_SCHEME_VERSION_V3`. See the versioning [documentation](https://docs.digitalasset-staging.com/build/3.5/explanations/external-signing/external_signing_hashing_algorithm.html#hashing-scheme-version) for details. - -### Active Contracts Head Snapshot (ACHS) - -The Active Contracts Head Snapshot (ACHS) is a new optional feature that maintains a continuously updated snapshot of -the currently active contracts. When enabled, the ACHS accelerates `GetActiveContracts` (ACS) queries by allowing them -to read directly from a pre-computed snapshot rather than scanning the full event log to reconstruct the active set. - -ACHS is disabled by default. To enable it, configure the `achs-config` block under the participant's indexer settings: -``` -canton.participants..parameters.ledger-api-server.indexer.achs-config { - valid-at-distance-target = 1000000 - last-populated-distance-target = 500000 -} -``` - -The `valid-at-distance-target` controls how far behind the ledger end (in event sequential IDs) the snapshot's validity -point is maintained. The ACHS is not used for serving queries below its validity point, logging at INFO level "ACHS for -skipped since validAt (...) already surpassed requested activeAt (...)". If the `valid-at-distance-target` -value is too small, long-running ACS queries may observe the ACHS validity point -moving (mid-stream) past their requested offset, causing the stream to fall back to the slower filter tables query, logging -at INFO level "ACHS stream for fell back to filter tables from (...) since validAt (...) surpassed activeAtEventSeqId (...)". If -the value is too large, the tail portion of the ACS (between the ACHS validity point and the requested offset) must be -resolved from the filter tables, making that last segment more expensive. - -As described above, when the ACHS validity point moves or is past the requested offset, an info-level log message is -emitted indicating that the stream fell back to the filter tables. -Two corresponding metrics, `achs_skips` and `achs_midstream_fallbacks`, are available under `daml.participant.api.index` -to help operators monitor the frequency of these fallbacks and tune the `valid-at-distance-target` accordingly. - -The `last-populated-distance-target` controls the additional lag (in event -sequential IDs) for the population of ACHS in order to store only the long-lived contracts. A larger value reduces -database I/O by skipping short-lived contracts that are created and archived before they would be added to the snapshot. -However, setting it too large increases the cost of the remaining ACS tail, as more data must be fetched from the filter -tables to cover the gap between the last populated point and the ACHS validity point. - -Further tuning parameters include: -- `population-parallelism`: number of parallel threads for adding activations to the ACHS during normal operation. -- `removal-parallelism`: number of parallel threads for removing deactivated activations from the ACHS during normal operation. -- `aggregation-threshold`: minimum batch size (in event sequential IDs) before ACHS maintenance work is emitted. -- `init-parallelism`: number of parallel threads for ACHS population and removal during initialization. -- `init-aggregation-threshold`: minimum batch size (in event sequential IDs) for ACHS maintenance during initialization. -- `buffer-size`: size of the internal buffer between the indexer pipeline and the ACHS maintenance flow. - -The `deactivation_distances` histogram metric which is available under `daml.participant.api.indexer.deactivation_distances` -can help operators understand the distribution of contract lifetimes (the event sequential ID distance between a contract's -activation and its deactivation) and set an appropriate `last-populated-distance-target`. Ideally, the population distance -should be large enough so that most short-lived contracts are already deactivated and thus not added to the snapshot. - -Three gauge metrics are available under `daml.participant.api.indexer` to monitor the ACHS state: -- `achs_valid_at`: the event sequential ID at which the ACHS is currently valid. ACS queries with a requested offset - at or after this value can read directly from the ACHS. -- `achs_last_populated`: the last event sequential ID for which activations were added to the ACHS. -- `achs_last_removed`: the last event sequential ID for which deactivations were looked up and the corresponding - activations were removed from the ACHS. - -### Hardened Error Handling in Sequencer Connect Service - -We have implemented strict error sanitization and rewording for the SequencerConnectService to mitigate information leakage. -Detailed internal error messages are now redacted before being sent to clients. - -If detailed diagnostics are required in a non-production environment, sanitization can be toggled off via: - -``` -canton.monitoring.sanitize-public-error-messages = false -``` - -### Ignoring of offboarded sequencers for submission requests - -In the case where sequencers are offboarded but remain online and kept in the connectivity configuration, it was still possible that members pick them as the target for submission requests. The submission would fail, but the member would incur a delay as it requires retrying. -This has now changed, and offboarded sequencers are ignored when sending submission requests. - -#### API Changes - -The previous method of returning errors via response fields has been removed in favor of canonical gRPC error propagation. -The following fields are now obsolete: - -- `HandshakeResponse.value.failure` -- `VerifyActiveResponse.value.failure` - -Errors are now communicated strictly through `io.grpc.Status` codes to ensure a consistent and secure interface. - -Status codes have changed as follows: - -- SequencerAuthenticationService.challenge newly fails with `INVALID_ARGUMENT` (instead of `FAILED_PRECONDITION`), - if the client does not support the sequencer's protocol version. -- SequencerConnectService newly fails with `INVALID_ARGUMENT` (instead of `FAILED_PRECONDITION`) if a non-participant tries to connect. -- SequencerConnectService.registerOnboardingTopologyTransactions newly fails with `INTERNAL` (instead of `FAILED_PRECONDITIONS`) -- if there are no dynamic synchronizer parameters. -- SequencerConnectService.registerOnboardingTopologyTransactions newly fails with `FAILED_PRECONDITION` if -- the transactions cannot be added to the topology state and sanitization of error messages is enabled. - -### Mediator Crash Fault Tolerance - -The mediator is now crash fault-tolerant and guarantees that all verdicts will eventually be persisted and available on the inspection API. - -### Enhanced Reliability for `GetHighestOffsetByTimestamp` - -Previously, the `GetHighestOffsetByTimestamp` RPC and the `find_highest_offset_by_timestamp` console command could return offsets not yet synced with the participant's local cache. Furthermore, forcing a query with a future timestamp resulted in an error. - -Specific changes: -- The required state is now retrieved atomically via a consistent database snapshot. -- The endpoint now includes an internal barrier (waiting up to 10 seconds) to ensure the local Ledger API cache catches up with the database before returning the offset. -- When `force` is true, requesting a future timestamp now gracefully returns the current ledger end instead of failing. - -No migration required. - -### ACS stream continuation - -The `GetActiveContracts` stream request has been extended with an optional `stream_continuation_token` field that allows -clients to continue an interrupted ACS stream from the last element which made through. The field can be populated with -the `stream_continuation_token` field of the last response element received before the interruption, and the stream will -continue from the next element after that. - -### ACS Ledger API counting - -Introduced a new memory-efficient consoled command `participant.ledger_api.acs.count()` -to count the number of active contracts on a participant node. - -> Note: This command is currently under the Testing feature flag. - -### ACS pagination - -A new, `GetActiveContractsPage` endpoint added to State Service API. This enables the client to retrieve the ACS in -paginated form, by specifying a `max_page_size`. The pages can be accessed sequentially by using the `page_token` -field. The token can be obtained from the `GetActiveContractsPageResponse` of the last page. - -### GetUpdates stream in descending order of events - -The `GetUpdatesRequest` object has new optional parameter `descending_order`. When this parameter is `true` the events -are streamed from the newest to the oldest ones. The pages can be accessed sequentially by using the `page_token` -field. - -### GetUpdates pagination -A new `GetUpdatesPage` endpoint has been added to Update Service API. THis allows retrieval of updates in paginated -form instead of requesting the stream. - -### Improvements for `repair.add` and migration advice - -The `participant.repair.add` admin command has been revised to use the new `ImportAcs` backend, bringing significant -memory performance improvements, stricter default safety validations, and several new parameters. - -#### Important behavioral change: strict `Validation` by default - -Previously, `repair.add` implicitly accepted all injected contracts without re-evaluating their cryptographic hashes. To -prevent accidental data corruption, the command now defaults to **Validation** mode ( -`contractImportMode = ContractImportMode.Validation`). - -- **Impact:** If you have existing scripts or recovery procedures that inject manually modified, synthetic, or - inconsistent contracts (where the payload does not strictly match the `ContractId` hash), they will now fail with a - `"Failed to authenticate contract with id"` error. -- **Migration:** To bypass this cryptographic validation and restore the legacy behavior, explicitly pass the `Accept` - mode in your command call: - ```scala - participant.repair.add( - synchronizerId = mySynchronizer, - protocolVersion = myProtocolVersion, - contracts = myContracts, - contractImportMode = ContractImportMode.Accept // Bypasses strict validation - ) - ``` - -#### New parameters - -The command signature has been expanded to support several optional parameters: - -- `workflowIdPrefix`: Allows you to set a custom prefix for the generated workflow ID to easily track the repair - transactions (defaults to `import-`). -- `contractImportMode`: Choose between `Validation` (default, validates that contract IDs comply with the scheme - associated to the synchronizer where the contracts are assigned), or `Accept` the contracts as they are (if you know - what you are doing). -- `representativePackageIdOverride`: Allows you to remap or override the representative package IDs of the contracts as - they are imported. -- `excludedStakeholders`: When defined, any contract that has one or more of these parties as a stakeholder will not be - added. - -### Improved party and repair ACS imports - -We have completely overhauled the ACS import endpoints for both party replication and participant repair to be -memory-efficient streaming endpoints: - -- Console command `participant.parties.import_party_acs` -- Console command `participant.repair.import_acs` -- gRPC RPC `PartyManagementService.ImportPartyAcs` -- gRPC RPC `ParticipantRepairService.ImportAcs` +### Topic A +Template for a bigger topic +#### Background +#### Specific Changes +#### Impact and Migration -This resolves previous memory limitations, as these endpoints no longer load the entire ACS snapshot into memory at -once. +### New `GetCompletions` endpoint on the command completion service +#### Background +The command completion service only offered `CompletionStream`, which always filters by a single user and a non-empty set of parties. There was no way to stream completions across all parties. -#### Action required: Breaking API change +#### Specific Changes +A new completion streaming endpoint is introduced: `GetCompletions` (gRPC) or `/commands/command-completions` (JSON API). This endpoint offers more flexible filtering semantics compared to the existing `CompletionStream`, `/commands/completions` endpoint. It filters by `parties` only; there is no user filtering: +- A non-empty `parties` filters to those parties and requires `ReadAs` (or `ActAs`) for each. +- An empty `parties` returns completions for all parties and requires `ReadAsAnyParty` (or `ActAsAnyParty`). -The `synchronizerId` is now a **mandatory** first parameter for both the `import_party_acs` and `import_acs` console -commands as well as their analogous gRPC endpoints. You will need to update any existing scripts. +In all other respects it behaves like `CompletionStream`, which becomes deprecated. -**For `import_party_acs`:** +#### Impact and Migration +This is an additive change. `CompletionStream` is unchanged, so no migration is required. -- **Old usage:** `participant.parties.import_party_acs("canton-acs-export.gz")` -- **New usage:** `participant.parties.import_party_acs(mySynchronizerId, importFilePath = "canton-acs-export.gz")` +### Minor Improvements +- Added latency metrics for signing and decryption operations. +- Updated nix package to source from GHCR and bump dpm version to 1.0.20 for remote dar support +- Fixed a BFT ordering sequencer crash-recovery issue affecting freshly onboarded nodes. If such a node crashed and restarted while still within its onboarding start epoch, on restart it could attempt to recover the output module from a block below its durable lower bound (a block that was never stored by this node), which could leave the node stuck. The onboarding boundary block is now persisted with its BFT time to seed BFT-time computation across a restart, the node's own activation time is reconstructed when needed for crash recovery, and output-module recovery is clamped to the durable lower bound. +- LSU: improved handshake between a sequencer and its successor: the physical synchronizer id and sequencer id are now validated. -**For `import_acs`:** +### Preview Features +- preview feature -- **Old usage:** `participant.repair.import_acs("canton-acs-export.gz")` -- **New usage:** `participant.repair.import_acs(mySynchronizerId, importFilePath = "canton-acs-export.gz")` +## Bugfixes -Because of the mandatory `synchronizerId` parameter, to import a multi-synchronizer ACS snapshot, you must now call the -endpoint sequentially for each synchronizer your participant is connected to, using the exact same snapshot file. The -import process will ignore any contracts in the snapshot that are associated to a different synchronizer. +### (YY-nnn, Risk): Title -##### Details on the gRPC `ImportAcs` repair endpoint +#### Issue Description -The `ImportAcs` and `ImportAcsV2` RPCs have been consolidated, introducing the following breaking changes and migration -steps: +#### Affected Deployments -- **Endpoint removed:** `ImportAcsV2` (along with its request/response messages) is completely removed. All clients must - migrate to the standard `ImportAcs` RPC. -- **Request signature and type changes:** - - Fields `workflow_id_prefix` (2), `contract_import_mode` (3), and `representative_package_id_override` (5) in - `ImportAcsRequest` are now explicitly `optional`. - - A new `optional string synchronizer_id = 6` field was added. - - **Migration (ScalaPB):** Adding `optional` changes generated code from base types to `Option[T]`. Existing clients - will fail to compile and must be updated to wrap assigned values (e.g., `workflowIdPrefix = Some("prefix")`) and - explicitly handle reading `Option` types. -- **Behavioral change (`synchronizer_id`):** When filtering by synchronizer, mismatched contracts are now ignored. This - breaks previous logic that relied on the import strictly aborting upon a mismatch. +#### Affected Versions -##### Details on the gRPC `ImportPartyAcs` party replication endpoint +#### Impact -The `ImportPartyAcs` endpoint underwent the exact same consolidation (removing `ImportPartyAcsV2`), streaming semantics -updates, generated code changes (ScalaPB `Option[T]`), and mismatched synchronizer behavior (ignoring rather than -failing) as `ImportAcs`. +#### Symptom -**Key differences specific to `ImportPartyAcs`:** +#### Workaround -- **New capability (`party_id`):** A new `optional string party_id = 6` field was added. Providing this in the first - request of the stream enables automatic, crash-resilient scheduling of the onboarding flag clearance. If omitted, the - participant logs a warning, and the flag must be cleared manually. +#### Likeliness -### Topology-Aware Package Selection (TAPS) improvements - -Topology-Aware Package Selection (TAPS) refinement for handling inconsistent vetting states: -- The algorithm now considers a party's package vetting state only for packages required by that party in the interpreted transaction. - It starts with a minimal set of restrictions derived from the command's root nodes and progressively accumulates more restrictions over a configurable number of passes. - This iterative process increases the likelihood of finding a valid package selection set for the routing of the transaction. -- The maximum number of TAPS passes can be set at the request-level via the optional `taps_max_passes` field in `Commands` or `PrepareSubmissionRequest` messages. - If not specified, the default value is taken from the participant configuration via `participants.participant.ledger-api.topology-aware-package-selection.max-passes-default` (defaults to `3`). - A hard limit is enforced by `participants.participant.ledger-api.topology-aware-package-selection.max-passes-limit` (defaults to `4`). -- TAPS now ignores unvetted dependencies of packages that are not required for interpretation. - complying now with the support of unvetted dependencies in the Canton protocol. - -### Ledger API Improvements - -- ApiRequestLogger now also used by Ledger JSON Api. Changes: - - Redundant Request TID removed from logs. - - Additional CLI options added: `--log-access` captures API access logs in a separate file (default: `log/canton_access.log`), and `--log-access-errors` captures API access errors in a separate file (default: `log/canton_access_error.log`). - - Additional config options added: `debugInProcessRequests` logs in-process gRPC requests at DEBUG instead of TRACE, and `prefixGrpcAddresses` prefixes gRPC client addresses with `grpc:` (enabled by default). -- LedgerAPI ListKnownParties supports an optional prefix filter argument filterParty. - The respective JSON API endpoint now additionally supports `identity-provider-id` as - an optional argument, as well as `filter-party`. -- Protect the admin participant from self lock-out. It is now impossible for an admin to remove own admin rights or - delete itself. -- On Ledger API interface subscriptions, the `CreatedEvent.interface_views` now returns the ID of the package containing - the interface implementation that was used to compute the specific interface view as `InterfaceView.implementation_package_id`. -- OffsetCheckpoints are now always generated when an open-ended updates or completions stream is requested, even if there - are no updates. The checkpoint can have the same offset as the exclusive start of the stream, making checkpoints visible - even when starting from the ledger end. This enables client systems to recognize when the ledger end is advancing, - even if the stream of updates is inactive. -- Extended the set of characters allowed in user-id in the ledger api to contain brackets: `()`. - This also makes those characters accepted as part of the `sub` claims in JWT tokens. -- Functionality for managing internal and external parties has been improved, removing previous asymmetry: - - User rights can now be assigned to an external party during allocation. - - External parties can be allocated by the user themselves in the self-administration mode. - Please note that users in self-administration mode can allocate up to N parties, depending on a setting of the parameter - ``` - canton.participants..ledger-api.party-management-service.max-self-allocated-parties - ``` - By default the value of this parameter is 0. -- An IDP administrator can now only allocate parties confined to their own IDP perimeter. - - -## Performance Improvements - -### Session Signing Keys - -Session signing keys can now be used to reduce the number of calls to external KMS (Key Management Service) providers. When enabled, session signing keys are generated and cached locally for a limited duration and used for signing operations during their validity period. - -Please read the documentation on [Session Signing Keys](https://docs.digitalasset.com/operate/3.5/howtos/secure/keys/session_signing_keys.html) for details on how to enable and configure this feature. -Session signing keys are only available from Protocol Version 35 and are not enabled by default. - -### Compatible sibling views compression - -In protocol version 35, each envelope in `TransactionConfirmationRequest` contains multiple views grouped by recipients instead of one envelope per view. -Assignment and re-assignments also use this new format, but they always have one view. - -### Single Topology Transaction for External Parties - -Multiple topology transactions for external parties can now be represented with a single `PartyToParticipant` topology transaction. - -The `generateExternalPartyTopology` endpoint on the Ledger API now returns a single `PartyToParticipant` topology transaction to onboard the party. -The transaction contains signing threshold and signing keys. This effectively deprecate the usage of `PartyToKeyMapping`. -For parties with signing keys both in `PartyToParticipant` and `PartyToKeyMapping`, the keys from `PartyToParticipant` take precedence. - -Deprecated usage of `PartyToKeyMapping`. The functionality provided by `PartyToKeyMapping` is now available directly in `PartyToParticipant`. -Please use `PartyToParticipant` for new transactions. `PartyToKeyMapping` is still fully supported in this version (including existing and new transactions). -In future version, creation of new `PartyToKeyMapping` transactions may be disallowed. - -Deprecated `TopologyManagerReadService.ListAll` in favor of `ListAllV2`, which uses an inclusion -list (`include_mappings`) instead of an exclusion list (`exclude_mappings`). This avoids sending -mapping codes unknown to older servers. The console method `topology.transactions.list` now calls -`ListAllV2` by default and only falls back to `ListAll` when targeting a 3.4 node. The -`excludeMappings` and `protocolVersion` parameters of `topology.transactions.list` are deprecated; -use `filterMappings` instead. - -Deprecated `TopologyManagerReadService.ExportTopologySnapshot` and `TopologyManagerWriteService.ImportTopologySnapshot`, -along with their console counterparts `topology.transactions.export_topology_snapshot`, -`topology.transactions.import_topology_snapshot`, `topology.transactions.import_topology_snapshot_from`, -and `topology.transactions.export_identity_transactions`. -Please use the corresponding `V2` variants (`ExportTopologySnapshotV2` / `ImportTopologySnapshotV2`, -`export_topology_snapshotV2`, `import_topology_snapshotV2`, `import_topology_snapshot_fromV2`, -`export_identity_transactionsV2`) instead, which use an updated internal bytestring format. - -Deprecated `SequencerInitializationService.InitializeSequencerFromGenesisState`, -`SequencerInitializationService.InitializeSequencerFromOnboardingState`, -`SequencerAdministrationService.OnboardingState`, and -`TopologyManagerReadService.GenesisState`, along with their console counterparts -`setup.assign_from_genesis_state`, `setup.assign_from_onboarding_state`, -`setup.onboarding_state_for_sequencer`, `setup.onboarding_state_at_timestamp`, -and `topology.transactions.genesis_state`. -Please use the corresponding `V2` variants (`InitializeSequencerFromGenesisStateV2`, -`InitializeSequencerFromOnboardingStateV2`, `OnboardingStateV2`, `GenesisStateV2`, -`assign_from_genesis_stateV2`, `assign_from_onboarding_stateV2`, -`onboarding_state_for_sequencerV2`, `onboarding_state_at_timestampV2`, -`genesis_stateV2`) instead, which use an updated internal bytestring format -that enables streaming ingestion, making snapshot export and import significantly less memory-intensive. - -### Minor Performance Improvements - -- The Postgres connection tuning configuration of the indexer is now separated from the configuration of the Ledger API server - (`canton.participants..ledger-api.postgres-data-source`). - The new configuration section `canton.participants..parameters.ledger-api-server.indexer.postgres-data-source` should - be used instead to tune the indexer's Postgres connections. -- A new indexer pipeline batching strategy added under the feature flag `useWeighetdBatching`. When switched on, the - batches are created using their estimated database processing time using the `submissionBatchInsertionSize` as a limit - for individual batches -- Changed the `CompressedBatch` structure in the sequencer protocol for protocol version 35 to separately keep recipients and envelopes (from `gzip(Seq((recp1, payload1), (recp2, payload2)))` to `gzip(Seq(recp1, recp2)), Seq(gzip(payload1), gzip(payload2)))`). -- Batching configuration now allows setting different parallelism for pruning (currently only for Sequencer pruning): - New option `canton.sequencers.sequencer.parameters.batching.pruning-parallelism` (defaults to `2`) can be used - separately from the general `canton.sequencers.sequencer.parameters.batching.parallelism` setting. -- Made the config option `...topology.use-time-proofs-to-observe-effective-time` work and changed the default to `false`. - Disabling this option activates a more robust time advancement broadcast mechanism on the sequencers, - which however still does not tolerate crashes or big gaps in block sequencing times. The parameters can be configured - in the sequencer via `canton.sequencers..parameters.time-advancing-topology`. -- Additional metrics for the ACS commitment processor: `daml.participant.sync.commitments.last-incoming-received`, `daml.participant.sync.commitments.last-incoming-processed`, `daml.participant.sync.commitments.last-locally-completed`, and `daml.participant.sync.commitments.last-locally-checkpointed`. - -## Breaking Changes - -### Removal of legacy party replication repair console macros - -The original party replication method, which relied on a silent synchronizer, has been superseded by the offline party -replication process. Consequently, the obsolete repair console macros associated with the legacy approach have -been removed. - -Specifically, the following macros are no longer available: -- `step1_hold_and_store_acs` -- `step2_import_acs` - -If you previously relied on the _Silent synchronizer replication procedure_, you will need to transition to the -current offline party replication process. For details, please consult the -[Offline Party Replication documentation](https://docs.digitalasset.com/operate/3.5/howtos/operate/parties/party_replication.html#offline-party-replication) - -### Removal of deprecated, legacy ACS export and import endpoints - -The legacy repair endpoints for the ACS export and import have been removed: - -- Console command `participant.repair.export_acs_old` -- Console command `participant.repair.import_acs_old` -- gRPC rpc `ParticipantRepairService.ExportAcsOld` -- gRPC rpc `ParticipantRepairService.ImportAcsOld` - -#### Migration advice - -Use repair endpoints without the 'old' suffix: - -- Migrate to `participant.repair.export_acs` from `participant.repair.export_acs_old` -- Migrate to `participant.repair.import_acs` from `participant.repair.import_acs_old` -- Migrate to `ParticipantRepairService.ExportAcs` from `ParticipantRepairService.ExportAcsOld` -- Migrate to `ParticipantRepairService.ImportAcs` from `ParticipantRepairService.ImportAcsOld` - -Note that previously created ACS snapshots with the legacy endpoints cannot be imported with the current endpoints as -the underlying data format has completely changed. - -##### Migrating to export_acs - -The most significant change is the removal of the `timestamp` parameter, which has been replaced by a mandatory -`ledgerOffset` parameter. - -**Console parameter changes:** - -- **New mandatory parameter:** `ledgerOffset (Long)`. You must now specify the exact ledger offset for the snapshot - instead of a `timestamp`. -- **Removed parameters:** `partiesOffboarding`, `timestamp` (replaced by `ledgerOffset`), `force`. -- **Renamed parameters:** `outputFile` is now `exportFilePath` (default is `"canton-acs-export.gz"`), - `filterSynchronizerId` is now `synchronizerId`. -- **New optional parameters:** `excludedStakeholders` allows you to omit contracts that have one or more of these - parties as a stakeholder; `contractSynchronizerRenames` allows mapping contracts from one synchronizer to another - during export. - -**gRPC changes for `ExportAcsRequest`:** - -- **`parties` -> `party_ids`:** Field renamed for consistency. If left empty, the endpoint will act as a wildcard and - export the ACS for *all* parties hosted by the participant. -- **`timestamp` -> `ledger_offset` (Breaking):** You must provide an exact `int64 ledger_offset` instead of a timestamp. -- **`filter_synchronizer_id` -> `synchronizer_id`:** Field renamed for consistency. -- **Removed fields:** `force` and `parties_offboarding` have been completely removed. -- **New fields:** `contract_synchronizer_renames` and `excluded_stakeholder_ids`. - -##### Migrating to import_acs - -The import command remains largely the same in basic usage, but introduces new optional parameters for advanced -validation and overrides, alongside strict memory-efficient streaming semantics for gRPC. - -**Console parameter changes:** - -- **Renamed parameter:** `inputFile` is now `importFilePath` (default is `"canton-acs-export.gz"`). -- **New optional parameters:** `contractImportMode` governs contract validation upon import (defaults to - `ContractImportMode.Validation`); `representativePackageIdOverride` allows overriding representative package IDs - during import; `excludedStakeholders` allows omitting contracts that have one or more of these parties as a - stakeholder. - -**gRPC changes for `ImportAcsRequest`:** - -- **Streaming Semantics (Breaking):** The new endpoint requires metadata fields (like `contract_import_mode`, - `synchronizer_id`, etc.) to be populated *only* in the first request of the stream. Subsequent requests must omit - metadata and only contain the binary `acs_snapshot` chunks. -- **New mandatory fields:** `contract_import_mode` and `synchronizer_id` must be explicitly defined in the first stream - request. -- **Removed fields:** `allow_contract_id_suffix_recomputation` is completely removed. -- **New fields:** `excluded_stakeholder_ids` and `representative_package_id_override`. -- **Response update:** `ImportAcsResponse` is now a completely empty message (previously returned a contract ID - mapping). - -### Only PackageName is accepted on Ledger API - -Usage of package id for ledger queries was deprecated and now the validation will fail if used. -The impacted APIs are: - - GetUpdates - - GetUpdateByOffset - - GetUpdateById - - GetActiveContracts - - GetEventsByContractIdRequest - - SubmitAndWaitForTransaction (the optional `transaction_format`) - - SubmitAndWaitForReassignmentRequest - - ExecuteSubmissionAndWaitForTransactionRequest - -### SynchronizerId field update in Externally signed transactions - -In Protocol version 35, the `synchronizer_id` field in externally signed prepared transaction metadata -will be populated with the physical synchronizer ID of the synchronizer on which the transaction will be processed, -instead of the logical synchronizer ID, as is the case in PV 34. -Applications must ensure they do not rely on the format of the `synchronizer_id` value. - -### Changes from NonNegativeLong to Long - -Some console commands using a NonNegativeLong for the offset are changed to accept a Long instead. -Similarly, some console commands returning an offset now return a Long instead of a NonNegativeLong. -It brings consistency and allows to pass the output of `participant.ledger_api.state.end()`. - -Impacted commands: -- `participant.repair.export_acs` -- `participant.parties.find_party_max_activation_offset` -- `participant.parties.find_party_max_deactivation_offset` -- `participant.parties.find_highest_offset_by_timestamp` - -### Removal of automatic recomputation of contract ids upon ACS import - -The ability to recompute contract ids upon ACS import has been removed. - -### Removal of multi-host name resolution tooling - -Support for the multi-host name resolution was removed. -This was only used if synchronizer connectivity defined a sequencer with multiple endpoints, which is not supported with our current sequencers: -we now have multiple sequencers each with exactly one endpoint. - -### Ledger JSON API Spec Corrections - -JSON Ledger API OpenAPI/AsyncAPI spec corrections -- Fields not marked as required in the Ledger API `.proto` specification are now also optional in the OpenAPI/AsyncAPI specifications. - If your client code is using code generated using previous versions of these specifications, it may not compile or function correctly with the new version. To migrate: - - If you prefer not to update your code, continue using the previous specification versions as the JSON API server preserves backward compatibility. - - If you want to use new endpoints, features or leverage the new less strict spec, migrate to the new OpenAPI/AsyncAPI specifications as follows: - - Java clients: No changes are needed if you use the `OpenAPI Generator`. Otherwise, potentially optionality of fields should be handled appropriately for other code generators. - - TypeScript clients: Update your code to handle optional fields, using the `!` or `??` operators as appropriate. -- From Canton 3.5 onwards, OpenAPI/AsyncAPI specification files are suffixed with the Canton version (e.g., `openapi-3.5.0.yaml`). -- Canton 3.5 is compatible with OpenAPI specification files from version 3.4.0 to 3.5.0 (inclusive). - -- The Ledger JSON API server now enforces that only fields marked as required by the Ledger API OpenAPI/AsyncAPI specification are mandatory in request payloads. - -### Change from grpcurl to grpc-health-probe in all Docker images - -The tool used for health check probes changed from grpcurl to grpc-health-probe in all the docker images. - -### Minor Breaking Changes - -- The expert `keep-alive-client` configuration parameter for various client services moved to `channel.keep-alive-client`. -- We reduced the defaults for `setBalanceRequestSubmissionWindowSize` and `defaultMaxSequencingTimeOffset` - to 2 minutes. -- The default OTLP gRPC port that the Canton connects to in order to export the traces has been changed from - 4318 to 4317. This aligns the default configuration of Canton with the default configuration of the OpenTelemetry - Collector. This change affects only the users who have configured an OTLP trace export through - ``` - canton.monitoring.tracing.tracer.exporter.type=otlp - ``` -- Removed the `LastErrorsAppender` along with the Admin API endpoints `StatusService.GetLastErrors` and `StatusServiceGetLastErrorTrace`, as - well as the corresponding console commands `last_errors` and `last_error_trace`. - - -## Deprecations - -### Deprecate scope-based access tokens -- "Scope-based" access tokens, i.e. JWTs without any audience specified, have been deprecated. -- A configuration that does not specify a `target-audience` will log a warning on node startup. -- Configurations that specify both a `target-audience` and a `target-scope` are not supported in this version and will also log a warning on node startup. -- Starting Canton version 3.7, support for "scope-based" tokens will be removed entirely to enforce a valid `aud` field in every incoming JWT. -- The `scope` field will, in a future version, be repurposed to serve exclusively as an additional, optional claim for fine-grained permissions. - -### Removal of the old sequencer connection transports - -The old sequencer connections transports have been removed, and only the new sequencer connection pool remains. -Consequently, the configuration `.sequencer-client.use-new-connection-pool` has been deprecated and no longer has any effect. - -### Deprecate initial protocol version configuration - -The config key `participant.parameters.initial-protocol-version` was unused and has been marked as deprecated. - -### Configuration Deprecations - -- The configuration parameters `topology.use-new-processor` and `topology.use-new-client` have been deprecated and now default to true. Configuring those parameters to false will be ignored. -- The parameter `canton.participants..parameters.package-metadata-view.init-takes-too-long-interval` - is now ignored, and a warning will only be printed once, rather than periodically. -- The parameter `canton.participants..parameters.ledger-api-server.indexer.prepare-package-metadata-time-out-warning` - is now ignored. -- The individual JVM metric flags `classes`, `cpu`, `memoryPools`, `threads`, `gc`, and `buffers` in - `canton.monitoring.metrics.jvm-metrics` are no longer supported since the upgrade to OpenTelemetry instrumentation 2.26.0. - All standard JVM metrics (classes, cpu, memory pools, threads, garbage collector) are now always enabled when - `jvm-metrics.enabled = true`. A new `experimental` flag has been added to control experimental JVM metrics - (e.g. buffer pools). Users who previously set `buffers = true` should migrate to `experimental = true`. - See https://github.com/open-telemetry/opentelemetry-java-instrumentation/pull/16087 for details. -- The Zipkin trace exporter configuration `canton.monitoring.tracing.tracer.exporter.type=zipkin` is - deprecated following the OpenTelemetry specification deprecation of Zipkin exporters. The Zipkin exporter - will be removed in a future release. Users should migrate to the OTLP exporter. - See https://opentelemetry.io/blog/2025/deprecating-zipkin-exporters/ for details. -- Removed the feature flag `canton.sequencers..parameters.async-writer.enabled`, as async writing is now - the only supported mode. -- Changed the path for `crypto.kms.session-signing-keys` (deprecated) to `crypto.session-signing-keys` so that session signing key configuration is no longer directly tied to a KMS. However, session signing keys can still only be enabled when using a KMS provider or when running with `non-standard-config=true`. -- `package-dependency-cache` field in `caching` configuration is deprecated. It can be removed safely from node configurations. - -### Ledger JSON API package vetting endpoints - -The Ledger JSON API `v2/package-vetting` endpoint exposes list functionality on the GET method by accepting a request body. This is not recommended by the HTTP specification, hence the endpoint is deprecated. -For consistency, the POST method, used for updating the vetting state, of the same endpoint is also deprecated. - -In turn, two new endpoints are implemented to provide the same functionality: -- `v2/package-vetting/list` accepts a POST request with the same body as the deprecated GET `v2/package-vetting` endpoint and returns the list of vetted packages in the same format. -- `v2/package-vetting/update` accepts a POST request with the same body as the deprecated POST endpoint `v2/package-vetting` and returns the updated vetting state of the package in the same format. - - -### Protocol version parameter in topology list commands - -The `protocolVersion` parameter in all `.topology..list` console commands has been deprecated and will be removed in a future version. - -## Minor Improvements - -### Bugfixes - -- Fixed a mid-crash recovery issue for offline party replication and repair ACS imports. Previously, if an ACS import - was interrupted (for example by a participant node restart or crash), a subsequent recovery attempt could result in - missing contracts on the Ledger API. The recovery process now properly rolls back uncommitted partial states upon - retrying the ACS import, ensuring recovered contracts are completely synchronized across both internal storage - and the Ledger API. -- Fixed a bug where the Ledger API `PackageService.ListVettedPackages` used to return a potentially not yet - effective state of the vetted packages. Now it returns the state of vetted packages effective at the time of the request. -- Sequencer health status used to incorrectly return the synchronizer uid instead of the sequencer uid. -- Prevent Ledger API crashes after running `ParticipantRepairService.PurgeContracts` admin command. - Fixes a critical issue where using the `ParticipantRepairService.PurgeContracts` command (when multi-synchronizer support is disabled) generated malformed - Daml values for the choice argument and choice result of the `Archive` choice of the purge contract events in the Ledger API event store. - This previously caused the Ledger API streams reading the generated `Archive` events to crash. - The repair command now generates correct Daml values for the corresponding entries, that can be safely delivered by the Ledger API. -- Fixed a bug in the repair service's `changeAssignation` where only a single repair counter was allocated when reassigning multiple contracts, - violating the monotonicity expected by the indexer. - -### Ledger API Multi-Synchronizer Events Alpha Support - -Adds a new participant node parameter, `alpha-multi-synchronizer-support` (Boolean). -- **Default (`false`):** Uses standard **Create** and **Archive** events. -- **Enabled (`true`):** Uses **Assign** and **Unassign** events. - -This flag is required in multi-synchronizer environments to preserve the **reassignment counter** of a contract. -Using the default (Create events) resets this counter to zero. - -Note: Multi-synchronizer support is currently in Alpha; most Ledger API consumers may not yet be compatible with -Assign/Unassign events. Only enable this if your application specifically requires non-zero reassignment counters -and can process these event types. - -### Support for adding table settings for PostgreSQL - -Added support for adding table settings for PostgreSQL. One can use a repeatable migration (Flyway feature) in a file -provided to Canton externally. - - Use the new config `repeatable-migrations-paths` under the `canton...storage.parameters` configuration section. - - The config takes a list of directories where repeatable migration files must be placed, paths must be prefixed with `filesystem:` for Flyway to recognize them. - - Example: `canton.sequencers.sequencer1.storage.parameters.repeatable-migrations-paths = ["filesystem:community/common/src/test/resources/test_table_settings"]`. - - Only repeatable migrations are allowed in these directories: files with names starting with `R__` and ending with `.sql`. - - The files cannot be removed once added, but they can be modified (unlike the `V__` versioned schema migrations), and if modified these will be reapplied on each Canton startup. - - The files are applied in lexicographical order. - - Example use case: adding `autovacuum_*` settings to existing tables. - - Only add idempotent changes in repeatable migrations. - -### Offline root namespace key scripts - -Offline root namespace key scripts: -- Renamed `prepare-certs.sh` to `prepare-cert.sh` -- Changed `assemble-certs.sh` to automatically suffix the generated certificate with a `.cert` extension, similarly to what is being done in `prepare-cert.sh` -- Removed the `10-offline-root-namespace-init` example folder as its content is now integrated in the documented how-to: https://docs.digitalasset.com/operate/3.5/howtos/secure/keys/namespace_key.html -- Committed the buf image necessary to run the script to the repository (also available in the release artifact), making usage from the open source repo easier - -### Reliability Improvements - -- Added a field `MaxConcurrentCallsPerConnection` and corresponding default - `defaultMaxConcurrentCallsPerConnection` (set to 100000) to `ServerConfig`. - This corresponds to `max-concurrent-streams-per-connection` in the app configs, e.g., - `docker/canton/images/canton-sequencer/app.conf` and can be changed there. At present - the value for sequencers is configured to be 500 for the public API and 100 for the Admin API. -- Added network timeout and client_connection_check_interval for db operations in the Ledger API server and indexer to avoid - hanging connections for Postgres (see PostgresDataSourceConfig). The defaults are 60 seconds network timeout and - 5 seconds client_connection_check_interval for the Ledger API server, and 20 seconds network timeout and - 5 seconds client_connection_check_interval for the indexer. These values can be configured via the new configuration parameters - `canton.participants..ledger-api.postgres-data-source.network-timeout` for network timeout of the Ledger API - server and `canton.participants..parameters.ledger-api-server.indexer.postgres-data-source.client-connection-check-interval` - for the client_connection_check_interval of the indexer. -- `.replication.connection-pool.connection.client-connection-check-interval` is introduced - that allows configuring the PostgreSQL-specific `client_connection_check_interval` parameter for DB locked connections. - This is a safety mechanism to prevent hanging connections in case of network issues. The default value is 5 seconds. -- The Ledger API now enforces a maximum number of signatures per party that can be provided for external submissions. - This value defaults to 50 and can be changed at the following config path: `canton.participants..ledger-api.interactive-submission-service.maximum-number-of-signatures-per-party` -- Added a new configuration parameter `canton.participants..ledger-api.index-service.max-lookup-limit` that caps the maximum number of contracts returned by a contract key lookup per request. - The default value is 1000. -- When the AcsCommitmentProcessor is initializing, read stakeholder groups from the snapshot in batches of size - `canton.parameters.general.batching.max-stakeholder-groups-batch-size` (default 1000), rather than all at once. - This allows early termination of this initialization if the node is shutting down. -- The release version is now exposed in `NodeStatus.NotInitialized`, so the node version can be retrieved even before the node is initialized. +#### Recommendation ## Compatibility @@ -848,3 +72,9 @@ Canton has been tested against the following versions of its dependencies: |----------------------------|----------------------------| | Java Runtime | JAVA_VERSION | | Postgres | POSTGRES_VERSION | + + +## What's Coming + +We are currently working on + diff --git a/canton/VERSION b/canton/VERSION index 4bba389fca..50ebb4c654 100644 --- a/canton/VERSION +++ b/canton/VERSION @@ -1 +1 @@ -3.5.1-SNAPSHOT +3.5.7-SNAPSHOT diff --git a/canton/base/adjustable-clock/src/main/scala/com/daml/clock/AdjustableClock.scala b/canton/base/adjustable-clock/src/main/scala/com/daml/clock/AdjustableClock.scala deleted file mode 100644 index 82813de3ee..0000000000 --- a/canton/base/adjustable-clock/src/main/scala/com/daml/clock/AdjustableClock.scala +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 -package com.daml.clock - -import java.time.{Clock, Duration, Instant, ZoneId} - -@SuppressWarnings(Array("org.wartremover.warts.Var")) -final case class AdjustableClock(baseClock: Clock, var offset: Duration) extends Clock { - def fastForward(by: Duration): Unit = - offset = offset.plus(by) - - def rewind(by: Duration): Unit = - offset = offset.minus(by) - - def set(to: Instant): Unit = - offset = Duration.between(baseClock.instant(), to) - - override def getZone: ZoneId = baseClock.getZone - - override def withZone(zone: ZoneId): Clock = - if (zone == baseClock.getZone) this - else AdjustableClock(baseClock.withZone(zone), offset) - - override def millis: Long = Math.addExact(baseClock.millis, offset.toMillis) - - override def instant: Instant = baseClock.instant.plus(offset) - - override def equals(obj: Any): Boolean = obj match { - case other: AdjustableClock => baseClock == other.baseClock && offset == other.offset - case _ => false - } - - override def hashCode: Int = baseClock.hashCode ^ offset.hashCode -} diff --git a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/AuthServiceJWTPayload.scala b/canton/base/daml-jwt/src/main/scala/com/daml/jwt/AuthServiceJWTPayload.scala deleted file mode 100644 index c114a97b97..0000000000 --- a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/AuthServiceJWTPayload.scala +++ /dev/null @@ -1,413 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.jwt - -import io.circe.* -import io.circe.parser.* -import org.slf4j.{Logger, LoggerFactory} - -import java.time.Instant -import java.util.concurrent.atomic.AtomicBoolean -import scala.util.{Failure, Success, Try} - -/** All the JWT payloads that can be used with the JWT auth service. */ -sealed abstract class AuthServiceJWTPayload extends Product with Serializable - -/** There are two JWT token formats which are currently supported by `StandardJWTPayload`. The - * format is identified by `aud` claim. - */ -sealed trait StandardJWTTokenFormat -object StandardJWTTokenFormat { - - /** `Scope` format is for the tokens where scope field contains `daml_ledger_api` or if it - * contains a bespoke string configured through a target-scope parameter. - */ - final case object Scope extends StandardJWTTokenFormat - - /** `Audience` format is for the tokens where `aud` claim starts with - * `https://daml.com/jwt/aud/participant/` or if it contains a bespoke string configured through - * a target-audience parameter. - */ - final case object Audience extends StandardJWTTokenFormat -} - -/** Payload parsed from the standard "sub", "aud", "exp", "iss" claims as specified in - * https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 - * - * @param issuer - * The issuer of the JWT. - * - * @param userId - * The user that is authenticated by this payload. - * - * @param participantId - * If not set, then the user is authenticated for any participant node that accepts the JWT - * issuer. We expect this to be used for development only. If set then the user is authenticated - * for the given participantId. - * - * @param exp - * If set, the token is only valid before the given instant. - * @param audiences - * If non-empty and it is an audience-based token, the token is only valid for the intended - * recipients. - */ -final case class StandardJWTPayload( - issuer: Option[String], - userId: String, - participantId: Option[String], - exp: Option[Instant], - format: StandardJWTTokenFormat, - audiences: List[String], - scope: Option[String], -) extends AuthServiceJWTPayload - -/** Codec for writing and reading [[AuthServiceJWTPayload]] to and from JSON. - * - * In general: - * - All custom claims are placed in a namespace field according to the OpenID Connect standard. - * - Access tokens use a Daml-specific scope to distinguish them from other access tokens issued - * by the same issuer for different systems or APIs. - * - All fields are optional in JSON for forward/backward compatibility reasons, where - * appropriate. - * - Extra JSON fields are ignored when reading. - * - Null values and missing JSON fields map to None or a safe default value (if there is one). - */ -object AuthServiceJWTCodec { - - protected val logger: Logger = LoggerFactory.getLogger(AuthServiceJWTCodec.getClass) - - // ------------------------------------------------------------------------------------------------------------------ - // Constants used in the encoding - // ------------------------------------------------------------------------------------------------------------------ - // Unique scope for standard tokens, following the pattern of https://developers.google.com/identity/protocols/oauth2/scopes - final val scopeLedgerApiFull: String = "daml_ledger_api" - - private[this] final val audPrefix: String = "https://daml.com/jwt/aud/participant/" - private[this] final val propAud: String = "aud" - private[this] final val propIss: String = "iss" - private[this] final val propExp: String = "exp" - private[this] final val propSub: String = "sub" - private[this] final val propScope: String = "scope" - private[this] final val propScp: String = "scp" - - // ------------------------------------------------------------------------------------------------------------------ - // Encoding - // ------------------------------------------------------------------------------------------------------------------ - def writePayload: AuthServiceJWTPayload => Json = { - case v: StandardJWTPayload if v.format == StandardJWTTokenFormat.Scope => - Json.obj( - propIss -> writeOptionalString(v.issuer), - propAud -> writeOptionalString(v.participantId), - propSub -> Json.fromString(v.userId), - propExp -> writeOptionalInstant(v.exp), - propScope -> writeOptionalString(v.scope), - ) - case v: StandardJWTPayload => - Json.obj( - propIss -> writeOptionalString(v.issuer), - propAud -> (v.audiences.headOption match { - case None => Json.fromString(audPrefix + v.participantId.getOrElse("")) - case Some(_) => writeStringList(v.audiences) - }), - propSub -> Json.fromString(v.userId), - propExp -> writeOptionalInstant(v.exp), - propScope -> writeOptionalString(v.scope), - ) - } - - def writeAudienceBasedPayload: AuthServiceJWTPayload => Json = { - case v: StandardJWTPayload if v.format == StandardJWTTokenFormat.Audience => - Json.obj( - propIss -> writeOptionalString(v.issuer), - propAud -> writeStringList(v.audiences), - propSub -> Json.fromString(v.userId), - propExp -> writeOptionalInstant(v.exp), - propScope -> writeOptionalString(v.scope), - ) - case _: StandardJWTPayload => - throw new RuntimeException( - s"Could not write StandardJWTPayload of scope format as audience-based payload" - ) - } - - def writeScopeBasedPayload: AuthServiceJWTPayload => Json = { - case v: StandardJWTPayload if v.format == StandardJWTTokenFormat.Scope => - Json.obj( - propIss -> writeOptionalString(v.issuer), - propAud -> writeStringList(v.audiences), - propSub -> Json.fromString(v.userId), - propExp -> writeOptionalInstant(v.exp), - propScope -> writeOptionalString(v.scope), - ) - case _: StandardJWTPayload => - throw new RuntimeException( - s"Could not write StandardJWTPayload of audience-based format as scope payload" - ) - } - - /** Writes the given payload to a compact JSON string */ - def compactPrint( - v: AuthServiceJWTPayload, - enforceFormat: Option[StandardJWTTokenFormat] = None, - ): String = - enforceFormat match { - case Some(StandardJWTTokenFormat.Audience) => - writeAudienceBasedPayload(v).noSpaces - case Some(StandardJWTTokenFormat.Scope) => writeScopeBasedPayload(v).noSpaces - case _ => writePayload(v).noSpaces - } - - private[this] def writeOptionalString(value: Option[String]): Json = - value.fold(Json.Null)(Json.fromString) - - private[this] def writeStringList(value: List[String]): Json = - Json.fromValues(value.map(Json.fromString).toVector) - - private[this] def writeOptionalInstant(value: Option[Instant]): Json = - value.fold(Json.Null)(i => Json.fromLong(i.getEpochSecond)) - - // ------------------------------------------------------------------------------------------------------------------ - // Decoding - // ------------------------------------------------------------------------------------------------------------------ - def readAudienceBasedToken(value: Json): Either[String, AuthServiceJWTPayload] = - value.asObject - .toRight(s"Could not read ${value.spaces2} as AuthServiceJWTPayload: value is not an object") - .map { obj => - val fields = obj.toMap - StandardJWTPayload( - issuer = readOptionalString(propIss, fields), - participantId = None, - userId = readString(propSub, fields), - exp = readInstant(propExp, fields), - format = StandardJWTTokenFormat.Audience, - audiences = readOptionalStringOrArray(propAud, fields), - scope = readAndCombineScopes(fields), - ) - } - - def readScopeBasedToken(value: Json): Either[String, AuthServiceJWTPayload] = - value.asObject - .toRight(s"Could not read ${value.noSpaces} as AuthServiceJWTPayload: value is not an object") - .map { obj => - val fields = obj.toMap - StandardJWTPayload( - issuer = readOptionalString(propIss, fields), - participantId = None, - userId = readString(propSub, fields), - exp = readInstant(propExp, fields), - format = StandardJWTTokenFormat.Scope, - audiences = readOptionalStringOrArray(propAud, fields), - scope = readAndCombineScopes(fields), - ) - } - - def readFromString(value: String): Either[RuntimeException, AuthServiceJWTPayload] = - parse(value).left - .map(_.getMessage) - .flatMap(readPayload) - .left - .map(message => new RuntimeException(message)) - - private def readPayload(value: Json): Either[String, AuthServiceJWTPayload] = - value.asObject match { - case Some(obj) => - val fields = obj.toMap - // Support scope that spells 'daml_ledger_api' - val scopes = readScopes(fields) - // We're using this rather restrictive test to ensure we continue parsing all legacy sandbox tokens that - // are in use before the 2.0 release; and thereby maintain full backwards compatibility. - val audienceValue = readOptionalStringOrArray(propAud, fields) - // Tokens with audience which starts with `https://daml.com/jwt/aud/participant/${participantId}` - // where `${participantId}` is non-empty string are supported. - // As required for JWTs, additional fields can be in a token but will be ignored (including scope) - val participantAudiences = audienceValue.filter(_.startsWith(audPrefix)) - if (participantAudiences.nonEmpty) { - participantAudiences - .map(_.substring(audPrefix.length)) - .filter(_.nonEmpty) match { - case participantId :: Nil => - Right( - StandardJWTPayload( - issuer = readOptionalString(propIss, fields), - participantId = Some(participantId), - userId = readString(propSub, fields), // guarded by if-clause above - exp = readInstant(propExp, fields), - format = StandardJWTTokenFormat.Audience, - audiences = - List.empty, // we do not read or extract audience claims for ParticipantId-based tokens - scope = readAndCombineScopes(fields), - ) - ) - case Nil => - Left( - s"Could not read ${value.noSpaces} as AuthServiceJWTPayload: `aud` must include participantId value prefixed by $audPrefix" - ) - case _ => - Left( - s"Could not read ${value.noSpaces} as AuthServiceJWTPayload: `aud` must include a single participantId value prefixed by $audPrefix" - ) - } - } else if (scopes.contains(scopeLedgerApiFull)) { - // We support the tokens with scope containing `daml_ledger_api`. - // `aud` field is interpreted as the participantId and may be validated by the apis authorizer for - // conformance with actual participantId. - val participantIdE = audienceValue match { - case id :: Nil => Right(Some(id)) - case Nil => Right(None) - case _ => - Left( - s"Could not read ${value.noSpaces} as AuthServiceJWTPayload: `aud` must be empty or a single participantId." - ) - } - participantIdE - .map(participantId => - StandardJWTPayload( - issuer = readOptionalString(propIss, fields), - participantId = participantId, - userId = readString(propSub, fields), - exp = readInstant(propExp, fields), - format = StandardJWTTokenFormat.Scope, - audiences = - List.empty, // we do not read or extract audience claims for Scope-based tokens - scope = Some(scopeLedgerApiFull), - ) - ) - - } else { - Left( - s"Access token with unknown scope \"${scopes.mkString}\". Issue tokens with adjusted or no scope to get rid of this warning." - ) - } - - case None => - Left( - s"Could not read ${value.noSpaces} as AuthServiceJWTPayload: value is not an object" - ) - } - - private[this] def readOptionalString(name: String, fields: Map[String, Json]): Option[String] = - fields.get(name) match { - case None => None - case Some(j) if j.isNull => None - case Some(j) => - j.asString.orElse( - sys.error(s"Could not read ${j.spaces2} as string for $name") - ) - } - - private[this] def readString(name: String, fields: Map[String, Json]): String = - fields.get(name) match { - case Some(j) => - j.asString.getOrElse( - throw new RuntimeException(s"Could not read ${j.noSpaces} as string for $name") - ) - case _ => - throw new RuntimeException(s"Could not read value for $name") - } - - private[this] def readOptionalStringOrArray( - name: String, - fields: Map[String, Json], - ): List[String] = - fields.get(name) match { - case None => List.empty - case Some(j) if j.isNull => List.empty - case Some(j) => - j.asString - .map(List(_)) - .orElse(j.asArray.map(readStringList(name, _))) - .getOrElse(sys.error(s"Could not read ${j.spaces2} as string for $name")) - } - - private[this] def readScopes(fields: Map[String, Json]): List[String] = { - // Read the scopes from the "scope" field which contains a string with space separated entries, - // see https://datatracker.ietf.org/doc/html/rfc8693#name-scope-scopes-claim - // Otherwise, read from the "scp" field which contains a vector of entries, - // see https://ldapwiki.com/wiki/Wiki.jsp?page=Scp%20%28Scopes%29%20Claim - val scopes = fields.get(propScope).toList.flatMap(_.asString.map(_.split(" ")).toList).flatten - if (scopes.nonEmpty) scopes else readOptionalStringOrArray(propScp, fields) - } - - private[this] def readAndCombineScopes(fields: Map[String, Json]): Option[String] = { - val scopes = readScopes(fields) - scopes.headOption.fold[Option[String]](None)(_ => Some(scopes.mkString(" "))) - } - - private def readStringList(name: String, values: Vector[Json]) = - values.toList.map { j => - j.asString.getOrElse( - throw new RuntimeException(s"Could not read ${j.noSpaces} as string element for $name") - ) - } - - private[this] def readInstant(name: String, fields: Map[String, Json]): Option[Instant] = - fields.get(name) match { - case None => None - case Some(j) if j.isNull => None - case Some(j) => - j.asNumber - .flatMap(_.toLong) - .map(Instant.ofEpochSecond) - .orElse( - throw new RuntimeException(s"Could not read ${j.spaces2} as epoch seconds for $name") - ) - } - - // ------------------------------------------------------------------------------------------------------------------ - // Implicits that can be imported to write JSON - // ------------------------------------------------------------------------------------------------------------------ - - private[this] lazy val sharedWarningCodec = new JsonImplicitsWithWarning() - - def jsonImplicits(warnOnJwtScopeUsage: Boolean): AuthServiceJWTPayloadCodec = if ( - warnOnJwtScopeUsage - ) - sharedWarningCodec - else - JsonImplicits - - object JsonImplicits extends AuthServiceJWTPayloadCodec(writePayload, readPayload) - - private class JsonImplicitsWithWarning( - private val firstRead: AtomicBoolean = new AtomicBoolean(true) - ) extends AuthServiceJWTPayloadCodec( - writePayload, - json => { - val decoded = readPayload(json) - decoded.foreach { - case payload: StandardJWTPayload if payload.format == StandardJWTTokenFormat.Scope => - if (firstRead.getAndSet(false)) { - logger.warn( - "Received scope-based token. Scope-based tokens are deprecated and will be removed from use in Canton release 3.7. Please migrate to audience-based tokens." - ) - } - case _ => - } - decoded - }, - ) - - object AudienceBasedTokenJsonImplicits - extends AuthServiceJWTPayloadCodec(writeAudienceBasedPayload, readAudienceBasedToken) - - object ScopeBasedTokenJsonImplicits - extends AuthServiceJWTPayloadCodec(writeScopeBasedPayload, readScopeBasedToken) - - abstract class AuthServiceJWTPayloadCodec( - writeToken: AuthServiceJWTPayload => Json, - readToken: Json => Either[String, AuthServiceJWTPayload], - ) { - implicit val authServiceJWTPayloadEncoder: Encoder[AuthServiceJWTPayload] = - Encoder.instance(writeToken) - - implicit val authServiceJWTPayloadDecoder: Decoder[AuthServiceJWTPayload] = - Decoder.instance { c => - Try(readToken(c.value)) match { - case Failure(exception) => Left(DecodingFailure(exception.getMessage, Nil)) - case Success(Left(parsingError)) => Left(DecodingFailure(parsingError, Nil)) - case Success(Right(parsedBody)) => Right(parsedBody) - } - } - } -} diff --git a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/Base64.scala b/canton/base/daml-jwt/src/main/scala/com/daml/jwt/Base64.scala deleted file mode 100644 index 29f7abd262..0000000000 --- a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/Base64.scala +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.jwt - -private object Base64 extends WithExecuteUnsafe { - - private val defaultEncoder = java.util.Base64.getUrlEncoder - - private val encoderWithoutPadding = java.util.Base64.getUrlEncoder.withoutPadding - - private val defaultDecoder = java.util.Base64.getUrlDecoder - - def encode(bs: Array[Byte]): Either[Error, Array[Byte]] = - encode(defaultEncoder, bs) - - def encodeWithoutPadding(bs: Array[Byte]): Either[Error, Array[Byte]] = - encode(encoderWithoutPadding, bs) - - private def encode( - encoder: java.util.Base64.Encoder, - bs: Array[Byte], - ): Either[Error, Array[Byte]] = - executeUnsafe(encoder.encode(bs), Symbol("Base64.encode")) - - def decode(base64str: String): Either[Error, String] = - executeUnsafe(new String(defaultDecoder.decode(base64str)), Symbol("Base64.decode")) -} diff --git a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/Error.scala b/canton/base/daml-jwt/src/main/scala/com/daml/jwt/Error.scala deleted file mode 100644 index 9472141bf5..0000000000 --- a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/Error.scala +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.jwt - -final case class Error(what: Symbol, message: String) { - def prettyPrint: String = s"Error: $what, $message" - def within(another: Symbol): Error = Error(what = another, message = s"($prettyPrint)") -} - -final case class JwtException(error: Error) extends RuntimeException diff --git a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtDecoder.scala b/canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtDecoder.scala deleted file mode 100644 index f653d65172..0000000000 --- a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtDecoder.scala +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.jwt - -object JwtDecoder extends WithExecuteUnsafe { - def decode(jwt: Jwt): Either[Error, DecodedJwt[String]] = - executeUnsafe(com.auth0.jwt.JWT.decode(jwt.value), Symbol("JwtDecoder.decode")) - .map(a => DecodedJwt(header = a.getHeader, payload = a.getPayload)) - .flatMap(base64Decode) - - private def base64Decode(jwt: DecodedJwt[String]): Either[Error, DecodedJwt[String]] = - jwt - .transform(Base64.decode) - .left - .map(_.within(Symbol("JwtDecoder.base64Decode"))) -} diff --git a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtFromBearerHeader.scala b/canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtFromBearerHeader.scala deleted file mode 100644 index 8d69e37439..0000000000 --- a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtFromBearerHeader.scala +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.jwt - -object JwtFromBearerHeader { - private val BearerTokenRegex = "Bearer (.+)".r - - def apply(header: String): Either[Error, String] = BearerTokenRegex - .findFirstMatchIn(header) - .map(_.group(1)) - .toRight( - Error(Symbol("JwtFromBearerHeader"), "Authorization header does not use Bearer format") - ) - -} diff --git a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtGenerator.scala b/canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtGenerator.scala deleted file mode 100644 index 1b77eef3c4..0000000000 --- a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtGenerator.scala +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.jwt - -import scala.util.Try - -object JwtGenerator { - def generate: Try[Jwt] = Try(Jwt("dummy")) -} diff --git a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtSigner.scala b/canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtSigner.scala deleted file mode 100644 index 5fd5525d1e..0000000000 --- a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtSigner.scala +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.jwt - -import com.auth0.jwt.algorithms.Algorithm - -import java.nio.charset.Charset -import java.security.interfaces.{ECPrivateKey, RSAPrivateKey} - -object JwtSigner extends WithExecuteUnsafe { - - private val charset = Charset.forName("ASCII") - - object HMAC256 { - def sign(jwt: DecodedJwt[String], secret: String): Either[Error, Jwt] = - for { - base64Jwt <- base64Encode(jwt) - - algorithm <- executeUnsafe(Algorithm.HMAC256(secret), Symbol("HMAC256.sign")) - - signature <- executeUnsafe( - algorithm.sign(base64Jwt.header, base64Jwt.payload), - Symbol("HMAC256.sign"), - ) - - base64Signature <- base64Encode(signature) - - } yield Jwt( - s"${str(base64Jwt.header): String}.${str(base64Jwt.payload)}.${str(base64Signature): String}" - ) - } - - @SuppressWarnings(Array("org.wartremover.warts.Null")) - object RSA256 { - def sign(jwt: DecodedJwt[String], privateKey: RSAPrivateKey): Either[Error, Jwt] = - for { - base64Jwt <- base64Encode(jwt) - - algorithm <- executeUnsafe(Algorithm.RSA256(null, privateKey), Symbol("RSA256.sign")) - - signature <- executeUnsafe( - algorithm.sign(base64Jwt.header, base64Jwt.payload), - Symbol("RSA256.sign"), - ) - - base64Signature <- base64Encode(signature) - - } yield Jwt( - s"${str(base64Jwt.header): String}.${str(base64Jwt.payload)}.${str(base64Signature): String}" - ) - } - - object ECDSA { - def sign( - jwt: DecodedJwt[String], - privateKey: ECPrivateKey, - algorithm: ECPrivateKey => Algorithm, - ): Either[Error, Jwt] = - for { - base64Jwt <- base64Encode(jwt) - - algorithm <- executeUnsafe(algorithm(privateKey), Symbol(algorithm.getClass.getTypeName)) - - signature <- executeUnsafe( - algorithm.sign(base64Jwt.header, base64Jwt.payload), - Symbol(algorithm.getClass.getTypeName), - ) - - base64Signature <- base64Encode(signature) - - } yield Jwt( - s"${str(base64Jwt.header): String}.${str(base64Jwt.payload)}.${str(base64Signature): String}" - ) - } - - private def str(bs: Array[Byte]) = new String(bs, charset) - - private def base64Encode(a: DecodedJwt[String]): Either[Error, DecodedJwt[Array[Byte]]] = - a.transform(base64Encode) - - private def base64Encode(str: String): Either[Error, Array[Byte]] = - base64Encode(str.getBytes) - - private def base64Encode(bs: Array[Byte]): Either[Error, Array[Byte]] = - Base64 - .encodeWithoutPadding(bs) - .left - .map(_.within(Symbol("JwtSigner.base64Encode"))) -} diff --git a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtTimestampLeeway.scala b/canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtTimestampLeeway.scala deleted file mode 100644 index fdcace356b..0000000000 --- a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtTimestampLeeway.scala +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.jwt - -import com.auth0.jwt.JWT -import com.auth0.jwt.algorithms.Algorithm -import com.auth0.jwt.interfaces.Verification - -final case class JwtTimestampLeeway( - default: Option[Long] = None, - expiresAt: Option[Long] = None, - issuedAt: Option[Long] = None, - notBefore: Option[Long] = None, -) - -trait Leeway { - def getVerifier( - algorithm: Algorithm, - jwtTimestampLeeway: Option[JwtTimestampLeeway] = None, - ): com.auth0.jwt.interfaces.JWTVerifier = { - def addLeeway( - verification: Verification, - jwtTimestampLeeway: JwtTimestampLeeway, - ): Verification = { - val mbOptionsActions: List[(Option[Long], (Verification, Long) => Verification)] = List( - (jwtTimestampLeeway.default, _.acceptLeeway(_)), - (jwtTimestampLeeway.expiresAt, _.acceptExpiresAt(_)), - (jwtTimestampLeeway.issuedAt, _.acceptIssuedAt(_)), - (jwtTimestampLeeway.notBefore, _.acceptNotBefore(_)), - ) - mbOptionsActions.foldLeft(verification) { - case (verifier, (None, _)) => verifier - case (verifier, (Some(value), f)) => f(verifier, value) - } - } - val defaultVerifier = JWT.require(algorithm) - val verification = jwtTimestampLeeway.fold(defaultVerifier)(addLeeway(defaultVerifier, _)) - verification.build() - } -} diff --git a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtVerifier.scala b/canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtVerifier.scala deleted file mode 100644 index cd7481a08e..0000000000 --- a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/JwtVerifier.scala +++ /dev/null @@ -1,179 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.jwt - -import com.auth0.jwt.algorithms.Algorithm -import com.auth0.jwt.interfaces.RSAKeyProvider - -import java.io.File -import java.security.interfaces.{ECPublicKey, RSAPublicKey} -import java.time.{Duration, Instant} -import scala.math.Ordered.orderingToOrdered - -abstract class JwtVerifierBase { - def verify(jwt: Jwt): Either[Error, DecodedJwt[String]] -} - -class JwtVerifier( - val verifier: com.auth0.jwt.interfaces.JWTVerifier, - val maxTokenLife: Option[Long], -) extends JwtVerifierBase - with WithExecuteUnsafe { - - def verify(jwt: Jwt): Either[Error, DecodedJwt[String]] = - // The auth0 library verification already fails if the token has expired, - // but we still need to do manual expiration checks in ongoing streams - executeUnsafe(verifier.verify(jwt.value), Symbol("JwtVerifier.verify")) - .map(a => - ( - DecodedJwt( - header = a.getHeader, - payload = a.getPayload, - ), - Option(a.getExpiresAtAsInstant()), - ) - ) - .flatMap { case (jwt, expirationOption) => - checkTokenLifeTime(jwt, expirationOption) - } - .flatMap(base64Decode) - - // Check if the expiration time is not too long and if it exists - private def checkTokenLifeTime( - jwt: DecodedJwt[String], - expirationOption: Option[Instant], - ) = { - // TODO (i27262) use TimeProvider to get current time - val currentTime = Instant.now() - expirationOption - .map { expiresAt => - val duration = Duration.ofMillis(expiresAt.toEpochMilli - currentTime.toEpochMilli) - val maxTokenLifeDuration = maxTokenLife.getOrElse(Long.MaxValue) - // We do not check for negative durations (expired token), as the JWT library already ensures that (with a leeway) - if (duration > Duration.ofMillis(maxTokenLifeDuration)) { - Left(Error(Symbol("JwtVerifier.verify"), s"token lifetime ($expiresAt) too long")) - } else { - Right(jwt) - } - } - .getOrElse( - maxTokenLife - .map(_ => Left(Error(Symbol("JwtVerifier.verify"), "token has no expiration time"))) - .getOrElse(Right(jwt)) - ) - } - - private def base64Decode(jwt: DecodedJwt[String]): Either[Error, DecodedJwt[String]] = - jwt.transform(Base64.decode).left.map(_.within(Symbol("JwtVerifier.base64Decode"))) - -} - -// HMAC256 validator factory -object HMAC256Verifier extends Leeway with WithExecuteUnsafe { - def apply( - secret: String, - jwtTimestampLeeway: Option[JwtTimestampLeeway] = None, - maxTokenLife: Option[Long] = None, - ): Either[Error, JwtVerifier] = - executeUnsafe( - { - val algorithm = Algorithm.HMAC256(secret) - val verifier = getVerifier(algorithm, jwtTimestampLeeway) - new JwtVerifier(verifier, maxTokenLife) - }, - Symbol("HMAC256"), - ) -} - -// ECDSA validator factory -object ECDSAVerifier extends Leeway with WithExecuteUnsafe { - def apply( - algorithm: Algorithm, - jwtTimestampLeeway: Option[JwtTimestampLeeway] = None, - maxTokenLife: Option[Long] = None, - ): Either[Error, JwtVerifier] = - executeUnsafe( - { - val verifier = getVerifier(algorithm, jwtTimestampLeeway) - new JwtVerifier(verifier, maxTokenLife) - }, - Symbol(algorithm.getName), - ) - - def fromCrtFile( - path: String, - algorithmPublicKey: ECPublicKey => Algorithm, - jwtTimestampLeeway: Option[JwtTimestampLeeway] = None, - maxTokenLife: Option[Long] = None, - ): Either[Error, JwtVerifier] = - for { - key <- KeyUtils - .readECPublicKeyFromCrt(new File(path)) - .toEither - .left - .map(e => Error(Symbol("ECDSAVerifier.fromCrtFile"), e.getMessage)) - verifier <- ECDSAVerifier(algorithmPublicKey(key), jwtTimestampLeeway, maxTokenLife) - } yield verifier -} - -// RSA256 validator factory -@SuppressWarnings(Array("org.wartremover.warts.Null")) -object RSA256Verifier extends Leeway with WithExecuteUnsafe { - def apply( - publicKey: RSAPublicKey, - jwtTimestampLeeway: Option[JwtTimestampLeeway] = None, - maxTokenLife: Option[Long] = None, - ): Either[Error, JwtVerifier] = - executeUnsafe( - { - val algorithm = Algorithm.RSA256(publicKey, null) - val verifier = getVerifier(algorithm, jwtTimestampLeeway) - new JwtVerifier(verifier, maxTokenLife) - }, - Symbol("RSA256"), - ) - - def apply(keyProvider: RSAKeyProvider, maxTokenLife: Option[Long]): Either[Error, JwtVerifier] = - executeUnsafe( - { - - val algorithm = Algorithm.RSA256(keyProvider) - val verifier = getVerifier(algorithm) - new JwtVerifier(verifier, maxTokenLife) - }, - (Symbol("RSA256")), - ) - - def apply( - keyProvider: RSAKeyProvider, - jwtTimestampLeeway: Option[JwtTimestampLeeway], - maxTokenLife: Option[Long], - ): Either[Error, JwtVerifier] = - executeUnsafe( - { - - val algorithm = Algorithm.RSA256(keyProvider) - val verifier = getVerifier(algorithm, jwtTimestampLeeway) - new JwtVerifier(verifier, maxTokenLife) - }, - Symbol("RSA256"), - ) - - /** Create a RSA256 validator with the key loaded from the given file. The file is assumed to be a - * X509 encoded certificate. These typically have the .crt file extension. - */ - def fromCrtFile( - path: String, - jwtTimestampLeeway: Option[JwtTimestampLeeway] = None, - maxTokenLife: Option[Long] = None, - ): Either[Error, JwtVerifier] = - for { - rsaKey <- KeyUtils - .readRSAPublicKeyFromCrt(new File(path)) - .toEither - .left - .map(e => Error(Symbol("RSA256Verifier.fromCrtFile"), e.getMessage)) - verifier <- RSA256Verifier.apply(rsaKey, jwtTimestampLeeway, maxTokenLife) - } yield verifier -} diff --git a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/KeyUtils.scala b/canton/base/daml-jwt/src/main/scala/com/daml/jwt/KeyUtils.scala deleted file mode 100644 index d62cfa1810..0000000000 --- a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/KeyUtils.scala +++ /dev/null @@ -1,155 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.jwt - -import java.io.{File, FileInputStream} -import java.nio.charset.StandardCharsets -import java.nio.file.Files -import java.security.cert.CertificateFactory -import java.security.interfaces.{ECPublicKey, RSAPrivateKey, RSAPublicKey} -import java.security.spec.PKCS8EncodedKeySpec -import java.security.{Key, KeyFactory} -import scala.reflect.{ClassTag, classTag} -import scala.util.{Try, Using} - -object KeyUtils { - private val mimeCharSet = StandardCharsets.ISO_8859_1 - - private implicit class SpecifyPublicKey[K <: Key](private val self: K) extends AnyVal { - def asSpecific[SK <: K: ClassTag]: SK = { - val Tag = classTag[SK] - self match { - case Tag(key) => key - case _ => - throw new IllegalStateException( - s"Expected a $Tag key, but got $self :${self.getClass.getName}" - ) - } - } - } - - /** Reads an RSA public key from a X509 encoded file. These usually have the .crt file extension. - */ - def readRSAPublicKeyFromCrt(file: File): Try[RSAPublicKey] = - Using(new FileInputStream(file))( - CertificateFactory - .getInstance("X.509") - .generateCertificate(_) - .getPublicKey - .asSpecific[RSAPublicKey] - ) - - /** Reads an EC public key from a X509 encoded file. These usually have the .crt file extension. - */ - def readECPublicKeyFromCrt(file: File): Try[ECPublicKey] = - Using(new FileInputStream(file))( - CertificateFactory - .getInstance("X.509") - .generateCertificate(_) - .getPublicKey - .asSpecific[ECPublicKey] - ) - - /** Reads a RSA private key from a PEM/PKCS#8 file. These usually have the .pem file extension. - */ - def readRSAPrivateKeyFromPem(file: File): Try[RSAPrivateKey] = - for { - fileContent <- Try(Files.readAllBytes(file.toPath)) - - // Remove PEM container header and footer - pemContent <- Try( - new String(fileContent, mimeCharSet) - .replaceFirst("-----BEGIN ([A-Z ])*-----\n", "") - .replaceFirst("\n-----END ([A-Z ])*-----\n", "") - .replace("\r", "") - .replace("\n", "") - ) - - // Base64-decode the PEM container content - decoded <- Base64 - .decode(pemContent) - .left - .map(e => new RuntimeException(e.prettyPrint)) - .toTry - - // Interpret the container content as PKCS#8 - key <- Try { - val kf = KeyFactory.getInstance("RSA") - val keySpec = new PKCS8EncodedKeySpec(decoded.getBytes) - kf.generatePrivate(keySpec).asSpecific[RSAPrivateKey] - } - } yield key - - /** Reads a RSA private key from a binary file (PKCS#8, DER). To generate this file from a .pem - * file, use the following command: openssl pkcs8 -topk8 -inform PEM -outform DER -in - * private-key.pem -nocrypt > private-key.der - */ - def readRSAPrivateKeyFromDer(file: File): Try[RSAPrivateKey] = - for { - fileContent <- Try(Files.readAllBytes(file.toPath)) - - // Interpret the container content as PKCS#8 - key <- Try { - val kf = KeyFactory.getInstance("RSA") - val keySpec = new PKCS8EncodedKeySpec(fileContent) - kf.generatePrivate(keySpec).asSpecific[RSAPrivateKey] - } - } yield key - - /** Generates a JWKS JSON object for the given map of KeyID->Key for RSA - * - * Note: this uses the same format as Google OAuth, see - * https://www.googleapis.com/oauth2/v3/certs - */ - def generateJwks(keys: Map[String, RSAPublicKey]): String = { - def generateKeyEntry(keyId: String, key: RSAPublicKey): String = - s""" { - | "kid": "$keyId", - | "kty": "RSA", - | "alg": "RS256", - | "use": "sig", - | "e": "${java.util.Base64.getUrlEncoder - .encodeToString(key.getPublicExponent.toByteArray)}", - | "n": "${java.util.Base64.getUrlEncoder.encodeToString(key.getModulus.toByteArray)}" - | }""".stripMargin - - s""" - |{ - | "keys": [ - |${keys.toList.map { case (keyId, key) => generateKeyEntry(keyId, key) }.mkString(",\n")} - | ] - |} - """.stripMargin - } - - /** Generates a JWKS JSON object for the given map of KeyID->Key for EC - * - * Note: this uses the same format as Google OAuth, see - * https://www.gstatic.com/iap/verify/public_key-jwk - */ - def generateECJwks(keys: Map[String, ECPublicKey]): String = { - def generateKeyEntry(keyId: String, key: ECPublicKey): String = - s""" { - | "kid": "$keyId", - | "kty": "EC", - | "alg": "ES${key.getParams.getCurve.getField.getFieldSize}", - | "use": "sig", - | "crv": "P-${key.getParams.getCurve.getField.getFieldSize}", - | "x": "${java.util.Base64.getUrlEncoder.encodeToString( - key.getW.getAffineX.toByteArray - )}", - | "y": "${java.util.Base64.getUrlEncoder.encodeToString( - key.getW.getAffineY.toByteArray - )}" - | }""".stripMargin - - s""" - |{ - | "keys": [ - |${keys.toList.map { case (keyId, key) => generateKeyEntry(keyId, key) }.mkString(",\n")} - | ] - |} - """.stripMargin - } -} diff --git a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/WithExecuteUnsafe.scala b/canton/base/daml-jwt/src/main/scala/com/daml/jwt/WithExecuteUnsafe.scala deleted file mode 100644 index 4c2af930a5..0000000000 --- a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/WithExecuteUnsafe.scala +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.jwt - -import scala.util.Try - -trait WithExecuteUnsafe { - def executeUnsafe[T](f: => T, symbol: Symbol): Either[Error, T] = - Try(f).toEither.left.map(e => Error(symbol, e.getMessage)) -} diff --git a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/package.scala b/canton/base/daml-jwt/src/main/scala/com/daml/jwt/package.scala deleted file mode 100644 index 91749ef79a..0000000000 --- a/canton/base/daml-jwt/src/main/scala/com/daml/jwt/package.scala +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml - -import java.net.URI -import scala.util.Try - -package jwt { - - final case class KeyPair[A](publicKey: A, privateKey: A) - - final case class Jwt(value: String) - - final case class DecodedJwt[A](header: A, payload: A) { - def transform[B](f: A => Either[Error, B]): Either[Error, DecodedJwt[B]] = - for { - header <- f(header) - payload <- f(payload) - } yield DecodedJwt(header, payload) - } - - final case class JwksUrl(value: String) extends AnyVal { - def toURL = new URI(value).toURL - } - - object JwksUrl { - def fromString(value: String): Either[String, JwksUrl] = - Try(new URI(value).toURL).toEither.left - .map(_.getMessage) - .map(_ => JwksUrl(value)) - - def assertFromString(str: String): JwksUrl = fromString(str) match { - case Right(value) => value - case Left(err) => throw new IllegalArgumentException(err) - } - } -} diff --git a/canton/base/daml-jwt/src/test/scala/com/daml/jwt/JwtFromBearerHeaderSpec.scala b/canton/base/daml-jwt/src/test/scala/com/daml/jwt/JwtFromBearerHeaderSpec.scala deleted file mode 100644 index 78d54035ee..0000000000 --- a/canton/base/daml-jwt/src/test/scala/com/daml/jwt/JwtFromBearerHeaderSpec.scala +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.jwt - -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers - -class JwtFromBearerHeaderSpec extends AnyFlatSpec with Matchers { - - it should "produce an error in case of empty string as header" in { - JwtFromBearerHeader("") shouldBe Left( - Error(Symbol("JwtFromBearerHeader"), "Authorization header does not use Bearer format") - ) - } - - it should "produce an error in case of missing Bearer header" in { - JwtFromBearerHeader("Bearer") shouldBe Left( - Error(Symbol("JwtFromBearerHeader"), "Authorization header does not use Bearer format") - ) - - JwtFromBearerHeader("Bearer ") shouldBe Left( - Error(Symbol("JwtFromBearerHeader"), "Authorization header does not use Bearer format") - ) - } - - it should "extract valid token from the header" in { - JwtFromBearerHeader("Bearer 123") shouldBe Right("123") - } - -} diff --git a/canton/base/daml-jwt/src/test/scala/com/daml/jwt/JwtTimestampLeewaySpec.scala b/canton/base/daml-jwt/src/test/scala/com/daml/jwt/JwtTimestampLeewaySpec.scala deleted file mode 100644 index bd1f6e6967..0000000000 --- a/canton/base/daml-jwt/src/test/scala/com/daml/jwt/JwtTimestampLeewaySpec.scala +++ /dev/null @@ -1,361 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.jwt - -import com.auth0.jwt.JWT -import com.auth0.jwt.algorithms.Algorithm -import com.auth0.jwt.exceptions.{InvalidClaimException, TokenExpiredException} -import org.scalactic.source -import org.scalatest.matchers.should.Matchers -import org.scalatest.prop.TableDrivenPropertyChecks -import org.scalatest.wordspec.AnyWordSpec - -import java.security.interfaces.{ECPrivateKey, ECPublicKey, RSAPrivateKey, RSAPublicKey} -import java.security.spec.ECGenParameterSpec -import java.time.temporal.ChronoUnit -import java.util.Date - -class JwtTimestampLeewaySpec extends AnyWordSpec with Matchers with TableDrivenPropertyChecks { - - import JwtTimestampLeewaySpec.* - - "Jwt" when { - forAll(verifires) { (verifierType, algorithm, jwtVerifier) => - ("using " + verifierType + " verifier") should { - - "work with a token that has not expired" in { - val now = new Date() - val token: String = JWT - .create() - .withExpiresAt(fiveSecondsLaterFrom(now)) - .sign(algorithm) - - jwtVerifier(None).verifier - .verify(token) - } - - "work with an expired token when leeway overlaps verification time" in { - val now = new Date() - val token: String = JWT - .create() - .withExpiresAt(oneSecondEarlierFrom(now)) - .sign(algorithm) - - val leeway1 = Some(JwtTimestampLeeway(Some(5), None, None, None)) - jwtVerifier(leeway1).verifier - .verify(token) - } - - "work with an expired token when expiresAt overlaps verification time" in { - val now = new Date() - val token: String = JWT - .create() - .withExpiresAt(oneSecondEarlierFrom(now)) - .sign(algorithm) - - val expiresAt1 = Some(JwtTimestampLeeway(None, Some(5), None, None)) - jwtVerifier(expiresAt1).verifier - .verify(token) - } - - "fail with an expired token when leeway is off" in { - val now = new Date() - val token: String = JWT - .create() - .withExpiresAt(oneSecondEarlierFrom(now)) - .sign(algorithm) - - assertThrows[TokenExpiredException] { - jwtVerifier(None).verifier - .verify(token) - } - } - - "fail with an expired token when leeway does not overlap verification time" in { - val now = new Date() - val token: String = JWT - .create() - .withExpiresAt(fiveSecondsEarlierFrom(now)) - .sign(algorithm) - - val leeway1 = Some(JwtTimestampLeeway(Some(1), None, None, None)) - assertThrows[TokenExpiredException] { - jwtVerifier(leeway1).verifier - .verify(token) - } - } - - "work with an expired token when leeway does not overlap verification but expiresAt does" in { - val now = new Date() - val token: String = JWT - .create() - .withExpiresAt(fiveSecondsEarlierFrom(now)) - .sign(algorithm) - - val leeway = Some(JwtTimestampLeeway(Some(1), Some(10), None, None)) - jwtVerifier(leeway).verifier - .verify(token) - } - - "fail with an expired token when leeway overlaps verification time but expiresAt does not" in { - val now = new Date() - val token: String = JWT - .create() - .withExpiresAt(fiveSecondsEarlierFrom(now)) - .sign(algorithm) - - val leeway = Some(JwtTimestampLeeway(Some(10), Some(1), None, None)) - assertThrows[TokenExpiredException] { - jwtVerifier(leeway).verifier - .verify(token) - } - } - - "work with a token issued in a past date" in { - val now = new Date() - val token: String = JWT - .create() - .withIssuedAt(fiveSecondsEarlierFrom(now)) - .sign(algorithm) - - jwtVerifier(None).verifier - .verify(token) - } - - "work with a token issued in a future date when leeway overlaps verification time" in { - val now = new Date() - val token: String = JWT - .create() - .withIssuedAt(oneSecondLaterFrom(now)) - .sign(algorithm) - - val leeway1 = Some(JwtTimestampLeeway(Some(5), None, None, None)) - jwtVerifier(leeway1).verifier - .verify(token) - } - - "work with a token issued in a future date when issuedAt overlaps verification time" in { - val now = new Date() - val token: String = JWT - .create() - .withIssuedAt(oneSecondLaterFrom(now)) - .sign(algorithm) - - val issuedAt1 = Some(JwtTimestampLeeway(None, None, Some(5), None)) - jwtVerifier(issuedAt1).verifier - .verify(token) - } - - "fail with a token issued in a future date when leeway is off" in { - val now = new Date() - val token: String = JWT - .create() - .withIssuedAt(fiveSecondsLaterFrom(now)) - .sign(algorithm) - - assertThrows[InvalidClaimException] { - jwtVerifier(None).verifier - .verify(token) - } - } - - "fail with a token issued in a future date when leeway does not overlap verification time" in { - val now = new Date() - val token: String = JWT - .create() - .withIssuedAt(fiveSecondsLaterFrom(now)) - .sign(algorithm) - - val leeway1 = Some(JwtTimestampLeeway(Some(1), None, None, None)) - assertThrows[InvalidClaimException] { - jwtVerifier(leeway1).verifier - .verify(token) - } - } - - "work with a token issued in a future date when leeway does not overlap verification but issuedAt does" in { - val now = new Date() - val token: String = JWT - .create() - .withIssuedAt(fiveSecondsLaterFrom(now)) - .sign(algorithm) - - val leeway = Some(JwtTimestampLeeway(Some(1), None, Some(10), None)) - jwtVerifier(leeway).verifier - .verify(token) - } - - "fail with a token issued in a future date when leeway overlaps verification time but expiresAt does not" in { - val now = new Date() - val token: String = JWT - .create() - .withIssuedAt(fiveSecondsLaterFrom(now)) - .sign(algorithm) - - val leeway = Some(JwtTimestampLeeway(Some(10), None, Some(1), None)) - assertThrows[InvalidClaimException] { - jwtVerifier(leeway).verifier - .verify(token) - } - } - - "work with a token that can already be used" in { - val now = new Date() - val token: String = JWT - .create() - .withNotBefore(oneSecondEarlierFrom(now)) - .sign(algorithm) - - jwtVerifier(None).verifier - .verify(token) - } - - "work with a token usable in a future date when leeway overlaps verification time" in { - val now = new Date() - val token: String = JWT - .create() - .withNotBefore(oneSecondLaterFrom(now)) - .sign(algorithm) - - val leeway1 = Some(JwtTimestampLeeway(Some(5), None, None, None)) - jwtVerifier(leeway1).verifier - .verify(token) - } - - "work with a token usable in a future date when notBefore overlaps verification time" in { - val now = new Date() - val token: String = JWT - .create() - .withNotBefore(oneSecondLaterFrom(now)) - .sign(algorithm) - - val notBefore1 = Some(JwtTimestampLeeway(None, None, None, Some(5))) - jwtVerifier(notBefore1).verifier - .verify(token) - } - - "fail with a token usable in a future date when leeway is off" in { - val now = new Date() - val token: String = JWT - .create() - .withNotBefore(fiveSecondsLaterFrom(now)) - .sign(algorithm) - - assertThrows[InvalidClaimException] { - jwtVerifier(None).verifier - .verify(token) - } - } - - "fail with a token usable in a future date when leeway does not overlap verification time" in { - val now = new Date() - val token: String = JWT - .create() - .withNotBefore(fiveSecondsLaterFrom(now)) - .sign(algorithm) - - val leeway1 = Some(JwtTimestampLeeway(Some(1), None, None, None)) - assertThrows[InvalidClaimException] { - jwtVerifier(leeway1).verifier - .verify(token) - } - } - - "work with a token usable in a future date when leeway does not overlap verification but notBefore does" in { - val now = new Date() - val token: String = JWT - .create() - .withNotBefore(fiveSecondsLaterFrom(now)) - .sign(algorithm) - - val leeway = Some(JwtTimestampLeeway(Some(1), None, None, Some(10))) - jwtVerifier(leeway).verifier - .verify(token) - } - - "fail with a token usable in a future date when leeway overlaps verification time but notBefore does not" in { - val now = new Date() - val token: String = JWT - .create() - .withNotBefore(fiveSecondsLaterFrom(now)) - .sign(algorithm) - - val leeway = Some(JwtTimestampLeeway(Some(10), None, None, Some(1))) - assertThrows[InvalidClaimException] { - jwtVerifier(leeway).verifier - .verify(token) - } - } - } - } - } -} - -object JwtTimestampLeewaySpec extends TableDrivenPropertyChecks { - - // HMAC - val secret = "secret key" - - // RSA - val kpgRSA = java.security.KeyPairGenerator.getInstance("RSA") - kpgRSA.initialize(2048) - val keyPairRSA = kpgRSA.generateKeyPair() - val privateKeyRSA = keyPairRSA.getPrivate.asInstanceOf[RSAPrivateKey] - val publicKeyRSA = keyPairRSA.getPublic.asInstanceOf[RSAPublicKey] - - // ECDSA - // 256 - val kpgECDSA256 = java.security.KeyPairGenerator.getInstance("EC") - val ecGenParameterSpec256 = new ECGenParameterSpec("secp256r1") - kpgECDSA256.initialize(ecGenParameterSpec256) - val keyPairECDSA256 = kpgECDSA256.generateKeyPair() - val privateKeyECDSA256 = keyPairECDSA256.getPrivate.asInstanceOf[ECPrivateKey] - val publicKeyECDSA256 = keyPairECDSA256.getPublic.asInstanceOf[ECPublicKey] - // 512 - val kpg512 = java.security.KeyPairGenerator.getInstance("EC") - val ecGenParameterSpec512 = new ECGenParameterSpec("secp521r1") - kpg512.initialize(ecGenParameterSpec512) - val keyPairECDSA512 = kpg512.generateKeyPair() - val privateKeyECDSA512 = keyPairECDSA512.getPrivate.asInstanceOf[ECPrivateKey] - val publicKeyECDSA512 = keyPairECDSA512.getPublic.asInstanceOf[ECPublicKey] - - val hmac256Verifier_ = HMAC256Verifier(secret, _: Option[JwtTimestampLeeway]).assertRight - val rsa256Verifier_ = RSA256Verifier(publicKeyRSA, _: Option[JwtTimestampLeeway]).assertRight - val ecdsa256Verifier_ = - ECDSAVerifier( - Algorithm.ECDSA256(publicKeyECDSA256, null), - _: Option[JwtTimestampLeeway], - ).assertRight - val ecdsa512Verifier_ = - ECDSAVerifier( - Algorithm.ECDSA512(publicKeyECDSA512, null), - _: Option[JwtTimestampLeeway], - ).assertRight - - val verifires = Table( - ("verifier name", "algorithm", "verifier"), - ("HMAC 256", Algorithm.HMAC256(secret), hmac256Verifier_), - ("RSA 256", Algorithm.RSA256(publicKeyRSA, privateKeyRSA), rsa256Verifier_), - ("ECDSA 256", Algorithm.ECDSA256(publicKeyECDSA256, privateKeyECDSA256), ecdsa256Verifier_), - ("ECDSA 512", Algorithm.ECDSA512(publicKeyECDSA512, privateKeyECDSA512), ecdsa512Verifier_), - ) - - def oneSecondEarlierFrom(date: Date): Date = - Date.from(date.toInstant.minus(1, ChronoUnit.SECONDS)) - - def oneSecondLaterFrom(date: Date): Date = - Date.from(date.toInstant.plus(1, ChronoUnit.SECONDS)) - - def fiveSecondsEarlierFrom(date: Date): Date = - Date.from(date.toInstant.minus(5, ChronoUnit.SECONDS)) - - def fiveSecondsLaterFrom(date: Date): Date = - Date.from(date.toInstant.plus(5, ChronoUnit.SECONDS)) - - private implicit final class AssertRight[A](private val ea: Either[Error, A]) extends AnyVal { - def assertRight(implicit pos: source.Position) = - ea.fold(e => org.scalatest.Assertions.fail(e.prettyPrint), identity) - } - -} diff --git a/canton/base/daml-jwt/src/test/scala/com/daml/jwt/SignatureSpec.scala b/canton/base/daml-jwt/src/test/scala/com/daml/jwt/SignatureSpec.scala deleted file mode 100644 index 16c7689c5c..0000000000 --- a/canton/base/daml-jwt/src/test/scala/com/daml/jwt/SignatureSpec.scala +++ /dev/null @@ -1,219 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.jwt - -import com.auth0.jwt.algorithms.Algorithm -import org.scalactic.source -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AnyWordSpec - -import java.security.KeyPair -import java.security.interfaces.{ECPrivateKey, ECPublicKey, RSAPrivateKey, RSAPublicKey} -import java.security.spec.ECGenParameterSpec - -class SignatureSpec extends AnyWordSpec with Matchers { - import SignatureSpec.* - - "Jwt" when { - - "using HMAC256 signatures" should { - - "work with a valid secret" in { - val secret = "secret key" - val jwtHeader = """{"alg": "HS256", "typ": "JWT"}""" - val jwtPayload = """{"dummy":"dummy"}""" - val jwt = DecodedJwt[String](jwtHeader, jwtPayload) - - val signedJwt = JwtSigner.HMAC256 - .sign(jwt, secret) - .assertRight - val verifier = HMAC256Verifier(secret).assertRight - verifier - .verify(signedJwt) - .assertRight - } - - "fail with an invalid secret" in { - val secret = "secret key" - val jwtHeader = """{"alg": "HS256", "typ": "JWT"}""" - val jwtPayload = """{"dummy":"dummy"}""" - val jwt = DecodedJwt[String](jwtHeader, jwtPayload) - - val success = { - val signedJwt = JwtSigner.HMAC256 - .sign(jwt, secret) - .assertRight - val verifier = HMAC256Verifier("invalid " + secret).assertRight - verifier - .verify(signedJwt) - .swap - .left - .map(jwt => fail(s"JWT $jwt was unexpectedly verified")) - } - - success.isRight shouldBe true - } - } - - "using RSA256 signatures" should { - - "work with a valid key" in { - val kpg = java.security.KeyPairGenerator.getInstance("RSA") - kpg.initialize(2048) - val keyPair = kpg.generateKeyPair() - val privateKey = keyPair.getPrivate.asInstanceOf[RSAPrivateKey] - val publicKey = keyPair.getPublic.asInstanceOf[RSAPublicKey] - - val jwtHeader = """{"alg": "RS256", "typ": "JWT"}""" - val jwtPayload = """{"dummy":"dummy"}""" - val jwt = DecodedJwt[String](jwtHeader, jwtPayload) - - val signedJwt = JwtSigner.RSA256 - .sign(jwt, privateKey) - .assertRight - val verifier = RSA256Verifier(publicKey).assertRight - verifier - .verify(signedJwt) - .assertRight - } - - "fail with an invalid key" in { - val kpg = java.security.KeyPairGenerator.getInstance("RSA") - kpg.initialize(2048) - val keyPair1 = kpg.generateKeyPair() - val privateKey = keyPair1.getPrivate.asInstanceOf[RSAPrivateKey] - - val keyPair2 = kpg.generateKeyPair() - val publicKey = keyPair2.getPublic.asInstanceOf[RSAPublicKey] - - val jwtHeader = """{"alg": "RS256", "typ": "JWT"}""" - val jwtPayload = """{"dummy":"dummy"}""" - val jwt = DecodedJwt[String](jwtHeader, jwtPayload) - - val signedJwt = JwtSigner.RSA256 - .sign(jwt, privateKey) - .assertRight - val verifier = RSA256Verifier(publicKey).assertRight - verifier - .verify(signedJwt) - .swap - .left - .map(jwt => fail(s"JWT $jwt was unexpectedly verified")) - } - } - - "using ECDA256 signatures" should { - "work with a valid key" in { - val kpg = java.security.KeyPairGenerator.getInstance("EC") - val ecGenParameterSpec = new ECGenParameterSpec("secp256r1") - kpg.initialize(ecGenParameterSpec) - val keyPair: KeyPair = kpg.generateKeyPair() - - val privateKey = keyPair.getPrivate.asInstanceOf[ECPrivateKey] - val publicKey = keyPair.getPublic.asInstanceOf[ECPublicKey] - - val jwtHeader = """{"alg": "ES256", "typ": "JWT"}""" - val jwtPayload = """{"dummy":"dummy"}""" - val jwt = DecodedJwt[String](jwtHeader, jwtPayload) - - val signedJwt = JwtSigner.ECDSA - .sign(jwt, privateKey, Algorithm.ECDSA256(null, _)) - .assertRight - val verifier = ECDSAVerifier(Algorithm.ECDSA256(publicKey, null)).assertRight - verifier - .verify(signedJwt) - .assertRight - } - "fail with a invalid key" in { - val kpg = java.security.KeyPairGenerator.getInstance("EC") - val ecGenParameterSpec = new ECGenParameterSpec("secp256r1") - kpg.initialize(ecGenParameterSpec) - val keyPair1: KeyPair = kpg.generateKeyPair() - - val privateKey1 = keyPair1.getPrivate.asInstanceOf[ECPrivateKey] - - val keyPair2: KeyPair = kpg.generateKeyPair() - val publicKey2 = keyPair2.getPublic.asInstanceOf[ECPublicKey] - - val jwtHeader = """{"alg": "ES256", "typ": "JWT"}""" - val jwtPayload = """{"dummy":"dummy"}""" - val jwt = DecodedJwt[String](jwtHeader, jwtPayload) - val success = { - val signedJwt = JwtSigner.ECDSA - .sign(jwt, privateKey1, Algorithm.ECDSA256(null, _)) - .assertRight - val verifier = ECDSAVerifier(Algorithm.ECDSA256(publicKey2, null)).assertRight - verifier - .verify(signedJwt) - .swap - .left - .map(jwt => fail(s"JWT $jwt was unexpectedly verified")) - } - - success.isRight shouldBe true - } - } - "using ECDSA512 signatures" should { - "work with a valid key" in { - val kpg = java.security.KeyPairGenerator.getInstance("EC") - val ecGenParameterSpec = new ECGenParameterSpec("secp521r1") - kpg.initialize(ecGenParameterSpec) - val keyPair: KeyPair = kpg.generateKeyPair() - - val privateKey = keyPair.getPrivate.asInstanceOf[ECPrivateKey] - val publicKey = keyPair.getPublic.asInstanceOf[ECPublicKey] - - val jwtHeader = """{"alg": "ES512", "typ": "JWT"}""" - val jwtPayload = """{"dummy":"dummy"}""" - val jwt = DecodedJwt[String](jwtHeader, jwtPayload) - val signedJwt = JwtSigner.ECDSA - .sign(jwt, privateKey, Algorithm.ECDSA512(null, _)) - .assertRight - - val verifier = ECDSAVerifier(Algorithm.ECDSA512(publicKey, null)).assertRight - verifier - .verify(signedJwt) - .assertRight - } - "fail with a invalid key" in { - val kpg = java.security.KeyPairGenerator.getInstance("EC") - val ecGenParameterSpec = new ECGenParameterSpec("secp521r1") - kpg.initialize(ecGenParameterSpec) - val keyPair1: KeyPair = kpg.generateKeyPair() - - val privateKey1 = keyPair1.getPrivate.asInstanceOf[ECPrivateKey] - - val keyPair2: KeyPair = kpg.generateKeyPair() - val publicKey2 = keyPair2.getPublic.asInstanceOf[ECPublicKey] - - val jwtHeader = """{"alg": "ES512", "typ": "JWT"}""" - val jwtPayload = """{"dummy":"dummy"}""" - val jwt = DecodedJwt[String](jwtHeader, jwtPayload) - val success = { - val signedJwt = JwtSigner.ECDSA - .sign(jwt, privateKey1, Algorithm.ECDSA512(null, _)) - .assertRight - val verifier = ECDSAVerifier(Algorithm.ECDSA512(publicKey2, null)).assertRight - verifier - .verify(signedJwt) - .swap - .left - .map(jwt => fail(s"JWT $jwt was unexpectedly verified")) - } - - success.isRight shouldBe true - } - } - - } -} - -object SignatureSpec { - - private implicit final class AssertRight[A](private val ea: Either[Error, A]) extends AnyVal { - def assertRight(implicit pos: source.Position) = - ea.fold(e => org.scalatest.Assertions.fail(e.prettyPrint), identity) - } - -} diff --git a/canton/base/daml-tls/src/main/scala/com/daml/tls/OcspProperties.scala b/canton/base/daml-tls/src/main/scala/com/daml/tls/OcspProperties.scala deleted file mode 100644 index 7ebb278297..0000000000 --- a/canton/base/daml-tls/src/main/scala/com/daml/tls/OcspProperties.scala +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.tls - -/** Enables certificate revocation checks with OCSP. See: - * https://tersesystems.com/blog/2014/03/22/fixing-certificate-revocation/ See: - * https://www.ibm.com/support/knowledgecenter/en/SSYKE2_8.0.0/com.ibm.java.security.component.80.doc/security-component/jsse2Docs/knowndiffsun.html - */ -object OcspProperties { - - val CheckRevocationPropertySun: String = "com.sun.net.ssl.checkRevocation" - val CheckRevocationPropertyIbm: String = "com.ibm.jsse2.checkRevocation" - val EnableOcspProperty: String = "ocsp.enable" - - def enableOcsp(): Unit = { - System.setProperty(CheckRevocationPropertySun, True) - System.setProperty(CheckRevocationPropertyIbm, True) - java.security.Security.setProperty(EnableOcspProperty, True) - } - - private val True: String = "true" - -} diff --git a/canton/base/daml-tls/src/main/scala/com/daml/tls/ProtocolDisabler.scala b/canton/base/daml-tls/src/main/scala/com/daml/tls/ProtocolDisabler.scala deleted file mode 100644 index b012736f58..0000000000 --- a/canton/base/daml-tls/src/main/scala/com/daml/tls/ProtocolDisabler.scala +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.tls - -import java.security.Security - -/** Disables the unwanted legacy SSLv2Hello protocol at the JSSE level. See: - * https://www.java.com/en/configure_crypto.html#DisableTLS:~:text=Disable%20TLS%201.0%20and%20TLS%201.1 - */ -object ProtocolDisabler { - val disabledAlgorithmsProperty: String = "jdk.tls.disabledAlgorithms" - val sslV2Protocol: String = "SSLv2Hello" - - def disableSSLv2Hello(): Unit = - PropertiesUpdater(Security.getProperty, Security.setProperty) - .appendToProperty(disabledAlgorithmsProperty, sslV2Protocol) -} - -private[tls] final case class PropertiesUpdater( - getter: String => String, - setter: (String, String) => Unit, -) { - def appendToProperty(name: String, value: String): Unit = { - val property = getter(name) - val fullProperty = - property - .split(",") - .map(_.trim) - .find(_ == value) - .map(_ => property) - .getOrElse(s"$property, $value") - setter(name, fullProperty) - } -} diff --git a/canton/base/daml-tls/src/main/scala/com/daml/tls/TlsConfig.scala b/canton/base/daml-tls/src/main/scala/com/daml/tls/TlsConfig.scala deleted file mode 100644 index 3103caf6a5..0000000000 --- a/canton/base/daml-tls/src/main/scala/com/daml/tls/TlsConfig.scala +++ /dev/null @@ -1,281 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.tls - -import com.digitalasset.canton.config.{PemFile, PemFileOrString} -import io.grpc.netty.shaded.io.netty.handler.ssl.{ClientAuth, SslContext} -import org.slf4j.LoggerFactory - -sealed trait TlsConfig { - def certChainFile: PemFileOrString - def privateKeyFile: PemFile - def minimumServerProtocolVersion: Option[String] - def ciphers: Option[Seq[String]] - - def protocols: Option[Seq[String]] = - minimumServerProtocolVersion.map { minVersion => - val knownTlsVersions = - Seq( - TlsVersion.V1.version, - TlsVersion.V1_1.version, - TlsVersion.V1_2.version, - TlsVersion.V1_3.version, - ) - knownTlsVersions - .find(_ == minVersion) - .fold[Seq[String]]( - throw new IllegalArgumentException(s"Unknown TLS protocol version $minVersion") - )(versionFound => knownTlsVersions.filter(_ >= versionFound)) - } -} - -/** A wrapper for TLS server parameters supporting only server side authentication - * - * Same parameters as the more complete `TlsServerConfig` - */ -final case class BaseServerTlsConfig( - certChainFile: PemFileOrString, - privateKeyFile: PemFile, - minimumServerProtocolVersion: Option[String] = Some( - TlsServerConfig.defaultMinimumServerProtocol - ), - ciphers: Option[Seq[String]] = TlsServerConfig.defaultCiphers, -) extends TlsConfig - -/** A wrapper for TLS related server parameters supporting mutual authentication. - * - * Certificates and keys must be provided in the PEM format. It is recommended to create them with - * OpenSSL. Other formats (such as GPG) may also work, but have not been tested. - * - * @param certChainFile - * a file containing a certificate chain, containing the certificate chain from the server to the - * root CA. The certificate chain is used to authenticate the server. The order of certificates - * in the chain matters, i.e., it must start with the server certificate and end with the root - * certificate. - * @param privateKeyFile - * a file containing the server's private key. The key must not use a password. - * @param trustCollectionFile - * a file containing certificates of all nodes the server trusts. Used for client authentication. - * It depends on the enclosing configuration whether client authentication is mandatory, optional - * or unsupported. If client authentication is enabled and this parameter is absent, the - * certificates in the JVM trust store will be used instead. - * @param clientAuth - * indicates whether server requires, requests, or does not request auth from clients. Normally - * the ledger api server requires client auth under TLS, but using this setting this requirement - * can be loosened. See - * https://github.com/digital-asset/daml/commit/edd73384c427d9afe63bae9d03baa2a26f7b7f54 - * @param minimumServerProtocolVersion - * minimum supported TLS protocol. Set None (or null in config file) to default to JVM settings. - * @param ciphers - * supported ciphers. Set to None (or null in config file) to default to JVM settings. - * @param enableCertRevocationChecking - * whether to enable certificate revocation checking per - * https://tersesystems.com/blog/2014/03/22/fixing-certificate-revocation/ - */ -// Information in this ScalaDoc comment has been taken from https://grpc.io/docs/guides/auth/. -final case class TlsServerConfig( - certChainFile: PemFileOrString, - privateKeyFile: PemFile, - trustCollectionFile: Option[PemFileOrString] = None, - clientAuth: ServerAuthRequirementConfig = ServerAuthRequirementConfig.Optional, - minimumServerProtocolVersion: Option[String] = Some( - TlsServerConfig.defaultMinimumServerProtocol - ), - ciphers: Option[Seq[String]] = TlsServerConfig.defaultCiphers, - enableCertRevocationChecking: Boolean = false, -) extends TlsConfig { - lazy val clientConfig: TlsClientConfig = { - val clientCert = clientAuth match { - case ServerAuthRequirementConfig.Require(cert) => Some(cert) - case _ => None - } - TlsClientConfig(trustCollectionFile = Some(certChainFile), clientCert = clientCert) - } - - /** This is a side-effecting method. It modifies JVM TLS properties according to the TLS - * configuration. - */ - def setJvmTlsProperties(): Unit = { - if (enableCertRevocationChecking) OcspProperties.enableOcsp() - ProtocolDisabler.disableSSLv2Hello() - } - - override def protocols: Option[Seq[String]] = { - val disallowedTlsVersions = - Seq( - TlsVersion.V1.version, - TlsVersion.V1_1.version, - ) - minimumServerProtocolVersion match { - case Some(minVersion) if disallowedTlsVersions.contains(minVersion) => - throw new IllegalArgumentException(s"Unsupported TLS version: $minVersion") - case _ => - super.protocols - } - } - -} - -object TlsServerConfig { - - // default OWASP strong cipher set with broad compatibility (B list) - // https://cheatsheetseries.owasp.org/cheatsheets/TLS_Cipher_String_Cheat_Sheet.html - lazy val defaultCiphers = { - val candidates = Seq( - "TLS_AES_256_GCM_SHA384", - "TLS_CHACHA20_POLY1305_SHA256", - "TLS_AES_128_GCM_SHA256", - "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384", - "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256", - "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", - "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", - "TLS_DHE_RSA_WITH_AES_256_CBC_SHA256", - "TLS_DHE_RSA_WITH_AES_128_CBC_SHA256", - "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384", - "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", - ) - val logger = LoggerFactory.getLogger(TlsServerConfig.getClass) - val filtered = candidates.filter { x => - io.grpc.netty.shaded.io.netty.handler.ssl.OpenSsl - .availableOpenSslCipherSuites() - .contains(x) || - io.grpc.netty.shaded.io.netty.handler.ssl.OpenSsl.availableJavaCipherSuites().contains(x) - } - if (filtered.isEmpty) { - val len = io.grpc.netty.shaded.io.netty.handler.ssl.OpenSsl - .availableOpenSslCipherSuites() - .size() + io.grpc.netty.shaded.io.netty.handler.ssl.OpenSsl - .availableJavaCipherSuites() - .size() - logger.warn( - s"All of Canton's default TLS ciphers are unsupported by your JVM (netty reports $len ciphers). Defaulting to JVM settings." - ) - if (!io.grpc.netty.shaded.io.netty.handler.ssl.OpenSsl.isAvailable) { - logger.info( - "Netty OpenSSL is not available because of an issue", - io.grpc.netty.shaded.io.netty.handler.ssl.OpenSsl.unavailabilityCause(), - ) - } - None - } else { - logger.debug( - s"Using ${filtered.length} out of ${candidates.length} Canton's default TLS ciphers" - ) - Some(filtered) - } - } - - val defaultMinimumServerProtocol = "TLSv1.2" - - /** Netty incorrectly hardcodes the report that the SSLv2Hello protocol is enabled. There is no - * way to stop it from doing it, so we just filter the netty's erroneous claim. We also make sure - * that the SSLv2Hello protocol is knocked out completely at the JSSE level through the - * ProtocolDisabler - */ - private def filterSSLv2Hello(protocols: Seq[String]): Seq[String] = - protocols.filter(_ != ProtocolDisabler.sslV2Protocol) - - def logTlsProtocolsAndCipherSuites( - sslContext: SslContext, - isServer: Boolean, - ): Unit = { - val (who, provider, logger) = - if (isServer) - ( - "Server", - SslContext.defaultServerProvider(), - LoggerFactory.getLogger(TlsServerConfig.getClass), - ) - else - ( - "Client", - SslContext.defaultClientProvider(), - LoggerFactory.getLogger(TlsClientConfig.getClass), - ) - - val tlsInfo = TlsInfo.fromSslContext(sslContext) - logger.info(s"$who TLS - enabled via $provider") - logger.debug( - s"$who TLS - supported protocols: ${filterSSLv2Hello(tlsInfo.supportedProtocols).mkString(", ")}." - ) - logger.info( - s"$who TLS - enabled protocols: ${filterSSLv2Hello(tlsInfo.enabledProtocols).mkString(", ")}." - ) - logger.debug( - s"$who TLS $who - supported cipher suites: ${tlsInfo.supportedCipherSuites.mkString(", ")}." - ) - logger.info(s"$who TLS - enabled cipher suites: ${tlsInfo.enabledCipherSuites.mkString(", ")}.") - } - -} - -/** A wrapper for TLS related client configurations - * - * @param trustCollectionFile - * a file containing certificates of all nodes the client trusts. If none is specified, defaults - * to the JVM trust store - * @param clientCert - * the client certificate - * @param enabled - * allows enabling TLS without `trustCollectionFile` or `clientCert` - */ -final case class TlsClientConfig( - trustCollectionFile: Option[PemFileOrString], - clientCert: Option[TlsClientCertificate], - enabled: Boolean = true, -) { - def withoutClientCert: TlsClientConfigOnlyTrustFile = - TlsClientConfigOnlyTrustFile( - trustCollectionFile = trustCollectionFile, - enabled = enabled, - ) -} - -/** A wrapper for TLS related client configurations without client auth support (currently public - * sequencer api) - * - * @param trustCollectionFile - * a file containing certificates of all nodes the client trusts. If none is specified, defaults - * to the JVM trust store - * @param enabled - * allows enabling TLS without `trustCollectionFile` - */ -final case class TlsClientConfigOnlyTrustFile( - trustCollectionFile: Option[PemFileOrString], - enabled: Boolean = true, -) { - def toTlsClientConfig: TlsClientConfig = TlsClientConfig( - trustCollectionFile = trustCollectionFile, - clientCert = None, - enabled = enabled, - ) -} - -final case class TlsClientCertificate(certChainFile: PemFileOrString, privateKeyFile: PemFile) - -/** Configuration on whether server requires auth, requests auth, or no auth */ -sealed trait ServerAuthRequirementConfig { - def clientAuth: ClientAuth -} -object ServerAuthRequirementConfig { - - /** A variant of [[ServerAuthRequirementConfig]] by which the server requires auth from clients */ - final case class Require(adminClient: TlsClientCertificate) extends ServerAuthRequirementConfig { - val clientAuth = ClientAuth.REQUIRE - } - - /** A variant of [[ServerAuthRequirementConfig]] by which the server merely requests auth from - * clients - */ - case object Optional extends ServerAuthRequirementConfig { - val clientAuth = ClientAuth.OPTIONAL - } - - /** A variant of [[ServerAuthRequirementConfig]] by which the server does not even request auth - * from clients - */ - case object None extends ServerAuthRequirementConfig { - val clientAuth = ClientAuth.NONE - } -} diff --git a/canton/base/daml-tls/src/main/scala/com/daml/tls/TlsConfiguration.scala b/canton/base/daml-tls/src/main/scala/com/daml/tls/TlsConfiguration.scala deleted file mode 100644 index d8c03ce47c..0000000000 --- a/canton/base/daml-tls/src/main/scala/com/daml/tls/TlsConfiguration.scala +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.tls - -import com.daml.tls.TlsVersion.TlsVersion -import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts -import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext -import org.slf4j.LoggerFactory - -import java.io.File -import scala.jdk.CollectionConverters.* - -// Interacting with java libraries makes null a necessity -@SuppressWarnings(Array("org.wartremover.warts.Null", "org.wartremover.warts.AsInstanceOf")) -final case class TlsConfiguration( - enabled: Boolean, - certChainFile: Option[File] = None, // mutual auth is disabled if null - privateKeyFile: Option[File] = None, - trustCollectionFile: Option[File] = None, // System default if null -) { - - private val logger = LoggerFactory.getLogger(getClass) - - /** If enabled and all required fields are present, it returns an SslContext suitable for client - * usage - */ - def client(enabledProtocols: Seq[TlsVersion] = Seq.empty): Option[SslContext] = - if (enabled) { - val enabledProtocolsNames = - if (enabledProtocols.isEmpty) - null - else - enabledProtocols.map(_.version).asJava - val sslContext = GrpcSslContexts - .forClient() - .keyManager( - certChainFile.orNull, - privateKeyFile.orNull, - ) - .trustManager(trustCollectionFile.orNull) - .protocols(enabledProtocolsNames) - .sslProvider(SslContext.defaultClientProvider()) - .build() - logTlsProtocolsAndCipherSuites(sslContext, isServer = false) - Some(sslContext) - } else None - - private[tls] def logTlsProtocolsAndCipherSuites( - sslContext: SslContext, - isServer: Boolean, - ): Unit = { - val (who, provider) = - if (isServer) - ("Server", SslContext.defaultServerProvider()) - else - ("Client", SslContext.defaultClientProvider()) - val tlsInfo = TlsInfo.fromSslContext(sslContext) - logger.info(s"$who TLS - enabled via $provider") - logger.debug( - s"$who TLS - supported protocols: ${filterSSLv2Hello(tlsInfo.supportedProtocols).mkString(", ")}." - ) - logger.info( - s"$who TLS - enabled protocols: ${filterSSLv2Hello(tlsInfo.enabledProtocols).mkString(", ")}." - ) - logger.debug( - s"$who TLS $who - supported cipher suites: ${tlsInfo.supportedCipherSuites.mkString(", ")}." - ) - logger.info(s"$who TLS - enabled cipher suites: ${tlsInfo.enabledCipherSuites.mkString(", ")}.") - } - - /** Netty incorrectly hardcodes the report that the SSLv2Hello protocol is enabled. There is no - * way to stop it from doing it, so we just filter the netty's erroneous claim. We also make sure - * that the SSLv2Hello protocol is knocked out completely at the JSSE level through the - * ProtocolDisabler - */ - private def filterSSLv2Hello(protocols: Seq[String]): Seq[String] = - protocols.filter(_ != ProtocolDisabler.sslV2Protocol) - -} - -object TlsConfiguration { - val Empty: TlsConfiguration = TlsConfiguration( - enabled = true, - certChainFile = None, - privateKeyFile = None, - trustCollectionFile = None, - ) -} diff --git a/canton/base/daml-tls/src/main/scala/com/daml/tls/TlsConfigurationCli.scala b/canton/base/daml-tls/src/main/scala/com/daml/tls/TlsConfigurationCli.scala deleted file mode 100644 index f02d8bb57e..0000000000 --- a/canton/base/daml-tls/src/main/scala/com/daml/tls/TlsConfigurationCli.scala +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.tls - -import java.nio.file.Paths -import scala.util.Try - -object TlsConfigurationCli { - - type Setter[T, B] = (B => B, T) => T - def parse[C](parser: scopt.OptionParser[C], colSpacer: String)( - setter: Setter[C, TlsConfiguration] - ): Unit = { - def enableSet(tlsUp: TlsConfiguration => TlsConfiguration, c: C) = - setter(tlsc => tlsUp(tlsc.copy(enabled = true)), c) - - import parser.opt - - opt[String]("pem") - .optional() - .text("TLS: The pem file to be used as the private key.") - .validate(validatePath(_, "The file specified via --pem does not exist")) - .action { (path, c) => - enableSet(_.copy(privateKeyFile = Some(Paths.get(path).toFile)), c) - }: Unit - - opt[String]("crt") - .optional() - .text( - s"TLS: The crt file to be used as the cert chain.\n$colSpacer" + - s"Required for client authentication." - ) - .validate(validatePath(_, "The file specified via --crt does not exist")) - .action { (path, c) => - enableSet(_.copy(certChainFile = Some(Paths.get(path).toFile)), c) - }: Unit - - opt[String]("cacrt") - .optional() - .text("TLS: The crt file to be used as the trusted root CA.") - .validate(validatePath(_, "The file specified via --cacrt does not exist")) - .action { (path, c) => - enableSet(_.copy(trustCollectionFile = Some(Paths.get(path).toFile)), c) - }: Unit - - // allows you to enable tls without any special certs, - // i.e., tls without client auth with the default root certs. - // If any certificates are set tls is enabled implicitly and - // this is redundant. - opt[Unit]("tls") - .optional() - .text("TLS: Enable tls. This is redundant if --pem, --crt or --cacrt are set") - .action((_, c) => enableSet(identity, c)): Unit - () - } - - private def validatePath(path: String, message: String): Either[String, Unit] = { - val valid = Try(Paths.get(path).toFile.canRead).getOrElse(false) - Either.cond(valid, (), message) - } -} diff --git a/canton/base/daml-tls/src/main/scala/com/daml/tls/TlsInfo.scala b/canton/base/daml-tls/src/main/scala/com/daml/tls/TlsInfo.scala deleted file mode 100644 index a727625154..0000000000 --- a/canton/base/daml-tls/src/main/scala/com/daml/tls/TlsInfo.scala +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.tls - -import io.grpc.netty.shaded.io.netty.buffer.ByteBufAllocator -import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext - -import javax.net.ssl.SSLEngine - -final case class TlsInfo( - enabledCipherSuites: Seq[String], - enabledProtocols: Seq[String], - supportedCipherSuites: Seq[String], - supportedProtocols: Seq[String], -) - -object TlsInfo { - def fromSslContext(sslContext: SslContext): TlsInfo = { - val engine: SSLEngine = sslContext.newEngine(ByteBufAllocator.DEFAULT) - TlsInfo( - enabledCipherSuites = engine.getEnabledCipherSuites.toIndexedSeq, - enabledProtocols = engine.getEnabledProtocols.toIndexedSeq, - supportedCipherSuites = engine.getSupportedCipherSuites.toIndexedSeq, - supportedProtocols = engine.getSupportedProtocols.toIndexedSeq, - ) - } -} diff --git a/canton/base/daml-tls/src/main/scala/com/daml/tls/TlsVersion.scala b/canton/base/daml-tls/src/main/scala/com/daml/tls/TlsVersion.scala deleted file mode 100644 index ef14e17738..0000000000 --- a/canton/base/daml-tls/src/main/scala/com/daml/tls/TlsVersion.scala +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.tls - -object TlsVersion { - - sealed abstract class TlsVersion(val version: String) { - override def toString: String = version - } - - case object V1 extends TlsVersion("TLSv1") - - case object V1_1 extends TlsVersion("TLSv1.1") - - case object V1_2 extends TlsVersion("TLSv1.2") - - case object V1_3 extends TlsVersion("TLSv1.3") - - val allVersions: Set[TlsVersion] = Set( - V1, - V1_1, - V1_2, - V1_3, - ) - -} diff --git a/canton/base/daml-tls/src/test/resources/gen-test-certificates.sh b/canton/base/daml-tls/src/test/resources/gen-test-certificates.sh deleted file mode 100755 index 62ce00d6d5..0000000000 --- a/canton/base/daml-tls/src/test/resources/gen-test-certificates.sh +++ /dev/null @@ -1,162 +0,0 @@ -#!/bin/bash - -# Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -set -eoux pipefail - -mkdir test-certificates -cd test-certificates - -mkdir newcerts - -ABSOLUTE_OUT=$(pwd) -DAYS=7305 # 20 years (accounting for leap years) - -# Generate SSL config from the template -cat ../openssl-template.cnf | sed -e "s;;$ABSOLUTE_OUT;g" > openssl.cnf - -# Setup directories -touch -- index.txt -echo 1000 > serial - -function create_key { - local name=$1 - openssl genrsa -out "${name}.key" 4096 -} - -function create_pem { - local name=$1 - openssl pkey -in "${name}.key" -out "${name}.pem" -} - -function create_key_and_pem { - local name=$1 - create_key "$name" - create_pem "$name" -} - -function create_certificate { - local conf=$1 - local name=$2 - local subj=$3 - openssl req -config "$conf" \ - -key "${name}.key" \ - -new -x509 -days $DAYS -sha256 -extensions v3_ca \ - -subj "$subj" \ - -out "${name}.crt" -} - -function create_csr { - local name=$1 - local subj=$2 - local san=$3 - local conf="${4:-""}" - - args=( - -subj "$subj" - -addext "subjectAltName=${san}" - -key "${name}.pem" - -new - -out "${name}.csr" - ) - if [[ -n "$conf" ]]; then - args+=(-config "$conf") - args+=(-sha256) - fi - - openssl req "${args[@]}" -} - -function print_certificate { - local name=$1 - openssl x509 -in "${name}.crt" -text -noout -} - -# Generate Root CA private key -create_key "ca" -chmod 400 "ca.key" -# Create Root Certificate (self-signed) -create_certificate "openssl.cnf" "ca" "/CN=0.0.0.0.ca" -print_certificate "ca" - -# Generate server key, csr and crt -create_key_and_pem "server" -create_csr "server" "/CN=0.0.0.0.server" "DNS:localhost, IP:127.0.0.1" "openssl.cnf" -openssl ca -batch -config "openssl.cnf" \ - -extensions server_cert -days $DAYS -notext -md sha256 \ - -in "server.csr" \ - -out "server.crt" -chmod 444 "server.crt" - -# Encrypt server's key and dump encryption parameters to a JSON file. -# NOTE: Encryption details used to encrypt the private must be kept in sync with `test-common/files/server-pem-decryption-parameters.json` -openssl enc -aes-128-cbc -base64 \ - -in "server.pem" \ - -out "server.pem.enc" \ - -K 0034567890abcdef1234567890abcdef \ - -iv 1134567890abcdef1234567890abcdef - -# Generate Client CA private key -create_key_and_pem "client" -openssl req -new -key "client.pem" \ - -subj "/CN=0.0.0.0.client" \ - -addext "subjectAltName = DNS:localhost, IP:127.0.0.1" \ - -out "client.csr" -# Sign Client Cert -openssl ca -batch -config "openssl.cnf" \ - -extensions usr_cert -days $DAYS -notext -md sha256 \ - -in "client.csr" \ - -out "client.crt" -# Validate cert is correct -openssl verify -CAfile "ca.crt" "client.crt" - -# Generate OCSP Server private key -openssl genrsa -out "ocsp.key.pem" 4096 -# Sign OCSP Server certificate -openssl req -config "openssl.cnf" -new -sha256 \ - -subj "/CN=ocsp.127.0.0.1" \ - -key "ocsp.key.pem" \ - -out "ocsp.csr" - -openssl ca -batch -config "openssl.cnf" \ - -extensions ocsp -days $DAYS -notext -md sha256 \ - -in "ocsp.csr" \ - -out "ocsp.crt" -# Validate extensions -openssl x509 -noout -text \ - -in "ocsp.crt" - - -# Generate Client-Revoked CA private key -openssl genpkey -out "client-revoked.key" -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -create_pem "client-revoked" -create_csr "client-revoked" "/CN=0.0.0.0.clientrevoked" "DNS:localhost, IP:127.0.0.1" -# Sign Client Cert -openssl ca -batch -config "openssl.cnf" \ - -extensions usr_cert -days $DAYS -notext -md sha256 \ - -in "client-revoked.csr" \ - -out "client-revoked.crt" -# Validate cert is correct -openssl verify -CAfile "ca.crt" "client-revoked.crt" -# Revoke -openssl ca -batch -config "openssl.cnf" -revoke "client-revoked.crt" - - -## Configure alternative CA for 'invalid certificate' scenarios -NEWCERTS_ALTERNATIVE_DIR=$ABSOLUTE_OUT/newcerts_alternative - -# Generate SSL config from the template -cat ../openssl-alternative-template.cnf | sed -e "s;;$ABSOLUTE_OUT;g" > openssl-alternative.cnf - -# Setup directories -mkdir -- $NEWCERTS_ALTERNATIVE_DIR -touch -- index_alternative.txt -echo 1000 > serial_alternative - -# Generate Root Alternative CA private key -create_key "ca_alternative" -chmod 400 ca_alternative.key -create_pem "ca_alternative" -create_certificate "openssl-alternative.cnf" "ca_alternative" "/CN=0.0.0.0.ca" -print_certificate "ca_alternative" diff --git a/canton/base/daml-tls/src/test/resources/openssl-alternative-template.cnf b/canton/base/daml-tls/src/test/resources/openssl-alternative-template.cnf deleted file mode 100644 index defe81bbb6..0000000000 --- a/canton/base/daml-tls/src/test/resources/openssl-alternative-template.cnf +++ /dev/null @@ -1,90 +0,0 @@ -# OpenSSL root CA configuration file. -# Copy to `certs/root/openssl.cnf`. - -# Copyright (c) 2022 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - - -[ ca ] -# `man ca` -default_ca = CA_default - -[ CA_default ] -# Directory and file locations. -dir = -certs = $dir/certs_alternative -crl_dir = $dir/crl_alternative -new_certs_dir = $dir/newcerts_alternative -database = $dir/index_alternative.txt -serial = $dir/serial_alternative -RANDFILE = $dir/private/.rand_alternative - -# The root key and root certificate. -private_key = $dir/ca_alternative.key -certificate = $dir/ca_alternative.crt - -# SHA-1 is deprecated, so use SHA-2 instead. -default_md = sha256 - -name_opt = ca_default -cert_opt = ca_default -default_days = 375 -preserve = no -policy = policy_loose - -[ policy_loose ] -# Allow the CA to sign a more diverse range of certificates. -# Test-only. -# See the POLICY FORMAT section of the `ca` man page. -countryName = optional -stateOrProvinceName = optional -localityName = optional -organizationName = optional -organizationalUnitName = optional -commonName = supplied -emailAddress = optional - -[ req ] -# Options for the `req` tool (`man req`). -default_bits = 2048 -distinguished_name = req_distinguished_name -string_mask = utf8only - -# SHA-1 is deprecated, so use SHA-2 instead. -default_md = sha256 - -# Extension to add when the -x509 option is used. -x509_extensions = v3_ca - -[ req_distinguished_name ] -# See . -countryName = CH - -[ v3_ca ] -# Extensions for a typical CA (`man x509v3_config`). -subjectKeyIdentifier = hash -authorityKeyIdentifier = keyid:always,issuer -basicConstraints = critical, CA:true - -[ usr_cert ] -# Extensions for client certificates (`man x509v3_config`). -subjectAltName = @alt_names -authorityInfoAccess = OCSP;URI:http://127.0.0.1:2560 - -[ server_cert ] -# Extensions for server certificates (`man x509v3_config`). -subjectAltName = @alt_names -authorityInfoAccess = OCSP;URI:http://127.0.0.1:2560 - -[ ocsp ] -# Extension for OCSP signing certificates (`man ocsp`). -basicConstraints = CA:FALSE -subjectKeyIdentifier = hash -authorityKeyIdentifier = keyid,issuer -keyUsage = critical, digitalSignature -extendedKeyUsage = critical, OCSPSigning - -[ alt_names ] -DNS = localhost -IP = 127.0.0.1 - diff --git a/canton/base/daml-tls/src/test/resources/openssl-template.cnf b/canton/base/daml-tls/src/test/resources/openssl-template.cnf deleted file mode 100644 index 5b2d89eafd..0000000000 --- a/canton/base/daml-tls/src/test/resources/openssl-template.cnf +++ /dev/null @@ -1,90 +0,0 @@ -# OpenSSL root CA configuration file. -# Copy to `certs/root/openssl.cnf`. - -# Copyright (c) 2022 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - - -[ ca ] -# `man ca` -default_ca = CA_default - -[ CA_default ] -# Directory and file locations. -dir = -certs = $dir/certs -crl_dir = $dir/crl -new_certs_dir = $dir/newcerts -database = $dir/index.txt -serial = $dir/serial -RANDFILE = $dir/private/.rand - -# The root key and root certificate. -private_key = $dir/ca.key -certificate = $dir/ca.crt - -# SHA-1 is deprecated, so use SHA-2 instead. -default_md = sha256 - -name_opt = ca_default -cert_opt = ca_default -default_days = 375 -preserve = no -policy = policy_loose - -[ policy_loose ] -# Allow the CA to sign a more diverse range of certificates. -# Test-only. -# See the POLICY FORMAT section of the `ca` man page. -countryName = optional -stateOrProvinceName = optional -localityName = optional -organizationName = optional -organizationalUnitName = optional -commonName = supplied -emailAddress = optional - -[ req ] -# Options for the `req` tool (`man req`). -default_bits = 2048 -distinguished_name = req_distinguished_name -string_mask = utf8only - -# SHA-1 is deprecated, so use SHA-2 instead. -default_md = sha256 - -# Extension to add when the -x509 option is used. -x509_extensions = v3_ca - -[ req_distinguished_name ] -# See . -countryName = CH - -[ v3_ca ] -# Extensions for a typical CA (`man x509v3_config`). -subjectKeyIdentifier = hash -authorityKeyIdentifier = keyid:always,issuer -basicConstraints = critical, CA:true - -[ usr_cert ] -# Extensions for client certificates (`man x509v3_config`). -subjectAltName = @alt_names -authorityInfoAccess = OCSP;URI:http://127.0.0.1:2560 - -[ server_cert ] -# Extensions for server certificates (`man x509v3_config`). -subjectAltName = @alt_names -authorityInfoAccess = OCSP;URI:http://127.0.0.1:2560 - -[ ocsp ] -# Extension for OCSP signing certificates (`man ocsp`). -basicConstraints = CA:FALSE -subjectKeyIdentifier = hash -authorityKeyIdentifier = keyid,issuer -keyUsage = critical, digitalSignature -extendedKeyUsage = critical, OCSPSigning - -[ alt_names ] -DNS = localhost -IP = 127.0.0.1 - diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/ca.crt b/canton/base/daml-tls/src/test/resources/test-certificates/ca.crt deleted file mode 100644 index 926c567235..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/ca.crt +++ /dev/null @@ -1,29 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFCjCCAvKgAwIBAgITZXn3DA82+xzsZ/NP8sRZpYih0DANBgkqhkiG9w0BAQsF -ADAVMRMwEQYDVQQDDAowLjAuMC4wLmNhMB4XDTI2MDMwOTA5MDU0OVoXDTQ2MDMw -OTA5MDU0OVowFTETMBEGA1UEAwwKMC4wLjAuMC5jYTCCAiIwDQYJKoZIhvcNAQEB -BQADggIPADCCAgoCggIBALeRMQw6URW5X7bI027/T2XT8XFdoTop1vzUT4w2l2H/ -YOFHEHdY8WJizvwQBGW+o/EXnziRhMb5DbccGj9RUmc9hrLLsTOVPoMy/bqNU00b -A7FfoHvqC4Oqbxb2lhySLsrr9xC5Bq5nCWBRDHJfN+HAHyQMc7LJvdPhcrrtLMvA -FmUwntox53ld8kef6ykP7NICSveNwDspt0JTLytABPY2iPw2K19aG3wxh4vovLLp -HQPV1/QvUJUg/XLsNbEzNyZron3ZJ++xO7+gyAE9kS0dtahqiFU9VxmaK1A+XanI -ISkEIrpeCt6wQKr720qLG72VxpB0NF5M1h4QMyjdECQoZhmBc43N6k71/Dw6bVhx -rcqrtHHLeXF3InjhijJm4hYWoqDSdHUoVohaDdzPw2nTmuC7Ew6TkIB3ZWgE13wD -kI1Gh61voJCV/SU/KCbbMj1Is1THZAF6V3ce3DjGyQTUboVPJwTk5NjJCYFZ4BM2 -BsmfppPrbhx8P2gpbYfFtMREFkvFstTgVrXXg8TTmsrGgypulZF+g0PBTMW+9Pcn -TdZeoI6szvXkg1btF0W/G1TaM71jbXsqxRztLvd1UeS+v+58eBuER6EVzFdXpXfH -gSkwOraSi4Cxd+hcyHdvQGL70KVQt2QgEH5j9B33M7CK4+i1vsvO+N2512sJA8/X -AgMBAAGjUzBRMB0GA1UdDgQWBBQRC9xCPoxP3RgToDoK88B1gOebzzAfBgNVHSME -GDAWgBQRC9xCPoxP3RgToDoK88B1gOebzzAPBgNVHRMBAf8EBTADAQH/MA0GCSqG -SIb3DQEBCwUAA4ICAQBCBnj94FA9AvSoTg1TcXjTwDUPBUZbTMDWML+mrw/m10Es -t+/WJQyuJ20Oj3KvPdO6TaXz4rh7H3rn428ITfFP4TAZzihYVmvIl+OokoZ3AR6m -RfxevvHD9R+eR0ru0DBRCzVLMGfU2nNd0Hhry8Q/zrPpyTqQ5zGB3N/y+dPS0VqH -aP/hOJoSAMgI6bn9tRC1C3E6SGmCDm9OygYqjWn2IuAPrTz1WxTx1TB181uD6uOj -VzvLxbbbJbbIjspa16l1VfEKGTI9WrvKf9pV/zkPgm7Nd1OxUR2G71GrMYvouI7t -3Uv758muCx9Oim3nT8ptthTfgqcfgvl2OEFfA2JWtAbaBfF9rMKASd7eZTp+d5BP -x+mEm6ZD0s+Xm4b6uHaMY8CkMOEiJ5OWZ/kXq9a9j9NiQ+cpiahxskfUzSIVoXKu -gqVUDZtUWlN7rj+MXqEa+SyrhDFUXecTq4LGuwTpXYYLIpu3tN91waw9xl3kHr+w -GLB/hLP+H+ImpDAUhdMmgLM4WwD78dOltooUkIKhHyFHbk8/aniTHdO9q/YvBcn3 -RznwbyLJVoo/oLq+qdCo8/LVpQVRLkwaBIeBh6adzpkfFXbsP3zqyyPLtPZm2gk1 -wkyGwlawjV8jmhyuiHwuSanQobDR4wzhSsvxEaKBNV/DNatNozMXgpf3y6rFqg== ------END CERTIFICATE----- diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/ca.key b/canton/base/daml-tls/src/test/resources/test-certificates/ca.key deleted file mode 100644 index cc909763de..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/ca.key +++ /dev/null @@ -1,52 +0,0 @@ ------BEGIN PRIVATE KEY----- -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 -6I3oWvy3J+hLa7tQGtGU0M3hFfqdvA== ------END PRIVATE KEY----- diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/ca_alternative.crt b/canton/base/daml-tls/src/test/resources/test-certificates/ca_alternative.crt deleted file mode 100644 index fa77aef2d5..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/ca_alternative.crt +++ /dev/null @@ -1,29 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFCzCCAvOgAwIBAgIUPEF+QY15O7GaOMff1imD/2Boy1owDQYJKoZIhvcNAQEL -BQAwFTETMBEGA1UEAwwKMC4wLjAuMC5jYTAeFw0yNjAzMDkwOTA1NTJaFw00NjAz -MDkwOTA1NTJaMBUxEzARBgNVBAMMCjAuMC4wLjAuY2EwggIiMA0GCSqGSIb3DQEB -AQUAA4ICDwAwggIKAoICAQCz/CFKWJy15HgNL3QUfzEqI6LweflrWGOcxrrnM+Xp -H+zbo0ScVXWyMJd4zC637CMZJmcOgZbXWqyUBkpf5De9wOKH70urYkJCpx5QWdaY -usMtUTxsbykMFwdOkpptm+wV/379KBU0+XZQWSxlR7FYKKhHAdgGWcKFjjRgQ4+j -j66XCH1Knes4ZWhYuvNWc3di8b942etxpKfHWfZJyHYff/mzkmgtBB2zMos2LXgQ -uTc2Qp5zu5l6bcTJ0OBHo8AQO3P04VLqZMNmVFW+pjAqQyq1jA1phT2nc93daWAw -NYQg0VRaJ8jabtHZOkG7q7ndBDkxcWUTOlTvi07YY2TcVoWIHGeqMmNQ03YuwtYZ -ZX/cbLzkOtGrhnHWOeXCKwvsYL76laojAtjieket8Zk1CP4lh6rXGAI2X8hWD1Ti -H4LvOfWdAjOyOZzXpElAly9qLqa5wLw9K4RSi49UAzjrUtHDkh8TfKsg/noRkAGx -cpvzqcCuQ/rPz6W/oTi0NRmlg2ePl79STbVbtW0F7vhzCjGxU1daWnCZhzR5ZV2W -fLzgxrd0BkWAyH7Xjb3nzXlStY8ICRFLORagxiPkDcJR1HbsmjVbSvGDZfdlFZhc -wE4/E/2xHTgaLWLa029dF585tAKUJ7J5UfkL5FoaUhQK0gbD1OCDdZ0zXqOmpEnO -IQIDAQABo1MwUTAdBgNVHQ4EFgQUrEoRCs52owDJH2wWPXizze8FupowHwYDVR0j -BBgwFoAUrEoRCs52owDJH2wWPXizze8FupowDwYDVR0TAQH/BAUwAwEB/zANBgkq -hkiG9w0BAQsFAAOCAgEAMsqyBmMjzwGCq5z3XDU5S6k05bLHxy0Gw5vTTO2REpdX -NmfMEXA8nvBFgPEG/4xIuiSfjnC1YQyf6zqFzO5W+SYW8LjZFXfZ5NKHQtq5/b+9 -909WNMDZF3T+YBhe9tUyhzoCAtgWPmkIWGyOvJg8qW+xQZy1ipo4+iqAPwox6INX -0raYHk+Ae0ERk+IWKkRHW6OY5vH1nCaWm5ZHXmQV7gLe3gh8VtzqxI4FRPvMc622 -3VuogmtemMHH0sBxMbzp+cP6OuP+Te4C+a2osNksPVlNwYdMMjdOgCIGHh8LTeKe -2oE9s2qnyIyBja9irYK3nA6Ifb+O2YDs9SjOrXED8qAmvv5NHLKBikXQTynSfwhh -shk3L/2qv2MjhV2Bu6iKj7mjbQiV/BNek410t4tFKEN4VUrdJ4MgFMBjmp7uiiW4 -y5MzKO1kIoM69+OIwFm1UvA6CQwdJRbcH4z1hWAUSIR2L5f2wmpTbCUUGtwM9X+n -3FUBDMNiLv/yj+Fk8bsPGyUiitQcigH255mqF8fnqJ52/ntLdolJjonD9y5Dxwg2 -02Bcmk6PIC6YiD28XMSUQnClwqDcadkzA6IjxU1ruV7EzcOpTzpGuwiMKRLWt3p0 -sr82zpL0NXOByb34KRrjgqMtIEd5UgaGVw8ytKP8TlOAfbX9as26M7SD6aR4uNY= ------END CERTIFICATE----- diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/ca_alternative.key b/canton/base/daml-tls/src/test/resources/test-certificates/ca_alternative.key deleted file mode 100644 index 69d8937b04..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/ca_alternative.key +++ /dev/null @@ -1,52 +0,0 @@ ------BEGIN PRIVATE KEY----- -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 -Nq3cDXPDkMfIHnGoTpP3wyRQxsPOLy8= ------END PRIVATE KEY----- diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/ca_alternative.pem b/canton/base/daml-tls/src/test/resources/test-certificates/ca_alternative.pem deleted file mode 100644 index 69d8937b04..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/ca_alternative.pem +++ /dev/null @@ -1,52 +0,0 @@ ------BEGIN PRIVATE KEY----- -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 -Nq3cDXPDkMfIHnGoTpP3wyRQxsPOLy8= ------END PRIVATE KEY----- diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/client-revoked.crt b/canton/base/daml-tls/src/test/resources/test-certificates/client-revoked.crt deleted file mode 100644 index ef0727252a..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/client-revoked.crt +++ /dev/null @@ -1,25 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIERDCCAiygAwIBAgICEAMwDQYJKoZIhvcNAQELBQAwFTETMBEGA1UEAwwKMC4w -LjAuMC5jYTAeFw0yNjAzMDkwOTA1NTFaFw00NjAzMDkwOTA1NTFaMCAxHjAcBgNV -BAMMFTAuMC4wLjAuY2xpZW50cmV2b2tlZDCCASIwDQYJKoZIhvcNAQEBBQADggEP -ADCCAQoCggEBAOVm8Z+wk+s5oB3Yuegve9H5YG+LDkTSqQsUMMx4XWpbVr7qGR+o -SxctNh4+6FNkPqsR4X7T/Wlh9EFAeQQwWJh2L4J5+Z3flnZXjt6MLS+ZlqJZwDhe -pFhSbjWmRuM/k55ikbMYhL6U4YgvMryjgrpLifu7QyNBdcM0JIPTWw8gflVFSoI4 -h+a+oEza1Z7e8wiFbe13HHjtZAcyy/4Ph+HSrMoPcIsY0xBFXiMCgkQMgO7HbmQC -eApJbLdvHVkIxk2WLt8oTgIf7EimW757QMjUIUnZ1EjHKTz3yJlNfbEdCKQVU7bK -i3wWa23zITG7xdel9EbXDuXPa4wtRX4hgd0CAwEAAaOBkjCBjzAaBgNVHREEEzAR -gglsb2NhbGhvc3SHBH8AAAEwMQYIKwYBBQUHAQEEJTAjMCEGCCsGAQUFBzABhhVo -dHRwOi8vMTI3LjAuMC4xOjI1NjAwHQYDVR0OBBYEFEMKaEm7poVdDX4c7LzcvpKa -+4zSMB8GA1UdIwQYMBaAFBEL3EI+jE/dGBOgOgrzwHWA55vPMA0GCSqGSIb3DQEB -CwUAA4ICAQA6pY1k96L/MF2sSnempg/XLH+T3UxIrSkQ0XprWKJnltQekEt8PRdr -PwA8v7fcLhpYV6PGNEucY/SwJf3sbtl/MdmmPJ/rjxI4DRA4T3XzDIYuLKi6nymu -1Usx4VdhITx6l/oLvTbgvbphjY+oUVSlwOEDHoHGWKY3WXph8et75sZDGV4KEBvD -Vds/vCznI2FfA9NoEpfzc5s+LYdw/iwJHC2z6QR8M8yetF+r8/95Tp+On6cXDHpF -Lj05HhRjgLjvQVIHL0g3W9X8krV+zJTbn9OijqJTjGIvFYuhgjdqQtL3mjMbhahP -6uaDIBotrqK+4oAu54E1u+LagacmU3p4PyBn7hnCJwo7HagqtMb4/zuTVewxVVlF -XQIlb+C0flHtynRKKRdMq0uNBaK7EwLIFNC8Cnp9UcLEH+bym3EtuKP2iepfTfCo -4C9c4PrweNS/VZdlpM3SdX6igz1nPlf+WonUJC2XyyR+Q75Veyp3VtatdFNYrDNT -QNKQVT+UYt+adogctUt+Ko8Rqoe08OtbWHjNvnrg8p3Rm1iPBI32RjkwCTPpen2r -G0dBGq1PRweeFVsHtoo2oo3LhPBlRnDX+irzrObOB3UFFFYPyYIXrviqsYDLpHKI -5eWrtjVvDABsDsOv9Cqv7vtyUOQhvcDUrcEQ/Wjr8kEp7+01EGbzxw== ------END CERTIFICATE----- diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/client-revoked.csr b/canton/base/daml-tls/src/test/resources/test-certificates/client-revoked.csr deleted file mode 100644 index f355e61383..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/client-revoked.csr +++ /dev/null @@ -1,16 +0,0 @@ ------BEGIN CERTIFICATE REQUEST----- -MIICkjCCAXoCAQAwIDEeMBwGA1UEAwwVMC4wLjAuMC5jbGllbnRyZXZva2VkMIIB -IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5Wbxn7CT6zmgHdi56C970flg -b4sORNKpCxQwzHhdaltWvuoZH6hLFy02Hj7oU2Q+qxHhftP9aWH0QUB5BDBYmHYv -gnn5nd+WdleO3owtL5mWolnAOF6kWFJuNaZG4z+TnmKRsxiEvpThiC8yvKOCukuJ -+7tDI0F1wzQkg9NbDyB+VUVKgjiH5r6gTNrVnt7zCIVt7XcceO1kBzLL/g+H4dKs -yg9wixjTEEVeIwKCRAyA7sduZAJ4Cklst28dWQjGTZYu3yhOAh/sSKZbvntAyNQh -SdnUSMcpPPfImU19sR0IpBVTtsqLfBZrbfMhMbvF16X0RtcO5c9rjC1FfiGB3QID -AQABoC0wKwYJKoZIhvcNAQkOMR4wHDAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8A -AAEwDQYJKoZIhvcNAQELBQADggEBAFfiu3aa4AfqaRhUGM2RwW8fTd24TiFlmus+ -buNtOmIJ4qDXkj8obCt/aJla3Dm0hVeYiv5dw/dpkwbpkj+AkbBoHgper0VeUFW2 -0u87uRuOjk4TSQuXk4mlPzkqdkl8mKsZNCw1MVGLAjal6MI8KSbPaWFUlm4t092I -AIoy1uMtk5josWPCT2MR/AUmZxNJo0GtJHOdhb+QK63NWBE4swxVDZLTHJTvhQvr -Bo/O+mfLAYBEKrZbgBjHYWv0nJqaBLEiU6FZx60Mdx2CAY7yAe62vj9IkRg8Bi7v -Yo5xxRTROJxBhxujT6dc5GIWuwecQh4AErZyS4F1DJJyye9kgIQ= ------END CERTIFICATE REQUEST----- diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/client-revoked.key b/canton/base/daml-tls/src/test/resources/test-certificates/client-revoked.key deleted file mode 100644 index db7b34eded..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/client-revoked.key +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDlZvGfsJPrOaAd -2LnoL3vR+WBviw5E0qkLFDDMeF1qW1a+6hkfqEsXLTYePuhTZD6rEeF+0/1pYfRB -QHkEMFiYdi+Cefmd35Z2V47ejC0vmZaiWcA4XqRYUm41pkbjP5OeYpGzGIS+lOGI -LzK8o4K6S4n7u0MjQXXDNCSD01sPIH5VRUqCOIfmvqBM2tWe3vMIhW3tdxx47WQH -Msv+D4fh0qzKD3CLGNMQRV4jAoJEDIDux25kAngKSWy3bx1ZCMZNli7fKE4CH+xI -plu+e0DI1CFJ2dRIxyk898iZTX2xHQikFVO2yot8Fmtt8yExu8XXpfRG1w7lz2uM -LUV+IYHdAgMBAAECggEAHOU1pDa6uxNcHsZQzHVGtHEj+4jZeEPMtS+K4gZ7Rc1R -hOS522n4Y2f3fCHSY8apuiSbcb1EG0USXjG/zI5GapXgcB/rGD60lh17Sn7/phJD -jhmeA7uwGcvwQ+O45CD6+CVUUunQtqzMJ7pCOs6hxdOEAuhYkZVKPxdPTieOPb5N -9gWLziiJjUPrbqKybA8lBSRZP1PKYlL/y4LVwRGR9ubdWnlheZvs3E4bOKe0s6hC -VFKQLBprKcOzUz4kgZP09l3cxLX97vwZ4DR9RjFOaKbWEVu1pTrY3RBKoLpjNo9O -cnbfhhoL1ccPptayPA/k1DTYIgN4ZtG8pLONkNmJgQKBgQD6hJvvFsPvn516bkHb -Kvx2C93UJ6oiHiTtDlIDrOClPe9OtyPL0tk2qTGAb8ZNxLaot4nHVwcnDz4D9KyY -0nR9I1nYdFo8bjrbgOZvWDYnapyNbFRQhE30HjwTsYteFJcrpRAqOHKPh5FdVA2A -EIfJKu5szHfabGvMF+ML5+oSYQKBgQDqbAtjzVEwTWkCzy5MBT/EtSlnHCKz5JYZ -LXQ75maE2MSgjVTGJoNTVReKQWzZ3tob7UgefIMDsbzSwoc7QRFudjglllpAb9PL -5OuSoYvBc6NxvyMddl8H6HVR8mz/q9OT8K7Tj7k1DYSrbPdNv0u21WoFW67Mc91k -BcozfvxY/QKBgGTeiDrYm8QcLLhjNLkesdCGLqeB4QsjwBKDNZqtPTbLnVPAgfVL -zJIpxfjFCL+/eRyt6hgNfp/Hj1zgY66U7rqERWTWVI8Ig0JmwGIMzqFIWAjJmZlh -7FFi6yWy0z7qY4P7Tmrxl48bMvi1I0pxXXsughnkqk2UYkMnSxA8LQ+BAoGAIArQ -q72ewVlzZxzmmSGGi96vYBaqb6hpE9vwrVda4AG5LrDiy4rsHHHfG9jBhi495d9T -IkemqNScmLpaPbExLsYd+pVIlNI0QwRxZsOKFNE3oA6eQCKz7qLJln/qgi/8ZZZa -bzSP/j5wyfXE/+2L7dvR+BWGGRAlTMy1zMAowgkCgYEAgmA/FesKg6tQny2BZLCX -paYC4dHN4jLPW+kJkgDjuGxS1G2+ybTnrOQbbV2LdpSELwIr9Wtb57avEv5QJGm9 -+MsiAMtQW0UcSyP01F8gazqZ2AIiwSdAYIbYmmhQIAFJCvIikFv7GEeuXWQdW2tJ -8FXgpq8mECwYMYRkIkBiRNY= ------END PRIVATE KEY----- diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/client-revoked.pem b/canton/base/daml-tls/src/test/resources/test-certificates/client-revoked.pem deleted file mode 100644 index db7b34eded..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/client-revoked.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDlZvGfsJPrOaAd -2LnoL3vR+WBviw5E0qkLFDDMeF1qW1a+6hkfqEsXLTYePuhTZD6rEeF+0/1pYfRB -QHkEMFiYdi+Cefmd35Z2V47ejC0vmZaiWcA4XqRYUm41pkbjP5OeYpGzGIS+lOGI -LzK8o4K6S4n7u0MjQXXDNCSD01sPIH5VRUqCOIfmvqBM2tWe3vMIhW3tdxx47WQH -Msv+D4fh0qzKD3CLGNMQRV4jAoJEDIDux25kAngKSWy3bx1ZCMZNli7fKE4CH+xI -plu+e0DI1CFJ2dRIxyk898iZTX2xHQikFVO2yot8Fmtt8yExu8XXpfRG1w7lz2uM -LUV+IYHdAgMBAAECggEAHOU1pDa6uxNcHsZQzHVGtHEj+4jZeEPMtS+K4gZ7Rc1R -hOS522n4Y2f3fCHSY8apuiSbcb1EG0USXjG/zI5GapXgcB/rGD60lh17Sn7/phJD -jhmeA7uwGcvwQ+O45CD6+CVUUunQtqzMJ7pCOs6hxdOEAuhYkZVKPxdPTieOPb5N -9gWLziiJjUPrbqKybA8lBSRZP1PKYlL/y4LVwRGR9ubdWnlheZvs3E4bOKe0s6hC -VFKQLBprKcOzUz4kgZP09l3cxLX97vwZ4DR9RjFOaKbWEVu1pTrY3RBKoLpjNo9O -cnbfhhoL1ccPptayPA/k1DTYIgN4ZtG8pLONkNmJgQKBgQD6hJvvFsPvn516bkHb -Kvx2C93UJ6oiHiTtDlIDrOClPe9OtyPL0tk2qTGAb8ZNxLaot4nHVwcnDz4D9KyY -0nR9I1nYdFo8bjrbgOZvWDYnapyNbFRQhE30HjwTsYteFJcrpRAqOHKPh5FdVA2A -EIfJKu5szHfabGvMF+ML5+oSYQKBgQDqbAtjzVEwTWkCzy5MBT/EtSlnHCKz5JYZ -LXQ75maE2MSgjVTGJoNTVReKQWzZ3tob7UgefIMDsbzSwoc7QRFudjglllpAb9PL -5OuSoYvBc6NxvyMddl8H6HVR8mz/q9OT8K7Tj7k1DYSrbPdNv0u21WoFW67Mc91k -BcozfvxY/QKBgGTeiDrYm8QcLLhjNLkesdCGLqeB4QsjwBKDNZqtPTbLnVPAgfVL -zJIpxfjFCL+/eRyt6hgNfp/Hj1zgY66U7rqERWTWVI8Ig0JmwGIMzqFIWAjJmZlh -7FFi6yWy0z7qY4P7Tmrxl48bMvi1I0pxXXsughnkqk2UYkMnSxA8LQ+BAoGAIArQ -q72ewVlzZxzmmSGGi96vYBaqb6hpE9vwrVda4AG5LrDiy4rsHHHfG9jBhi495d9T -IkemqNScmLpaPbExLsYd+pVIlNI0QwRxZsOKFNE3oA6eQCKz7qLJln/qgi/8ZZZa -bzSP/j5wyfXE/+2L7dvR+BWGGRAlTMy1zMAowgkCgYEAgmA/FesKg6tQny2BZLCX -paYC4dHN4jLPW+kJkgDjuGxS1G2+ybTnrOQbbV2LdpSELwIr9Wtb57avEv5QJGm9 -+MsiAMtQW0UcSyP01F8gazqZ2AIiwSdAYIbYmmhQIAFJCvIikFv7GEeuXWQdW2tJ -8FXgpq8mECwYMYRkIkBiRNY= ------END PRIVATE KEY----- diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/client.crt b/canton/base/daml-tls/src/test/resources/test-certificates/client.crt deleted file mode 100644 index 0099588a3d..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/client.crt +++ /dev/null @@ -1,31 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFPTCCAyWgAwIBAgICEAEwDQYJKoZIhvcNAQELBQAwFTETMBEGA1UEAwwKMC4w -LjAuMC5jYTAeFw0yNjAzMDkwOTA1NTBaFw00NjAzMDkwOTA1NTBaMBkxFzAVBgNV -BAMMDjAuMC4wLjAuY2xpZW50MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKC -AgEAoA/N31uYGYzWbUU1r+mwAt5l1BxLatW02co0Ky2VQfadolvYJVuLsQmkVufa -Fww5OwiQGgvGvDHoa38i+ve3bJTgvt60B6fZT7tTzaMPx27Y9MPezZfc1efVWzC2 -MNGE4RUaxMud8iEKPzKgOO0ZA/QuWnfVM+IqjzaC8U/5KE+QiqdyeYb6jd+GypRB -41FQcJQ6YHGpYQZIHlsIliGOrWb+Ny5T61uhdC6GIQD+x2BM2Oqzfw8RWp3kDdku -hAJ3fuQ0aPU7T5rARxtFwLG1HclSInTblinQW76Cksugyma6bwhgmsAAwArahSM8 -CfiL+yxoT7YAEDFmD6O4xiUDl1EApba9WPJ/vqgIyuEAAxXiciSko8TApKgNttjV -ZixWp4Xxlu3Smz46bmk8MHdHYip/z7nAHX9ovWFvyw+dgEeJf6/N2yHRMAk460Gg -Q9LREuxd35g0Yq6gbvwRwVY1Flt5LcFfDPlsk/VxA9rFsKBMbnj4Q6QIyGX8axAI -GID4QFsEbiXRb8mhVE8zQ3o0M1BHyhieDhKhkpQ4Rf+NsuPWGY3wjU+cfYgNUQ5O -JjGJSKi73UR9m3hBcOBhVvBCupdivETR8OM3cv1HlEFrLqMQvc3esjc5+R2/xNTF -0YNljd0vE0FaDbVAi1AkaHonvhL9joAOkqoQEUPtIqKOYOsCAwEAAaOBkjCBjzAa -BgNVHREEEzARgglsb2NhbGhvc3SHBH8AAAEwMQYIKwYBBQUHAQEEJTAjMCEGCCsG -AQUFBzABhhVodHRwOi8vMTI3LjAuMC4xOjI1NjAwHQYDVR0OBBYEFHG3iWviVDhy -ioqNyoeSVcp1L0OlMB8GA1UdIwQYMBaAFBEL3EI+jE/dGBOgOgrzwHWA55vPMA0G -CSqGSIb3DQEBCwUAA4ICAQBbIoHXS8dXvlOkSXUXkO3XiDiVCk+ZJmUkSD0rBbfX -WaFCX7aB/ktvhWgJ2kPMGEIahB6XfcY95weZNn8UDNsQv5YkraGOc7wHM31kYd0q -5rvN8YpJN3Kc2Cpe8KI3gnaSQJTYPO5CUvF6ZvwpiyOLlyviz+Mt6Dg+WjocUZ9p -KtNn/gNBROFFeCkKgiY/IAZQh0ik/nl0ctoJUKndhoy5opTwtwXT/T3hCkaT5/7J -J95jSJmif+5fxDvuk0suZa9vXN60SlvQ7DXXgiINv0s1kqji5I6vrfcRT7hPKmsi -GXvZTbJ7JvNM0+tLiNgHfLpVHab/2RpqS0UfRjIMqmbjJ1SQwLw238KObFrUuir0 -Z6vnzQYs5v1DFp1i2iQsJeSrZn2Q6upNauBiQeX0B1rUrHPH3VNMsh+bF5pksLc3 -B21qiwlzo21jxiEo5Mejrx5JlcQihwgkPWaK06mNBpccrUWDQj4ypDnn99S1/Irk -A1Ndobt/BPxVuGS9m8UQLxLbMnZutCdXryLiFG6CTXB9Ox8ZJDTy28GhPxQ/fmWi -AQaTJjQM4hpmUxMMO9r3PrQXM4WpD+z+ulMhJzGbocLhwwGNL6E1390SEzva0Qvb -yEATyTWWl0894P4rfgDHP/1GBBEULmG6EOOzYhd/ryGzN+FXq6+VJzGDi4WuGWKu -gw== ------END CERTIFICATE----- diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/client.csr b/canton/base/daml-tls/src/test/resources/test-certificates/client.csr deleted file mode 100644 index 73cebc34d0..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/client.csr +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN CERTIFICATE REQUEST----- -MIIEizCCAnMCAQAwGTEXMBUGA1UEAwwOMC4wLjAuMC5jbGllbnQwggIiMA0GCSqG -SIb3DQEBAQUAA4ICDwAwggIKAoICAQCgD83fW5gZjNZtRTWv6bAC3mXUHEtq1bTZ -yjQrLZVB9p2iW9glW4uxCaRW59oXDDk7CJAaC8a8MehrfyL697dslOC+3rQHp9lP -u1PNow/Hbtj0w97Nl9zV59VbMLYw0YThFRrEy53yIQo/MqA47RkD9C5ad9Uz4iqP -NoLxT/koT5CKp3J5hvqN34bKlEHjUVBwlDpgcalhBkgeWwiWIY6tZv43LlPrW6F0 -LoYhAP7HYEzY6rN/DxFaneQN2S6EAnd+5DRo9TtPmsBHG0XAsbUdyVIidNuWKdBb -voKSy6DKZrpvCGCawADACtqFIzwJ+Iv7LGhPtgAQMWYPo7jGJQOXUQCltr1Y8n++ -qAjK4QADFeJyJKSjxMCkqA222NVmLFanhfGW7dKbPjpuaTwwd0diKn/PucAdf2i9 -YW/LD52AR4l/r83bIdEwCTjrQaBD0tES7F3fmDRirqBu/BHBVjUWW3ktwV8M+WyT -9XED2sWwoExuePhDpAjIZfxrEAgYgPhAWwRuJdFvyaFUTzNDejQzUEfKGJ4OEqGS -lDhF/42y49YZjfCNT5x9iA1RDk4mMYlIqLvdRH2beEFw4GFW8EK6l2K8RNHw4zdy -/UeUQWsuoxC9zd6yNzn5Hb/E1MXRg2WN3S8TQVoNtUCLUCRoeie+Ev2OgA6SqhAR -Q+0ioo5g6wIDAQABoC0wKwYJKoZIhvcNAQkOMR4wHDAaBgNVHREEEzARgglsb2Nh -bGhvc3SHBH8AAAEwDQYJKoZIhvcNAQELBQADggIBACsEMFTNYGsXiq1rmdcLL92V -od1UbIzmO/BM8/Y0bSPHYGyTZqOGZ0TFKYt/PEKnaxs92R8qH/Razq+HNnsouQ8/ -LE/1mvUCW/xpfkQKzbGxOrjJwzyZRvech7LbaL9nPhxSoHxlff1fN+m3LlgAqjD7 -rGefpWwo/ReP3NvnXnMS0VBFYIy4DQ8j+LnGdedog9V6fNYJFfMbVJ9Xgr5+457G -fvFkAvdoJL7Lcg1En8cj1oFdw7FKWvHZF9FAEe2ks42FTNd4VT8TLUlWw2OZeZ+U -uQQlCEm57MIX1lW70COHHFiv1BVKi8waTsM+5ntCTQNgqf2bGDmItoMC8s1Q9n67 -80bas9SallUfbuBHF/SF320cJLBvCTGiCahqk2GGMaCr0QQF5A/RzH8qq/ynCzN1 -yv8HLLw5BiBCGuWge6CkYZK4A14zO6+NZyPBIfzmAGQX3XqtGbD5rv5BefXyHL4I -T1ADYAaBwMmrgIgZ4QnHhJx4mEqC8ipp+RNad8EL4DAbw71SurEeVKtmG4OANdk8 -df/ss6NGAqjSHJJnPDuJmFucsUwYfYSA/gmlTKeo+FlaxYzcmq0QqV2NFugz7yYv -iK5LVSeucVVzOaoUzXAZ6+PLRCc7Pb+bjA/MnV/9RCG1rHilgND5NY3Yo8I56uEq -jk4mnBygvdOPP9+tHGb8 ------END CERTIFICATE REQUEST----- diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/client.key b/canton/base/daml-tls/src/test/resources/test-certificates/client.key deleted file mode 100644 index e35e74dde5..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/client.key +++ /dev/null @@ -1,52 +0,0 @@ ------BEGIN PRIVATE KEY----- -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 -K/VKL1G/nMN+f5AYYVYQEbpGspWxqA== ------END PRIVATE KEY----- diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/client.pem b/canton/base/daml-tls/src/test/resources/test-certificates/client.pem deleted file mode 100644 index e35e74dde5..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/client.pem +++ /dev/null @@ -1,52 +0,0 @@ ------BEGIN PRIVATE KEY----- -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 -K/VKL1G/nMN+f5AYYVYQEbpGspWxqA== ------END PRIVATE KEY----- diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/index.txt b/canton/base/daml-tls/src/test/resources/test-certificates/index.txt deleted file mode 100644 index dd9513e956..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/index.txt +++ /dev/null @@ -1,4 +0,0 @@ -V 460309090549Z 1000 unknown /CN=0.0.0.0.server -V 460309090550Z 1001 unknown /CN=0.0.0.0.client -V 460309090551Z 1002 unknown /CN=ocsp.127.0.0.1 -R 460309090551Z 260309090551Z 1003 unknown /CN=0.0.0.0.clientrevoked diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/ocsp.crt b/canton/base/daml-tls/src/test/resources/test-certificates/ocsp.crt deleted file mode 100644 index 1e05194cb4..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/ocsp.crt +++ /dev/null @@ -1,30 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFHzCCAwegAwIBAgICEAIwDQYJKoZIhvcNAQELBQAwFTETMBEGA1UEAwwKMC4w -LjAuMC5jYTAeFw0yNjAzMDkwOTA1NTFaFw00NjAzMDkwOTA1NTFaMBkxFzAVBgNV -BAMMDm9jc3AuMTI3LjAuMC4xMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKC -AgEAnNiotR1w2EzZ5Mg+YOWgIzbeazaDn6EJL6nSWvPeMhv06auUAWvsxL0C7k00 -8dZBSmUqZtJ3RD794CKfy9G5XW1UmrnHuZi82agq0T/ZLoedO/1UMB0L32op+17h -GzmoBE1gJ4xBGdFnr+CkBnrBnY/dKiFZXYq6pDbgqen2nnYWd28KwGtp6XbcJg+U -ZBozumFA7RrXau818JQGIjHLe1ObUxWQgQzun1Vmmt1P7/SbtOr+hsbYNronJweA -zTtSwOYRIdX9v97qACb3biAWTO5ohLCoturYMcYu7uWP/sBpotEVAfETgDlHJJ1t -x2nZ/q252eyp9B8iDK1gZ1/zNMSCdLqLf3C+B99HZOsE3tmfx7t/903Bz0sjNoUk -cxyB/HVYrBxkLQEi1jFJ8ioEZil/4cAGmGGzH/+/YIKWUulL0QR2KCrreTB7XPma -qFQ3Ywle8MVNjL4u583usoSmI8dAZlhwtQ/a7PkiVZsYoalsWHlCGHghH2V+tNsu -uyO2Tigj0fHFSRBhgN6aqLUAHl/Or7G1eBGWKeRjFIk4Eq9m7f1hwuDqsTmI0tmq -1M6+csaYqUDFFvTPEXtAaIKu0rWmWaEjpCPL/aRfYYj9Gw9oDuVPiicBSmiqnkDW -zCiHTKNINRh2fMS2kIzClKfONWn5zCCONGTTVVkdn7oqr6kCAwEAAaN1MHMwCQYD -VR0TBAIwADAdBgNVHQ4EFgQUDYUoN4/EBPNk0g0464tU2Y/2eyMwHwYDVR0jBBgw -FoAUEQvcQj6MT90YE6A6CvPAdYDnm88wDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB -/wQMMAoGCCsGAQUFBwMJMA0GCSqGSIb3DQEBCwUAA4ICAQBC+oV7E8J9KzyvW3nf -/60KYn/p8qigKKSlrApcIGVrASCr63M8RBOTuLG/CQaexcuZru7OwV1smVjJ4KrK -f42WVW8G471YVRpOTNDiGGg/5M3LJQyt/9SdZFfxXQT6xKI3iP64RuyR4ybtwvoj -0J6RIB6GUVTP6lnIE2lqoP49bzZ0vQ+R/YlNdgDeIQWU2EzhuoThcbMIho8RCcP4 -MoOyAV+4BiM2TMtS2mrddmQWlO7CuulMHN+ukvV622ZXGUhsZbpV2qrNs21AcE48 -1zSLqlnaFWogJHDz235hg4oaAbtdThWM0WCQkbJ5/ihcHKmfZPswr/fsbcoP/PBb -7G+0VDsQ84RusCMW4+cIxIyhp1P2DPAJ3tM/TinTn5rIi7aNt/4iFDFTdmpKW29R -TZwfixOzQAZwj/lPmQPFt02LsUmuzFpz5NBoHyu7c8vMLBfsK6NwnPNrnmmEDq/m -8t5M8UWBa1f4MUppwel4VOgnsNlWPIOItPDnmFZsQW7BW419XJLuIEF64O9rAJTm -W+XJcBz5QuUM9nKx4TUPaYKxz7Jw6hZTRmrc0tStOvZjUzD4xtcQ/HfkFXCRV7KL -s0wjSy4+rjSuauPf7kFLb/urAbvVNkNSvLXOMrYUTwubaDkabQdeHbA3Exu7K65V -PX8TGvc72Mj3rdzVksqip80KBA== ------END CERTIFICATE----- diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/ocsp.csr b/canton/base/daml-tls/src/test/resources/test-certificates/ocsp.csr deleted file mode 100644 index 549fca9a75..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/ocsp.csr +++ /dev/null @@ -1,26 +0,0 @@ ------BEGIN CERTIFICATE REQUEST----- -MIIEXjCCAkYCAQAwGTEXMBUGA1UEAwwOb2NzcC4xMjcuMC4wLjEwggIiMA0GCSqG -SIb3DQEBAQUAA4ICDwAwggIKAoICAQCc2Ki1HXDYTNnkyD5g5aAjNt5rNoOfoQkv -qdJa894yG/Tpq5QBa+zEvQLuTTTx1kFKZSpm0ndEPv3gIp/L0bldbVSauce5mLzZ -qCrRP9kuh507/VQwHQvfain7XuEbOagETWAnjEEZ0Wev4KQGesGdj90qIVldirqk -NuCp6faedhZ3bwrAa2npdtwmD5RkGjO6YUDtGtdq7zXwlAYiMct7U5tTFZCBDO6f -VWaa3U/v9Ju06v6Gxtg2uicnB4DNO1LA5hEh1f2/3uoAJvduIBZM7miEsKi26tgx -xi7u5Y/+wGmi0RUB8ROAOUcknW3Hadn+rbnZ7Kn0HyIMrWBnX/M0xIJ0uot/cL4H -30dk6wTe2Z/Hu3/3TcHPSyM2hSRzHIH8dVisHGQtASLWMUnyKgRmKX/hwAaYYbMf -/79ggpZS6UvRBHYoKut5MHtc+ZqoVDdjCV7wxU2Mvi7nze6yhKYjx0BmWHC1D9rs -+SJVmxihqWxYeUIYeCEfZX602y67I7ZOKCPR8cVJEGGA3pqotQAeX86vsbV4EZYp -5GMUiTgSr2bt/WHC4OqxOYjS2arUzr5yxpipQMUW9M8Re0Bogq7StaZZoSOkI8v9 -pF9hiP0bD2gO5U+KJwFKaKqeQNbMKIdMo0g1GHZ8xLaQjMKUp841afnMII40ZNNV -WR2fuiqvqQIDAQABoAAwDQYJKoZIhvcNAQELBQADggIBABNVAQIWTUzG8GvYOPvl -9e0PgRDJ3juh1Xw81VqVYoSLZ5m7AxPgEQ/DpSrzhHMue5nyjCwI8ARMmJ81IXB4 -vE2HeXy8Q/dU79eN+tZpy39Ku5p6UMwTyR7Kd2sgQB5HsNjA4ydMS0WpBuN4gTJX -hn/12Fjy/cFfeA+cCyO3PZN8dKI79CVV7Z4X8FiIVFnUxKPxFgGVli8pkYOPnpUs -TeeIOgIGgicnqmegJlSxvsNU69pJbTLqDQT3vntxiSlsvt4ISEnoFYGXx7amO7Is -oEiak+wVMmiHxMDRCcttVxEurG73hDUAsu91spZvXspIgKotq4TsZraTxSK634uf -V+4ZvYiFFNGptJfw+0u8qe9nD4xXt4jDdWZCGCdizk3Ly+7bmqYkdg9LWAS1mRap -Bo0HUVXiOctj5jd7t2t7XWLKhAaZuUH3ckFLCFWEuAmHgkRvzFzl1Z/FUh+AgwHs -JaB8gYHsO6b+JgNhDyrEo8dYygW6GTGKtjMb9pXUCxHkcputxxCnV6MY/Sn2iF3M -KZzJJumtvVN7siuBPmM86xMiK6lKLy+3PVO2GshstMz7+s0ixKUWt9wbTdFHY+aJ -jZjAmg6L6GipGNWO9aUvC2RLlUq5SiFtsItz5gvBlb2c9DCAtF7w2mAeDkr9P433 -mMfsWMlmiifna0CpZS6PIdBf ------END CERTIFICATE REQUEST----- diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/ocsp.key.pem b/canton/base/daml-tls/src/test/resources/test-certificates/ocsp.key.pem deleted file mode 100644 index d5504e683f..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/ocsp.key.pem +++ /dev/null @@ -1,52 +0,0 @@ ------BEGIN PRIVATE KEY----- -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 -0eQ1uoTEB9a+nFYybjrKuWkXqzVtDw== ------END PRIVATE KEY----- diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/server.crt b/canton/base/daml-tls/src/test/resources/test-certificates/server.crt deleted file mode 100644 index 7cc82aff9b..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/server.crt +++ /dev/null @@ -1,31 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFPTCCAyWgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwFTETMBEGA1UEAwwKMC4w -LjAuMC5jYTAeFw0yNjAzMDkwOTA1NDlaFw00NjAzMDkwOTA1NDlaMBkxFzAVBgNV -BAMMDjAuMC4wLjAuc2VydmVyMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKC -AgEAqMiaoKUIV5JGtOWWXv1EzYoscsq9NG9TjtQWU5+tZpziEw1A6Ynk/z5EV7JQ -48s0vHpNvjxvXCU9qfJ98FkA7C7duaOKog3NVxRmMUBeqsWEjPJ/tDujconCK++1 -ta3x0HadZzUuoJo0sY53jKRzjG7baIWzNxSHGMcFl+CBlvPDqGwKdJUBMz5x+E9L -MBV4PmRY1+HAP8qA+CxZ/20yCVwwZoSMKQfm0cpbw+fZl+OwHag9AovfE1hb5ase -62U4fa/1Hi95J7UHEKnod6OKeLtl/GcipFhUa+zJcbVRL8YxnHlkXyccQOlF/LSL -TN4XmdArXQmfs/2DoCXNs1E4cLSavrYfiqXEZAmn1ZEWOPzaGflIQFssnHUl0ujD -QxiORHLL3C3uPskW5q07j7Zc0WzhlLYXi/et651zGObXvAPzoD9bW02Cyjgjotiz -IzNfhwbwitAMnYzUZNlChWMUucW6XKZ1jutXkn1B9wbrysjyFOHy3KDRAY6dxaFY -jSnLMUAO66k8NOFZMZS1a1EFicoCPxsksy76jNR1Fqd/Hukhme/7lQF/6OAk6rex -gLb8o2/5oqYGoa7LExOXXGJyg0lIDZTvWAI6yuOpB9Dn1cBfWjHSWsgXTRkMRUdK -WWh6P00ohvMu5MW651QqRYMJs0TrTUSPKq87yz6hm6EJ6pMCAwEAAaOBkjCBjzAa -BgNVHREEEzARgglsb2NhbGhvc3SHBH8AAAEwMQYIKwYBBQUHAQEEJTAjMCEGCCsG -AQUFBzABhhVodHRwOi8vMTI3LjAuMC4xOjI1NjAwHQYDVR0OBBYEFLHq2VPY2pw/ -ZEg8d2+K5OpzP7zzMB8GA1UdIwQYMBaAFBEL3EI+jE/dGBOgOgrzwHWA55vPMA0G -CSqGSIb3DQEBCwUAA4ICAQBEC3Ziaslu4qQi/67tA4/cDVzJv5f0deDZ2DiVc5pn -DUZ+JMxArpHnL5aNZKvJ+/lQZC0pkoMoJzdaypRUhe8gsX7wn3jd6UJ175/B0u1q -6d3/kD02uKD2EVYTXAfYHCfWXdAD+AETeJENvOzz3OEg2LLTFlllmiMPnZHumnKR -O2x7giX72ixd/q/Or327svzqp/rMPWuMe0Sg4M0J6oSu6v+Rr0iY2pWT7VTG3E8v -cpVQgXmmIfImLcTfKlhQK46PTG2JV+kLu4t1S/zr3xVpxTrOc2vDDGIk2xiJMWhl -ds4t6RWkF9w/7ENNhhs0iWEq3Q1p+rTHnXxQx73xf8yUrH5NbrBR5vn2t9DMBelz -K+oKBdA3dJWr1RJz8yk5xX9b1zub8FvyMZEZ0N2iNmFCk5baDzMmyRWVO/NIOBy3 -pwOhtqdR4tHBOX8eq+/1EdRs7wLdfR7UnzH+WwqtqFCpT0iEX6Q1RWx/VcFjEIFW -Qah6J/NNlNKKZZrbzltxQTmZhiuI7JHA1mQNtFEm5V7S1rQm2KSAD2ijVFcrd4ti -GVORnaddkez+P8OlVfy37se0bVtVNAe5zWH0zfIkgDjedqXDQOrr3pMrtp/sF9km -am9VdStdKxHXWiuEivk5CFCUP1ZQkhlSFc3G+/3mpPag9sRNsdffjq/4Rni+bWHT -kg== ------END CERTIFICATE----- diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/server.csr b/canton/base/daml-tls/src/test/resources/test-certificates/server.csr deleted file mode 100644 index e9a3b57680..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/server.csr +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN CERTIFICATE REQUEST----- -MIIEizCCAnMCAQAwGTEXMBUGA1UEAwwOMC4wLjAuMC5zZXJ2ZXIwggIiMA0GCSqG -SIb3DQEBAQUAA4ICDwAwggIKAoICAQCoyJqgpQhXkka05ZZe/UTNiixyyr00b1OO -1BZTn61mnOITDUDpieT/PkRXslDjyzS8ek2+PG9cJT2p8n3wWQDsLt25o4qiDc1X -FGYxQF6qxYSM8n+0O6NyicIr77W1rfHQdp1nNS6gmjSxjneMpHOMbttohbM3FIcY -xwWX4IGW88OobAp0lQEzPnH4T0swFXg+ZFjX4cA/yoD4LFn/bTIJXDBmhIwpB+bR -ylvD59mX47AdqD0Ci98TWFvlqx7rZTh9r/UeL3kntQcQqeh3o4p4u2X8ZyKkWFRr -7MlxtVEvxjGceWRfJxxA6UX8tItM3heZ0CtdCZ+z/YOgJc2zUThwtJq+th+KpcRk -CafVkRY4/NoZ+UhAWyycdSXS6MNDGI5EcsvcLe4+yRbmrTuPtlzRbOGUtheL963r -nXMY5te8A/OgP1tbTYLKOCOi2LMjM1+HBvCK0AydjNRk2UKFYxS5xbpcpnWO61eS -fUH3BuvKyPIU4fLcoNEBjp3FoViNKcsxQA7rqTw04VkxlLVrUQWJygI/GySzLvqM -1HUWp38e6SGZ7/uVAX/o4CTqt7GAtvyjb/mipgahrssTE5dcYnKDSUgNlO9YAjrK -46kH0OfVwF9aMdJayBdNGQxFR0pZaHo/TSiG8y7kxbrnVCpFgwmzROtNRI8qrzvL -PqGboQnqkwIDAQABoC0wKwYJKoZIhvcNAQkOMR4wHDAaBgNVHREEEzARgglsb2Nh -bGhvc3SHBH8AAAEwDQYJKoZIhvcNAQELBQADggIBAHp7IZPzcIuUAYuayqd0nyTO -e/nh63RlAW4buR9rJfbMpt9YKqommoz6AAt+cBRdr62xnSAksaMX6OwaFXoYhZ6O -0fb5KoQ8GqTgo802yU5HA487zsEpZ+TByBti0rOF8nLEmtAqgRazxjpPmRIV8GUi -BXNumzMui/8v28KB43Vqum7LE0tS+W2x+h8ab5c8BwR0ctoqGsXDYrIDEVp09nFA -0AROemGbqMDJEkyHWGfuRWYUXWQS0Vhf1pZY8FwSSLtbjIVckezw5x57Hg1/Wyl3 -Z4awZs2KywSXw1v9Q8w3dJqTMNn07qnrN1xe7Y0w0wF4Ab7AAtGz5VVFElav1nqh -9OwO2TQRbrqUovEZMbwc0AhCUFCs4nS62lM0CeHppBmJbvG5WooJKN+7T7fbz2k4 -4WO5SqGlr3esbzob3cm4sYS/Sip1MieTMAPoOpptg7ig59D8y9yiZfYSSMQs2h34 -QP8AG52emcc7qZ7hcujZYdQyC2NDA1WiYxsArGNR+cccznnI7ir61NQN5oxpbPHu -8s8wxOPduj1OgV9EPC9FvlVUse9dNXpsnMherq7CKD0qdODwv3mIi/lYrkdImd/r -kU3FUK2h090SE4+NNrBZLhqmV7CDYQZbWvYgJAijfJJ1F6suCPV/hvTUV6f4LncV -C8yNip6UYnuE9bHKnKgn ------END CERTIFICATE REQUEST----- diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/server.key b/canton/base/daml-tls/src/test/resources/test-certificates/server.key deleted file mode 100644 index 1c7641a2dc..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/server.key +++ /dev/null @@ -1,52 +0,0 @@ ------BEGIN PRIVATE KEY----- -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 -i1mipiuno3v+EVRtyKuDVDuWG/S+0w== ------END PRIVATE KEY----- diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/server.pem b/canton/base/daml-tls/src/test/resources/test-certificates/server.pem deleted file mode 100644 index 1c7641a2dc..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/server.pem +++ /dev/null @@ -1,52 +0,0 @@ ------BEGIN PRIVATE KEY----- -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 -i1mipiuno3v+EVRtyKuDVDuWG/S+0w== ------END PRIVATE KEY----- diff --git a/canton/base/daml-tls/src/test/resources/test-certificates/server.pem.enc b/canton/base/daml-tls/src/test/resources/test-certificates/server.pem.enc deleted file mode 100644 index 3bddeb1d27..0000000000 --- a/canton/base/daml-tls/src/test/resources/test-certificates/server.pem.enc +++ /dev/null @@ -1,69 +0,0 @@ -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 -JAozyXqPiZBHGvXlz5takA== diff --git a/canton/base/daml-tls/src/test/scala/com/daml/tls/ProtocolDisablerTest.scala b/canton/base/daml-tls/src/test/scala/com/daml/tls/ProtocolDisablerTest.scala deleted file mode 100644 index 8d678e8130..0000000000 --- a/canton/base/daml-tls/src/test/scala/com/daml/tls/ProtocolDisablerTest.scala +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.tls - -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AnyWordSpec - -import java.security.Security -import scala.collection.mutable - -class ProtocolDisablerTest extends AnyWordSpec with Matchers { - - "properties updater" should { - - // given - val hello = "SSLv2Hello" - val noHello = "SSLv3, RC4, MD5withRSA, DH keySize < 1024, EC keySize < 224" - val helloAtTheEnd = s"$noHello, $hello" - val helloAtTheBeginning = s"$hello, $noHello" - val state: mutable.Map[String, String] = mutable.Map.empty - val updater = PropertiesUpdater(state(_), state(_) = _) - - "add element if it doesn't exist yet" in { - // when - state("without") = noHello - updater.appendToProperty("without", hello) - // then - state("without") shouldBe helloAtTheEnd - } - - "do nothing if the element exists already" in { - // when - state("atTheEnd") = helloAtTheEnd - state("atTheBeginning") = helloAtTheBeginning - updater.appendToProperty("atTheEnd", hello) - updater.appendToProperty("atTheBeginning", hello) - // then - state("atTheEnd") shouldBe helloAtTheEnd - state("atTheBeginning") shouldBe helloAtTheBeginning - } - - "add the element if it already exists as substring of another element" in { - // when - val helloAsSubstring = - "SSLv3, RC4, MD5withRSA, DH keySize < 1024, EC keySize < 224, SSLv2Hello Hello" - state("asSubstring") = helloAsSubstring - updater.appendToProperty("asSubstring", hello) - // then - state("asSubstring") shouldBe s"$helloAsSubstring, $hello" - } - } - - "protocol disabler" should { - // given - def disabledProtocols = Security.getProperty(ProtocolDisabler.disabledAlgorithmsProperty) - "disable hello protocol if it is enabled" in { - // when - val startingValue = disabledProtocols - val expected = ProtocolDisabler.sslV2Protocol.r.findFirstIn(startingValue) match { - case None => - ProtocolDisabler.disableSSLv2Hello() - s"$startingValue, ${ProtocolDisabler.sslV2Protocol}" - case Some(_) => - startingValue - } - val endingValue = disabledProtocols - - // then - endingValue shouldBe expected - } - } - -} diff --git a/canton/base/errors/src/main/scala/com/digitalasset/base/error/Alarm.scala b/canton/base/errors/src/main/scala/com/digitalasset/base/error/Alarm.scala deleted file mode 100644 index b669afbaf3..0000000000 --- a/canton/base/errors/src/main/scala/com/digitalasset/base/error/Alarm.scala +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error - -import com.digitalasset.base.error.ErrorCategory.{SecurityAlert, UnredactedSecurityAlert} -import io.grpc.StatusRuntimeException - -/** An alarm indicates that a different node is behaving maliciously. Alarms include situations - * where an attack has been mitigated successfully. Alarms are security relevant events that need - * to be logged in a standardized way for monitoring and auditing. - */ -abstract class AlarmErrorCode(id: String, redactDetails: Boolean = true)(implicit - parent: ErrorClass -) extends ErrorCode(id, if (redactDetails) SecurityAlert else UnredactedSecurityAlert) { - implicit override val code: AlarmErrorCode = this - -} - -trait BaseAlarm extends BaseError { - override def code: AlarmErrorCode - - override def context: Map[String, String] = - super.context ++ BaseError.extractContext(this) - - /** Report the alarm to the logger. */ - def report()(implicit logger: BaseErrorLogger): Unit = logWithContext() - - /** Reports the alarm to the logger. - * - * @return - * this alarm - */ - def reported()(implicit logger: BaseErrorLogger): this.type = { - report() - this - } - - def asGrpcError(implicit logger: BaseErrorLogger): StatusRuntimeException = - ErrorCode.asGrpcError(this)(logger) -} - -abstract class Alarm( - override val cause: String, - override val throwableO: Option[Throwable] = None, -)(implicit - override val code: AlarmErrorCode -) extends BaseAlarm diff --git a/canton/base/errors/src/main/scala/com/digitalasset/base/error/BaseError.scala b/canton/base/errors/src/main/scala/com/digitalasset/base/error/BaseError.scala deleted file mode 100644 index a4e69bf19d..0000000000 --- a/canton/base/errors/src/main/scala/com/digitalasset/base/error/BaseError.scala +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error - -import com.google.rpc.status.Status as ProtoStatus -import org.slf4j.event.Level - -/** The main error interface for everything that should be logged and notified. - * - * There are two ways to communicate an error to the user: write it into a log or send it as a - * string. In most cases, we'll do both: log the error appropriately locally and communicate it to - * the user by failing the API call with an error string. - */ -trait BaseError extends LocationMixin { - - /** Optional override for the log level used when this error is logged. If defined, this level - * will be used instead of the default level associated with the error code. - */ - def overrideLogLevel: Option[Level] = None - - /** The error code, usually passed in as implicit where the error class is defined */ - def code: ErrorCode - - /** A human readable string indicating the error */ - def cause: String - - /** An optional argument to log exceptions - * - * If you want to log an exception as part of your error, then use the following example: - * - * {{{ - * object MyCode extends ErrorCode(id="SUPER_DUPER_ERROR") { - * case class MyError(someString: String, throwable: Throwable) extends SomeInternalError( - * cause = "Something failed with an exception bla", - * throwableO = Some(throwable) - * ) - * } - * }}} - * - * NOTE: This throwable's details are not included the exception communicated to the gRPC clients - * so if you want them communicated, you need to explicitly add them to the e.g. context map or - * cause string. - */ - def throwableO: Option[Throwable] = None - - /** The context (declared fields) of this error - */ - def context: Map[String, String] = Map() - - /** The resources related to this error - * - * We return the set of resources via com.google.rpc.ResourceInfo. Override this method in order - * to return resource information via com.google.rpc.Status - */ - def resources: Seq[(ErrorResource, String)] = Seq() - - def logWithContext(extra: Map[String, String] = Map())(implicit - errorLoggingContext: BaseErrorLogger - ): Unit = - errorLoggingContext.logError(this, extra, overrideLogLevel) - - /** Returns retryability information of this particular error - * - * In some cases, error instances would like to provide custom retry intervals. This can be - * achieved by locally overriding this method. - * - * Do not use this to change the contract of the error categories. Non-retryable errors shouldn't - * be made retryable. Only use it for adjusting the retry intervals. - */ - def retryable: Option[ErrorCategoryRetry] = code.category.retryable - - /** Controls whether a `definite_answer` error detail is added to the gRPC status code */ - def definiteAnswerO: Option[Boolean] = None - - def rpcStatus()(implicit - loggingContext: BaseErrorLogger - ): com.google.rpc.status.Status = - ProtoStatus.fromJavaProto(ErrorCode.asGrpcStatus(this)) - -} - -trait LocationMixin { - - /** Contains the location where the error has been created. */ - val location: Option[String] = { - val stack = Thread.currentThread().getStackTrace - val thisClassName = this.getClass.getName - val idx = stack.indexWhere(_.getClassName == thisClassName) - if (idx != -1 && (idx + 1) < stack.length) { - val stackTraceElement = stack(idx + 1) - Some(s"${stackTraceElement.getFileName}:${stackTraceElement.getLineNumber}") - } else None - } -} - -object BaseError { - object RedactedMessage { - val Prefix = "An error occurred. Please contact the operator and inquire about the request" - private val regex = s"$Prefix (.+) with tid (.+)".r - - def apply(correlationId: Option[String] = None, traceId: Option[String] = None): String = - s"$Prefix ${correlationId.getOrElse("")} with tid ${traceId - .getOrElse("")}" - - def unapply(msg: String): Option[(Option[String], Option[String])] = - msg match { - case regex(corrIdO, tIdO) => - val checkedCorrelationId = Option(corrIdO).filter(_ != "") - val checkedTraceId = Option(tIdO).filter(_ != "") - Some(checkedCorrelationId -> checkedTraceId) - case _ => None - } - } - - val isRedactedMessage: String => Boolean = - _.startsWith(RedactedMessage.Prefix) - - private val ignoreFields = - Set( - "cause", - "throwable", - "loggingContext", - "definiteAnswer", - "representativeProtocolVersion", - "companionObj", - "orderedErrors", - ) - - def extractContext[D](obj: D): Map[String, String] = - obj.getClass.getDeclaredFields - .filterNot(x => ignoreFields.contains(x.getName) || x.getName.startsWith("_")) - .map { field => - field.setAccessible(true) - (field.getName, field.get(obj).toString) - } - .toMap -} diff --git a/canton/base/errors/src/main/scala/com/digitalasset/base/error/BaseErrorLogger.scala b/canton/base/errors/src/main/scala/com/digitalasset/base/error/BaseErrorLogger.scala deleted file mode 100644 index dd570a1e97..0000000000 --- a/canton/base/errors/src/main/scala/com/digitalasset/base/error/BaseErrorLogger.scala +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error - -import org.slf4j.event.Level - -trait BaseErrorLogger { - def logError( - err: BaseError, - extra: Map[String, String], - overrideLogLevel: Option[Level] = None, - ): Unit - def correlationId: Option[String] - def traceId: Option[String] - def properties: Map[String, String] - - // Error construction warnings/errors - def warn(message: => String): Unit - def error(message: => String, throwable: Throwable): Unit -} -object NoBaseLogging - extends NoBaseLogging(properties = Map.empty, correlationId = None, traceId = None) {} - -class NoBaseLogging( - val properties: Map[String, String], - val correlationId: Option[String], - val traceId: Option[String] = None, -) extends BaseErrorLogger { - override def logError( - err: BaseError, - extra: Map[String, String], - overrideLogLevel: Option[Level] = None, - ): Unit = () - override def warn(message: => String): Unit = () - override def error(message: => String, throwable: Throwable): Unit = () -} diff --git a/canton/base/errors/src/main/scala/com/digitalasset/base/error/DamlError.scala b/canton/base/errors/src/main/scala/com/digitalasset/base/error/DamlError.scala deleted file mode 100644 index 46984becdf..0000000000 --- a/canton/base/errors/src/main/scala/com/digitalasset/base/error/DamlError.scala +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error - -import com.google.rpc.Status -import io.grpc.StatusRuntimeException - -abstract class ContextualizedDamlError( - override val cause: String, - override val throwableO: Option[Throwable] = None, - extraContext: Map[String, Any] = Map(), -)(implicit - override val code: ErrorCode, - val logger: BaseErrorLogger, -) extends BaseError - with RpcError - with LogOnCreation { - - // Automatically log the error on generation - override def logOnCreation: Boolean = true - - def logError(): Unit = logWithContext()(logger) - - def asGrpcStatus: Status = - ErrorCode.asGrpcStatus(this)(logger) - - def asGrpcError: StatusRuntimeException = - ErrorCode.asGrpcError(this)(logger) - - override def context: Map[String, String] = - super.context ++ extraContext.view.mapValues(_.toString) - - def correlationId: Option[String] = logger.correlationId - - def traceId: Option[String] = logger.traceId -} - -/** @param definiteAnswer - * Determines the value of the `definite_answer` key in the error details - */ -class DamlErrorWithDefiniteAnswer( - override val cause: String, - override val throwableO: Option[Throwable] = None, - val definiteAnswer: Boolean = false, - extraContext: Map[String, Any] = Map(), -)(implicit - override val code: ErrorCode, - loggingContext: BaseErrorLogger, -) extends ContextualizedDamlError( - cause = cause, - throwableO = throwableO, - extraContext = extraContext, - ) { - - final override def definiteAnswerO: Option[Boolean] = Some(definiteAnswer) - -} diff --git a/canton/base/errors/src/main/scala/com/digitalasset/base/error/ErrorCategory.scala b/canton/base/errors/src/main/scala/com/digitalasset/base/error/ErrorCategory.scala deleted file mode 100644 index 7509e48628..0000000000 --- a/canton/base/errors/src/main/scala/com/digitalasset/base/error/ErrorCategory.scala +++ /dev/null @@ -1,449 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error - -import io.grpc.Status.Code -import org.slf4j.event.Level - -import scala.concurrent.duration.* - -/** Standard error categories - * - * Ideally, all products will return errors with appropriate error codes. Every such error code is - * associated with an error category that defines how the error will appear in the log file and on - * the api level. - */ -sealed trait ErrorCategory extends Product with Serializable { - - /** The Grpc code use to signal this error (in case it is signalled via API) */ - def grpcCode: Option[Code] - - /** The log level used to log this error on the server side */ - def logLevel: Level - - /** Default retryability information for this error category */ - def retryable: Option[ErrorCategoryRetry] - - /** If true, error details should not be emitted on the api, typically for security reasons. Must - * be true for authentication errors, permission errors and internal errors by OWASP - * recommendations. - */ - def redactDetails: Boolean - - /** Int representation of this error category */ - def asInt: Int - - /** Rank used to order severity (internal only) */ - def rank: Int -} - -object ErrorCategory { - - val all: Seq[ErrorCategory] = - Seq( - TransientServerFailure, - ContentionOnSharedResources, - DeadlineExceededRequestStateUnknown, - SystemInternalAssumptionViolated, - AuthInterceptorInvalidAuthenticationCredentials, - InsufficientPermission, - // UnredactedSecurityAlert comes before SecurityAlert with the same int representation so that - // find by int returns the unredacted one (redacted security alerts will never be found because they are redacted!) - UnredactedSecurityAlert, - SecurityAlert, - InvalidIndependentOfSystemState, - InvalidGivenCurrentSystemStateOther, - InvalidGivenCurrentSystemStateResourceExists, - InvalidGivenCurrentSystemStateResourceMissing, - InvalidGivenCurrentSystemStateSeekAfterEnd, - BackgroundProcessDegradationWarning, - InternalUnsupportedOperation, - ) - - def fromInt(ii: Int): Option[ErrorCategory] = all.find(_.asInt == ii) - - abstract class ErrorCategoryImpl( - val grpcCode: Option[Code], - val logLevel: Level, - val retryable: Option[ErrorCategoryRetry], - val redactDetails: Boolean, - val asInt: Int, - val rank: Int, - ) - - /** Service is temporarily unavailable - */ - @Description("""One of the services required to process the request was not available. - |The request might or might not have been processed, as the server aborted the request while it was being processed. - |Note that for requests that change the state of the system, this error may be returned - |even if the request has completed successfully.""") - @RetryStrategy("Retry quickly in load balancer.") - @Resolution( - "Expectation: transient failure that should be handled by retrying the request with appropriate backoff." - ) - case object TransientServerFailure - extends ErrorCategoryImpl( - grpcCode = Some(Code.UNAVAILABLE), - logLevel = Level.INFO, - retryable = Some(ErrorCategoryRetry(1.second)), - redactDetails = false, - asInt = 1, - rank = 3, - ) - with ErrorCategory - - /** Failure due to contention on some resources - */ - @Description( - """The request could not be processed due to shared processing resources - |(e.g. locks or rate limits that replenish quickly) being occupied. - |If the resource is known (i.e. locked contract), it will be included as a resource info. (Not known - |resource contentions are e.g. overloaded networks where we just observe timeouts, but can’t pin-point the cause).""" - ) - @RetryStrategy("Retry quickly (indefinitely or limited), but do not retry in load balancer.") - @Resolution("""Expectation: this is processing-flow level contention that should be handled by - |retrying the request with appropriate backoff.""") - case object ContentionOnSharedResources - extends ErrorCategoryImpl( - grpcCode = Some(Code.ABORTED), - logLevel = Level.INFO, - retryable = Some(ErrorCategoryRetry(1.second)), - redactDetails = false, - asInt = 2, - rank = 3, - ) - with ErrorCategory - - /** Request completion not observed within a pre-defined window - */ - @Description("""The request might not have been processed, as its deadline expired before its - |completion was signalled. Note that for requests that change the state of the - |system, this error may be returned even if the request has completed successfully. - |Note that known and well-defined timeouts are signalled as - |[[ContentionOnSharedResources]], while this category indicates that the - |state of the request is unknown.""") - @RetryStrategy("Retry for a limited number of times with deduplication.") - @Resolution( - """Expectation: the deadline might have been exceeded due to transient resource - |congestion or due to a timeout in the request processing pipeline being too low. - |The transient errors might be solved by the application retrying. - |The non-transient errors will require operator intervention to change the timeouts.""" - ) - case object DeadlineExceededRequestStateUnknown - extends ErrorCategoryImpl( - grpcCode = Some(Code.DEADLINE_EXCEEDED), - logLevel = Level.INFO, - retryable = Some(ErrorCategoryRetry(1.second)), - redactDetails = false, - asInt = 3, - rank = 3, - ) - with ErrorCategory - - /** Some internal error - */ - @Description( - "Request processing failed due to a violation of system internal invariants. This error is exposed on the API with grpc-status INTERNAL without any details for security reasons" - ) - @RetryStrategy("Retry after operator intervention.") - @Resolution( - """Expectation: this is due to a bug in the implementation or data corruption in the systems databases. - |Resolution will require operator intervention, and potentially vendor support.""" - ) - case object SystemInternalAssumptionViolated - extends ErrorCategoryImpl( - grpcCode = Some(Code.INTERNAL), - logLevel = Level.ERROR, - retryable = None, - redactDetails = true, - asInt = 4, - rank = 1, - ) - with ErrorCategory - - @Description( - """A potential attack or a faulty peer component has been detected. - |This error is exposed on the API with grpc-status INVALID_ARGUMENT with unredacted details.""" - ) - @RetryStrategy("Errors in this category are non-retryable.") - @Resolution( - """Expectation: this can be a severe issue that requires operator attention or intervention, and - |potentially vendor support. It means that the system has detected invalid information that can be attributed - |to either faulty or malicious manipulation of data coming from a peer source.""" - ) - case object UnredactedSecurityAlert - extends ErrorCategoryImpl( - grpcCode = Some(Code.INVALID_ARGUMENT), - logLevel = Level.WARN, - retryable = None, - redactDetails = false, - asInt = 5, - rank = 2, - ) - with ErrorCategory - - @Description( - """A potential attack or a faulty peer component has been detected. - |This error is exposed on the API with grpc-status INVALID_ARGUMENT without any details for security reasons.""" - ) - @RetryStrategy("Errors in this category are non-retryable.") - @Resolution( - """Expectation: this can be a severe issue that requires operator attention or intervention, and - |potentially vendor support. It means that the system has detected invalid information that can be attributed - |to either faulty or malicious manipulation of data coming from a peer source.""" - ) - case object SecurityAlert - extends ErrorCategoryImpl( - grpcCode = Some(Code.INVALID_ARGUMENT), - logLevel = Level.WARN, - retryable = None, - redactDetails = true, - asInt = 5, - rank = 2, - ) - with ErrorCategory - - /** Client is not authenticated properly - */ - @Description( - """The request does not have valid authentication credentials for the operation. This error is exposed on the API with grpc-status UNAUTHENTICATED without any details for security reasons""" - ) - @RetryStrategy("""Retry after application operator intervention.""") - @Resolution( - """Expectation: this is an application bug, application misconfiguration or ledger-level - |misconfiguration. Resolution requires application and/or ledger operator intervention.""" - ) - case object AuthInterceptorInvalidAuthenticationCredentials - extends ErrorCategoryImpl( - grpcCode = Some(Code.UNAUTHENTICATED), - logLevel = Level.WARN, - retryable = None, - redactDetails = true, - asInt = 6, - rank = 2, - ) - with ErrorCategory - - /** Client does not have appropriate permissions - */ - @Description( - """The caller does not have permission to execute the specified operation. This error is exposed on the API with grpc-status PERMISSION_DENIED without any details for security reasons""" - ) - @RetryStrategy("""Retry after application operator intervention.""") - @Resolution( - """Expectation: this is an application bug or application misconfiguration. Resolution requires - |application operator intervention.""" - ) - case object InsufficientPermission - extends ErrorCategoryImpl( - grpcCode = Some(Code.PERMISSION_DENIED), - logLevel = Level.WARN, - retryable = None, - redactDetails = true, - asInt = 7, - rank = 2, - ) - with ErrorCategory - - /** A request which is never going to be valid - */ - @Description("""The request is invalid independent of the state of the system.""") - @RetryStrategy("""Retry after application operator intervention.""") - @Resolution( - """Expectation: this is an application bug or ledger-level misconfiguration (e.g. request size limits). - |Resolution requires application and/or ledger operator intervention.""" - ) - case object InvalidIndependentOfSystemState - extends ErrorCategoryImpl( - grpcCode = Some(Code.INVALID_ARGUMENT), - logLevel = Level.INFO, - retryable = None, - redactDetails = false, - asInt = 8, - rank = 3, - ) - with ErrorCategory - - /** A failure due to the current system state - */ - @Description( - """The mutable state of the system does not satisfy the preconditions required to execute the request. - |We consider the whole Daml ledger including ledger config, parties, packages, users and command - |deduplication to be mutable system state. Thus all Daml interpretation errors are reported - |as this error or one of its specializations.""" - ) - @RetryStrategy("""Retry after application operator intervention.""") - @Resolution("""ALREADY_EXISTS and NOT_FOUND are special cases for the existence and non-existence of well-defined - |entities within the system state; e.g., a .dalf package, contracts ids, contract keys, or a - |transaction at an offset. OUT_OF_RANGE is a special case for reading past a range. Violations of the - |Daml ledger model always result in these kinds of errors. Expectation: this is due to - |application-level bugs, misconfiguration or contention on application-visible resources; and might be - |resolved by retrying later, or after changing the state of the system. Handling these errors requires - |an application-specific strategy and/or operator intervention.""") - case object InvalidGivenCurrentSystemStateOther - extends ErrorCategoryImpl( - grpcCode = Some(Code.FAILED_PRECONDITION), - logLevel = Level.INFO, - retryable = None, - redactDetails = false, - asInt = 9, - rank = 3, - ) - with ErrorCategory - - /** A failure due to a resource already existing in the current system state - */ - @Description("""Special type of InvalidGivenCurrentSystemState referring to a well-defined - |resource.""") - @RetryStrategy( - """Inspect resource failure and retry after resource failure has been resolved (depends on type of - |resource and application).""" - ) - @Resolution("""Same as [[InvalidGivenCurrentSystemStateOther]].""") - case object InvalidGivenCurrentSystemStateResourceExists - extends ErrorCategoryImpl( - grpcCode = Some(Code.ALREADY_EXISTS), - logLevel = Level.INFO, - retryable = None, - redactDetails = false, - asInt = 10, - rank = 3, - ) - with ErrorCategory - - /** A failure due to a resource not existing in the current system state - */ - @Description("""Special type of InvalidGivenCurrentSystemState referring to a well-defined - |resource.""") - @RetryStrategy( - """Inspect resource failure and retry after resource failure has been resolved (depends on type of - |resource and application).""" - ) - @Resolution("""Same as [[InvalidGivenCurrentSystemStateOther]].""") - case object InvalidGivenCurrentSystemStateResourceMissing - extends ErrorCategoryImpl( - grpcCode = Some(Code.NOT_FOUND), - logLevel = Level.INFO, - retryable = None, - redactDetails = false, - asInt = 11, - rank = 3, - ) - with ErrorCategory - - /** A failure due to requesting a resource using a parameter value that falls beyond the current - * upper bound (or 'end') defined by the system's state. - */ - @Description( - """The request failed because it resulted in an operation beyond the current upper bound (or 'end') - |defined by the system's state. For example, supplying a ledger offset which is larger than the current - |ledger end, or a record time that is in the future.""" - ) - @RetryStrategy( - """Wait and retry. For example, retry a limited number of times with potentially increasing backoff. - |Hint: Inspect the retryable value of the error code to decide on the particular retry duration.""" - ) - @Resolution( - """Resolution can occur naturally as the system progresses. The requested operation may become valid - |eventually once the system's state has advanced further. For example, when new ledger entries are added. - |If however the situation does not resolve as expected, operator intervention may be required.""" - ) - case object InvalidGivenCurrentSystemStateSeekAfterEnd - extends ErrorCategoryImpl( - grpcCode = Some(Code.OUT_OF_RANGE), - logLevel = Level.INFO, - retryable = Some(ErrorCategoryRetry(1.second)), - redactDetails = false, - asInt = 12, - rank = 3, - ) - with ErrorCategory - - /** Background daemon notifying about observed degradation - */ - @Description( - """This error category is used internally to signal to the system operator an internal degradation.""" - ) - @RetryStrategy("""Not an API error, therefore not retryable.""") - @Resolution("""Inspect details of the specific error for more information.""") - case object BackgroundProcessDegradationWarning - extends ErrorCategoryImpl( - grpcCode = None, // should not be used on the API level - logLevel = Level.WARN, - retryable = None, - redactDetails = false, - asInt = 13, - rank = 2, - ) - with ErrorCategory - - @Description( - """This error category is used to signal that an unimplemented code-path has been triggered by a client or participant operator request. This error is exposed on the API with grpc-status UNIMPLEMENTED without any details for security reasons""" - ) - @RetryStrategy("""Errors in this category are non-retryable.""") - @Resolution( - """This error is caused by a ledger-level misconfiguration or by an implementation bug. - |Resolution requires node operator intervention.""" - ) - case object InternalUnsupportedOperation - extends ErrorCategoryImpl( - grpcCode = Some(Code.UNIMPLEMENTED), - logLevel = Level.ERROR, - retryable = None, - redactDetails = true, - asInt = 14, - rank = 1, - ) - with ErrorCategory - - implicit val orderingErrorType: Ordering[ErrorCategory] = Ordering.by[ErrorCategory, Int](_.rank) - - /** Special error category that isn't included in [[ErrorCategory.all]] and is meant purely for - * overriding the error category text in the generated error code documentation. It is expected - * that this category is replaced by an existing category from ErrorCategory.all in the error - * code implementation of [[RpcError.code]] when the actual error is instantiated. as such, all - * methods in this Error Category are errors - */ - final case class OverrideDocStringErrorCategory(overrideText: String) extends ErrorCategory { - private def unimplemented = throw new IllegalArgumentException( - "Attempted to use OverrideDocStringErrorCategory in thrown error. This should be replaced at construction" - ) - override def grpcCode: Option[Code] = unimplemented - override def logLevel: Level = unimplemented - override def retryable: Option[ErrorCategoryRetry] = unimplemented - override def redactDetails: Boolean = unimplemented - override def asInt: Int = unimplemented - override def rank: Int = unimplemented - } - - /** Generic error category class meant to be used as a data container for information deserialized - * from gRPC statuses (see [[com.digitalasset.base.error.utils.DecodedCantonError]]). - * - * Note: Do NOT use this class for adding error category information to error code instances but - * instead re-use existing ones or define new ones (see [[ErrorCategory.all]]) - */ - final case class GenericErrorCategory( - override val grpcCode: Option[Code], - override val logLevel: Level, - override val retryable: Option[ErrorCategoryRetry], - override val redactDetails: Boolean, - override val asInt: Int, - override val rank: Int, - ) extends ErrorCategoryImpl( - grpcCode = grpcCode, - logLevel = logLevel, - retryable = retryable, - redactDetails = redactDetails, - asInt = asInt, - rank = rank, - ) - with ErrorCategory -} - -/** Default retryability information - * - * Every error category has a default retryability classification. An error code may adjust the - * retry duration. - */ -final case class ErrorCategoryRetry(duration: FiniteDuration) diff --git a/canton/base/errors/src/main/scala/com/digitalasset/base/error/ErrorClass.scala b/canton/base/errors/src/main/scala/com/digitalasset/base/error/ErrorClass.scala deleted file mode 100644 index b6b0052465..0000000000 --- a/canton/base/errors/src/main/scala/com/digitalasset/base/error/ErrorClass.scala +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error - -/** A component of [[ErrorClass]] - * - * @param docName - * The name that will appear in the generated documentation for the grouping. - * @param fullClassName - * Full class name of the corresponding [[ErrorGroup]]. - */ -final case class Grouping( - docName: String, - fullClassName: String, -) { - require( - docName.trim.nonEmpty, - s"Grouping.docName must be non empty and must contain not only whitespace characters, but was: |$docName|!", - ) -} - -/** Used to hierarchically structure error codes in the official documentation. - */ -final case class ErrorClass(groupings: List[Grouping]) { - def extend(grouping: Grouping): ErrorClass = - ErrorClass(groupings :+ grouping) -} - -object ErrorClass { - def root(): ErrorClass = ErrorClass(Nil) -} diff --git a/canton/base/errors/src/main/scala/com/digitalasset/base/error/ErrorCode.scala b/canton/base/errors/src/main/scala/com/digitalasset/base/error/ErrorCode.scala deleted file mode 100644 index a1e25c9d67..0000000000 --- a/canton/base/errors/src/main/scala/com/digitalasset/base/error/ErrorCode.scala +++ /dev/null @@ -1,210 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error - -import com.google.rpc.Status -import io.grpc.Status.Code -import io.grpc.StatusRuntimeException -import io.grpc.protobuf.StatusProto -import org.slf4j.event.Level - -import scala.annotation.StaticAnnotation -import scala.util.control.{NoStackTrace, NonFatal} - -import SerializableErrorCodeComponents.validateTraceIdAndCorrelationId - -/** Error Code Definition - * - * We want to support our users and our developers with good error codes. Therefore, every error - * that an API returns should refer to a documented error code and provide some context - * information. - * - * Every error code is uniquely identified using an error-id of max 63 CAPITALIZED_WITH_UNDERSCORES - * characters - * - * Errors are organised according to ErrorGroups. And we separate the error code definition (using - * a singleton by virtue of using objects to express the nested hierarchy) and the actual error. - * - * Please note that there is some implicit argument passing involved in the example below: - * - * {{{ - * object SyncServiceErrors extends ParticipantErrorGroup { - * object ConnectionErrors extends ErrorGroup { - * object SynchronizerUnavailable extends ErrorCode(id="SYNCHRONIZER_UNAVAILABLE", ..) { - * case class ActualError(someContext: Val) extends BaseError with SyncServiceError - * // this error will actually be referring to the same error code! - * case class OtherError(otherContext: Val) extends BaseError with SyncServiceError - * } - * } - * object HandshakeErrors extends ErrorGroup { - * ... - * } - * } - * }}} - */ -abstract class ErrorCode(val id: String, val category: ErrorCategory)(implicit - val parent: ErrorClass -) { - - require(id.nonEmpty, "error-id must be non empty") - require(id.length < 64, s"error-id is too long: $id") - require(id.forall(c => c.isUpper || c == '_' || c.isDigit), s"Invalid characters in error-id $id") - - implicit val code: ErrorCode = this - - /** The machine readable error code string, uniquely identifiable by the error id, error category - * and correlation id. e.g. NOT_CONNECTED_TO_ANY_SYNCHRONIZER(2,ABC234) - */ - def codeStr(correlationId: Option[String]): String = - ErrorCodeMsg.codeStr(code.id, category.asInt, correlationId) - - /** @return - * message including error category id, error code id, correlation id and cause - */ - def toMsg(cause: => String, correlationId: Option[String], limit: Option[Int]): String = { - val truncatedCause = limit match { - case Some(maxLength) if (cause.length > maxLength) => cause.take(maxLength) + "..." - case _ => cause - } - ErrorCodeMsg(id, category.asInt, correlationId, truncatedCause) - } - - /** Log level of the error code - * - * Generally, the log level is defined by the error category. In rare cases, it might be - * overridden by the error code. - */ - def logLevel: Level = category.logLevel - - /** True if this error may appear on the API */ - protected def exposedViaApi: Boolean = category.grpcCode.nonEmpty - - /** The error conveyance doc string provides a statement about the form this error will be - * returned to the user - */ - def errorConveyanceDocString: Option[String] = { - val loggedAs = s"This error is logged with log-level $logLevel on the server side" - val apiLevel = (category.grpcCode, exposedViaApi) match { - case (Some(grpcCode), true) => - if (category.redactDetails) - s". It is exposed on the API with grpc-status $grpcCode without any details for security reasons." - else - s" and exposed on the API with grpc-status $grpcCode including a detailed error message." - case _ => "." - } - Some(loggedAs ++ apiLevel) - } -} - -object ErrorCodeMsg { - private val ErrorCodeMsgRegex = """([A-Z_]+)\((\d+),(.+?)\): (.*)""".r - - def apply( - errorCodeId: String, - errorCategoryInt: Int, - maybeCorrelationId: Option[String], - cause: String, - ): String = - s"${codeStr(errorCodeId, errorCategoryInt, maybeCorrelationId)}: $cause" - - def codeStr( - errorCodeId: String, - errorCategoryInt: Int, - maybeCorrelationId: Option[String], - ): String = s"$errorCodeId($errorCategoryInt,${maybeCorrelationId.getOrElse("0").take(8)})" - - def extract(errorCodeMsg: String): Either[String, (String, Int, String, String)] = - errorCodeMsg match { - case ErrorCodeMsgRegex(errorCodeId, errorCategoryIdIntAsString, corrId, cause) => - Right((errorCodeId, errorCategoryIdIntAsString.toInt, corrId, cause)) - case other => Left(s"Could not extract error code constituents from $other") - } -} - -object ErrorCode { - - /** Maximum size (in bytes) of the [[com.google.rpc.Status]] proto that a self-service error code - * can be serialized into. - * - * We choose this value with the following considerations: - * - The serialized error Status proto is packed into a [[io.grpc.Metadata]] together with the - * error description, which is then enriched with additional gRPC-internal entries and - * converted into HTTP2 headers for transmission. - * - The default maximum gRPC metadata size is 8KB (for both clients and servers). We MUST - * ensure that we don't exceed this value for error-returning gRPC metadata, otherwise - * INTERNAL errors may be reported on both the client and server. - * - The error description is packed twice in the serialized metadata (see - * [[ErrorCode.asGrpcError]] and how it creates a [[io.grpc.StatusRuntimeException]]). - * - * Conservatively we allow a buffer of > 3KB for gRPC and gRPC->HTTP2 internals overhead. - * (considering a [[MaxErrorContentBytes]] maximum Status proto size and limit the cause - * description size). - * - * Note: instead of increasing this value, consider limiting better the error contents. - */ - val MaxErrorContentBytes = 4096 - - def asGrpcError(err: BaseError)(implicit - loggingContext: BaseErrorLogger - ): StatusRuntimeException = { - val status = asGrpcStatus(err)(loggingContext) - // Builder methods for metadata are not exposed, so going route via creating an exception - val e = StatusProto.toStatusRuntimeException(status) - // Stripping stacktrace - err match { - case loc: LogOnCreation if loc.logOnCreation => - new ErrorCode.LoggedApiException(e.getStatus, e.getTrailers) - case _ => - new ErrorCode.ApiException(e.getStatus, e.getTrailers) - } - } - - def asGrpcStatus(err: BaseError)(implicit loggingContext: BaseErrorLogger): Status = - asGrpcStatus(err, MaxErrorContentBytes) - - private[error] def asGrpcStatus(err: BaseError, maxSerializedErrorSize: Int)(implicit - loggingContext: BaseErrorLogger - ): Status = - try - SerializableErrorCodeComponents( - errorCode = err.code, - loggingContext = loggingContext, - rawCorrelationId = loggingContext.correlationId, - rawTraceId = loggingContext.traceId, - cause = err.cause, - definiteAnswer = err.definiteAnswerO, - errorResources = err.resources, - contextMap = err.context ++ loggingContext.properties, - retryableInfo = err.retryable.map(_.duration), - ).toStatusProto(maxSerializedErrorSize) - catch { - case NonFatal(e) => - val (traceId, correlationId) = - validateTraceIdAndCorrelationId(loggingContext.traceId, loggingContext.correlationId) - - loggingContext.error(s"Error building gRPC status for error $err", e) - com.google.rpc.Status - .newBuilder() - .setCode(Code.INTERNAL.value()) - .setMessage( - BaseError.RedactedMessage(correlationId = correlationId, traceId = traceId) - ) - .build() - } - - class ApiException(status: io.grpc.Status, metadata: io.grpc.Metadata) - extends StatusRuntimeException(status, metadata) - with NoStackTrace - - /** Exception that has already been logged. - */ - class LoggedApiException(status: io.grpc.Status, metadata: io.grpc.Metadata) - extends ApiException(status, metadata) -} - -// Use these annotations to add more information to the documentation for an error on the website -final case class Explanation(explanation: String) extends StaticAnnotation -final case class Resolution(resolution: String) extends StaticAnnotation -final case class Description(description: String) extends StaticAnnotation -final case class RetryStrategy(retryStrategy: String) extends StaticAnnotation diff --git a/canton/base/errors/src/main/scala/com/digitalasset/base/error/ErrorGroup.scala b/canton/base/errors/src/main/scala/com/digitalasset/base/error/ErrorGroup.scala deleted file mode 100644 index 1f92d91607..0000000000 --- a/canton/base/errors/src/main/scala/com/digitalasset/base/error/ErrorGroup.scala +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error - -abstract class ErrorGroup()(implicit parent: ErrorClass) { - private val simpleClassName: String = getClass.getSimpleName.replace("$", "") - val fullClassName: String = getClass.getName - - implicit val errorClass: ErrorClass = - parent.extend(Grouping(docName = simpleClassName, fullClassName = fullClassName)) -} diff --git a/canton/base/errors/src/main/scala/com/digitalasset/base/error/ErrorResource.scala b/canton/base/errors/src/main/scala/com/digitalasset/base/error/ErrorResource.scala deleted file mode 100644 index 3228f3ac05..0000000000 --- a/canton/base/errors/src/main/scala/com/digitalasset/base/error/ErrorResource.scala +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error - -/** Type of error resource - * - * Some errors are linked to a specific resource such as a contract id or a package id. In such - * cases, we include the resource identifier as part of the error message. This enum allows an - * error to provide identifiers of a resource - */ -final case class ErrorResource(asString: String) { - def nullable: ErrorResource = ErrorResource(s"NULLABLE_$asString") -} - -object ErrorResource { - lazy val ContractId: ErrorResource = ErrorResource("CONTRACT_ID") - lazy val ContractIds: ErrorResource = ErrorResource("CONTRACT_IDS") - lazy val ContractKey: ErrorResource = ErrorResource("CONTRACT_KEY") - lazy val ContractArg: ErrorResource = ErrorResource("CONTRACT_ARG") - lazy val CryptoValue: ErrorResource = ErrorResource("CRYPTO_VALUE") - lazy val TransactionId: ErrorResource = ErrorResource("TRANSACTION_ID") - lazy val UpdateId: ErrorResource = ErrorResource("UPDATE_ID") - lazy val DalfPackage: ErrorResource = ErrorResource("PACKAGE") - lazy val TemplateId: ErrorResource = ErrorResource("TEMPLATE_ID") - lazy val InterfaceId: ErrorResource = ErrorResource("INTERFACE_ID") - lazy val PackageName: ErrorResource = ErrorResource("PACKAGE_NAME") - lazy val CommandId: ErrorResource = ErrorResource("COMMAND_ID") - lazy val Party: ErrorResource = ErrorResource("PARTY") - lazy val Parties: ErrorResource = ErrorResource("PARTIES") - lazy val User: ErrorResource = ErrorResource("USER") - lazy val IdentityProviderConfig: ErrorResource = ErrorResource("IDENTITY_PROVIDER_CONFIG") - lazy val ContractKeyHash: ErrorResource = ErrorResource("CONTRACT_KEY_HASH") - lazy val ExceptionValue: ErrorResource = ErrorResource("EXCEPTION_VALUE") - lazy val ExceptionType: ErrorResource = ErrorResource("EXCEPTION_TYPE") - lazy val ExceptionText: ErrorResource = ErrorResource("EXCEPTION_TEXT") - lazy val DevErrorType: ErrorResource = ErrorResource("DEV_ERROR_TYPE") - lazy val SynchronizerId: ErrorResource = ErrorResource("SYNCHRONIZER_ID") - lazy val SynchronizerAlias: ErrorResource = ErrorResource("SYNCHRONIZER_ALIAS") - lazy val Offset: ErrorResource = ErrorResource("OFFSET") - lazy val ExpectedType: ErrorResource = ErrorResource("EXPECTED_TYPE") - lazy val FieldIndex: ErrorResource = ErrorResource("FIELD_INDEX") - - lazy val all: Seq[ErrorResource] = Seq( - CommandId, - ContractArg, - ContractId, - ContractIds, - ContractKey, - ContractKeyHash, - CryptoValue, - DalfPackage, - DevErrorType, - ExceptionText, - ExceptionType, - ExceptionValue, - ExpectedType, - FieldIndex, - IdentityProviderConfig, - InterfaceId, - Offset, - PackageName, - Parties, - Party, - SynchronizerAlias, - SynchronizerId, - TemplateId, - TransactionId, - UpdateId, - User, - ) - - def fromString(str: String): Option[ErrorResource] = str.split("NULLABLE_") match { - case Array("", resource) => - all.find(_.asString == resource).map(_.nullable) - case Array(resource) => - all.find(_.asString == resource) - case _ => None - } -} diff --git a/canton/base/errors/src/main/scala/com/digitalasset/base/error/GrpcStatuses.scala b/canton/base/errors/src/main/scala/com/digitalasset/base/error/GrpcStatuses.scala deleted file mode 100644 index 66ec1c88b7..0000000000 --- a/canton/base/errors/src/main/scala/com/digitalasset/base/error/GrpcStatuses.scala +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error - -import com.google.rpc.error_details.ErrorInfo -import com.google.rpc.status.Status as StatusProto - -import scala.util.Try - -object GrpcStatuses { - val DefiniteAnswerKey = "definite_answer" - val CompletionOffsetKey = "completion_offset" - - def isDefiniteAnswer(status: StatusProto): Boolean = - status.details.exists { any => - if (any.is(ErrorInfo.messageCompanion)) { - Try(any.unpack(ErrorInfo.messageCompanion)).toOption - .exists(isDefiniteAnswer) - } else { - false - } - } - - private def isDefiniteAnswer(errorInfo: ErrorInfo): Boolean = - errorInfo.metadata.get(DefiniteAnswerKey).exists(value => java.lang.Boolean.valueOf(value)) -} diff --git a/canton/base/errors/src/main/scala/com/digitalasset/base/error/LogOnCreation.scala b/canton/base/errors/src/main/scala/com/digitalasset/base/error/LogOnCreation.scala deleted file mode 100644 index 656a2f97ab..0000000000 --- a/canton/base/errors/src/main/scala/com/digitalasset/base/error/LogOnCreation.scala +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error - -/** Trait to log on creation */ -trait LogOnCreation { - def logOnCreation: Boolean = true - def logError(): Unit - if (logOnCreation) { - logError() - } -} diff --git a/canton/base/errors/src/main/scala/com/digitalasset/base/error/RpcError.scala b/canton/base/errors/src/main/scala/com/digitalasset/base/error/RpcError.scala deleted file mode 100644 index a3deb52cd2..0000000000 --- a/canton/base/errors/src/main/scala/com/digitalasset/base/error/RpcError.scala +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error - -import com.google.rpc.Status -import io.grpc.StatusRuntimeException - -trait RpcError { - - /** The error code, usually passed in as implicit where the error class is defined */ - def code: ErrorCode - - /** The context (declared fields) of this error */ - def context: Map[String, String] - - /** A human readable string indicating the error */ - def cause: String - - /** The resources related to this error */ - def resources: Seq[(ErrorResource, String)] - - /** The correlationId (e.g. submissionId) associated with the request that caused the error */ - def correlationId: Option[String] - - /** The traceId associated with the TraceContext at error creation */ - def traceId: Option[String] - - /** The gRPC status */ - def asGrpcStatus: Status - - /** The gRPC status encoded as a StatusRuntimeException */ - def asGrpcError: StatusRuntimeException - -} diff --git a/canton/base/errors/src/main/scala/com/digitalasset/base/error/SerializableErrorComponents.scala b/canton/base/errors/src/main/scala/com/digitalasset/base/error/SerializableErrorComponents.scala deleted file mode 100644 index 27e129fd19..0000000000 --- a/canton/base/errors/src/main/scala/com/digitalasset/base/error/SerializableErrorComponents.scala +++ /dev/null @@ -1,315 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error - -import com.digitalasset.base.error.utils.ErrorDetails -import io.grpc.Status.Code - -import scala.concurrent.duration.FiniteDuration -import scala.jdk.CollectionConverters.IterableHasAsJava -import scala.util.matching.Regex - -import NonSecuritySensitiveErrorCodeComponents.{ - MaxCauseLogLength, - stringsPackedSize, - truncateDetails, -} -import SerializableErrorCodeComponents.* - -object SerializableErrorCodeComponents { - private[error] val ValidMetadataKeyRegex: Regex = "[^(a-zA-Z0-9-_)]".r - private[error] val GrpcCodeBytes = 1 - // Each string is encoded additionally with a tag and a size value - // Hence, we add some overhead to account for them - private[error] val MaximumPerTagOverheadBytes = 5 - private[error] val ErrorInfoDetailPackingOverheadBytes = - ErrorDetails.ErrorInfoDetail("", Map.empty).toRpcAny.getSerializedSize - private[error] val ResourceInfoDetailPackingOverheadBytes = - ErrorDetails.ResourceInfoDetail("", "").toRpcAny.getSerializedSize - // By default, hex encoded correlation-ids and trace-ids have 32 characters - // Since we don't have any explicit enforcement, truncate everything above 256 to disallow error serialization issues - private[error] val MaxTraceIdCorrelationIdSize = 256 - - def apply( - errorCode: ErrorCode, - loggingContext: BaseErrorLogger, - rawTraceId: Option[String], - rawCorrelationId: Option[String], - // Next parameters are by-name to avoid unnecessary computation if the error is security sensitive - cause: => String, - definiteAnswer: => Option[Boolean], - errorResources: => Seq[(ErrorResource, String)], - contextMap: => Map[String, String], - retryableInfo: => Option[FiniteDuration], - ): SerializableErrorCodeComponents = { - val (traceId, correlationId) = - validateTraceIdAndCorrelationId(rawTraceId, rawCorrelationId)(loggingContext) - - if (errorCode.category.redactDetails) - SecuritySensitiveErrorCodeComponents( - grpcStatusCode = errorCode.category.grpcCode, - traceId = traceId, - correlationId = correlationId, - )(logger = loggingContext) - else - NonSecuritySensitiveErrorCodeComponents( - traceId = traceId, - correlationId = correlationId, - cause = cause, - errorCode = errorCode, - definiteAnswer = definiteAnswer, - errorResources = errorResources, - contextMap = contextMap ++ loggingContext.properties, - retryableInfo = retryableInfo, - )(loggingContext = loggingContext) - } - - private[error] def validateTraceIdAndCorrelationId( - rawTraceId: Option[String], - rawCorrelationId: Option[String], - )(implicit loggingContext: BaseErrorLogger) = { - val traceId = rawTraceId.map(tId => - truncateString( - tId, - MaxTraceIdCorrelationIdSize, - loggingContext.warn( - s"Trace-id $tId exceeded maximum allowed size of $MaxTraceIdCorrelationIdSize and has been truncated for gRPC error serialization" - ), - ) - ) - val correlationId = rawCorrelationId.map(cId => - truncateString( - cId, - MaxTraceIdCorrelationIdSize, - loggingContext.warn( - s"Correlation-id $cId exceeded maximum allowed size of $MaxTraceIdCorrelationIdSize and has been truncated for gRPC error serialization" - ), - ) - ) - (traceId, correlationId) - } - - private[error] def truncateString(v: String, maxSize: Int, onTruncate: => Unit = ()): String = - if (v.length > maxSize) { - onTruncate - s"${v.take(maxSize - 3)}..." - } else v -} - -sealed trait SerializableErrorCodeComponents { - def toStatusProto(maxSizeBytes: Int): com.google.rpc.Status -} - -private[error] final case class SecuritySensitiveErrorCodeComponents( - grpcStatusCode: Option[Code], - traceId: Option[String], - correlationId: Option[String], -)(logger: BaseErrorLogger) - extends SerializableErrorCodeComponents { - - override def toStatusProto(maxSizeBytes: Int): com.google.rpc.Status = - com.google.rpc.Status - .newBuilder() - .setCode( - grpcStatusCode - .getOrElse { - logger.warn("Missing grpc status code for security sensitive error") - Code.INTERNAL - } - .value() - ) - .setMessage(BaseError.RedactedMessage(correlationId, traceId)) - .addAllDetails( - correlationId - .orElse(traceId) - .map(ErrorDetails.RequestInfoDetail.apply) - .toList - .map(_.toRpcAny) - .asJava - ) - .build() -} - -private[error] final case class NonSecuritySensitiveErrorCodeComponents( - traceId: Option[String], - correlationId: Option[String], - cause: String, - errorCode: ErrorCode, - definiteAnswer: Option[Boolean], - errorResources: Seq[(ErrorResource, String)], - contextMap: Map[String, String], - retryableInfo: Option[FiniteDuration], -)(loggingContext: BaseErrorLogger) - extends SerializableErrorCodeComponents { - - /** Truncates and serializes the self-service error components into a [[com.google.rpc.Status]]. - * - * Truncation happens for both the error message and error details aiming to ensure that the - * maximum message size ([[NonSecuritySensitiveErrorCodeComponents.MaxCauseLogLength]]) and - * maximum total Status serialization size ([[ErrorCode.MaxErrorContentBytes]]) are respected. - */ - def toStatusProto(maxSizeBytes: Int): com.google.rpc.Status = { - val grpcStatusCode = validatedGrpcErrorCode(errorCode.category.grpcCode) - val errorCategoryContext = "category" -> errorCode.category.asInt.toString - val traceIdContext = traceId.map("tid" -> _) - val definiteAnswerContext = - definiteAnswer.map(value => GrpcStatuses.DefiniteAnswerKey -> value.toString) - - val retryInfoRpc = retryableInfo.map(ErrorDetails.RetryInfoDetail(_).toRpcAny) - val requestInfoRpc = - correlationId.orElse(traceId).map(ErrorDetails.RequestInfoDetail(_).toRpcAny) - val errorCodeId = errorCode.id - - val validatedMessage = errorCode.toMsg(cause, correlationId, limit = Some(MaxCauseLogLength)) - val mandatoryDetailsEncodedSize = - GrpcCodeBytes + MaximumPerTagOverheadBytes + // Grpc code size + its byte tag overhead - stringsPackedSize( - Seq(validatedMessage, errorCodeId, errorCategoryContext._1, errorCategoryContext._2) ++ - traceIdContext.map(v => Seq(v._1, v._2)).getOrElse(Seq.empty) ++ - definiteAnswerContext.map(v => Seq(v._1, v._2)).getOrElse(Seq.empty) - ) + - retryInfoRpc.fold(0)(_.getSerializedSize + MaximumPerTagOverheadBytes) + - requestInfoRpc.fold(0)(_.getSerializedSize + MaximumPerTagOverheadBytes) + - // overhead bytes for packing ErrorInfo - ErrorInfoDetailPackingOverheadBytes - - val bytesLeftForTruncateableDetails = maxSizeBytes - mandatoryDetailsEncodedSize - - // Truncate-able error details - val (truncatedContext, truncatedErrorResources) = - truncateDetails(contextMap, errorResources, bytesLeftForTruncateableDetails) - - val errorInfoDetail = ErrorDetails.ErrorInfoDetail( - errorCodeId = errorCodeId, - // For simplicity, all key-values added here have been accounted with tag overheads - // even though they are not tagged when serialized. - metadata = - (truncatedContext ++ definiteAnswerContext.toList ++ traceIdContext.toList :+ errorCategoryContext).toMap, - ) - - val resourceInfos = - truncatedErrorResources.view - .map(_.swap) - .map((ErrorDetails.ResourceInfoDetail.apply _).tupled) - .toList - - val allDetails = - Seq(errorInfoDetail.toRpcAny) ++ retryInfoRpc.toList ++ requestInfoRpc.toList ++ resourceInfos - .map(_.toRpcAny) - - // Build status - com.google.rpc.Status - .newBuilder() - .setCode(grpcStatusCode.value()) - .setMessage(validatedMessage) - .addAllDetails(allDetails.asJava) - .build() - } - - private def validatedGrpcErrorCode(grpcCode: Option[Code]): Code = - grpcCode.getOrElse { - loggingContext.warn(s"Passing non-grpc error via grpc ${errorCode.id} ") - Code.INTERNAL - } -} - -private[error] object NonSecuritySensitiveErrorCodeComponents { - - /** The maximum size (in characters) of the self-service error description, truncated for - * transport as part of a Status - */ - val MaxCauseLogLength = 512 - - private[error] def truncateDetails( - context: Map[String, String], - errResources: Seq[(ErrorResource, String)], - remainingBudgetBytes: Int, - ): (Seq[(String, String)], Seq[(String, String)]) = { - - val numberOfEntries = context.size + errResources.size - if (numberOfEntries == 0) (Seq.empty, Seq.empty) - else { - val remainingBudgetForEntries = remainingBudgetBytes - - // account for two tags per key-value pair - 2 * MaximumPerTagOverheadBytes * numberOfEntries - - val budgetForTruncatedResources = - (errResources.size.toLong * remainingBudgetForEntries.toLong) / numberOfEntries.toLong - - val budgetForTruncatedContext = remainingBudgetForEntries - budgetForTruncatedResources - - val truncatedErrorResources = truncateResources( - errResources.map { case (res, v) => res.asString -> v }, - budgetForTruncatedResources.toInt, - ) - - val truncatedContext = truncateContext(context.toSeq, budgetForTruncatedContext.toInt) - (truncatedContext, truncatedErrorResources) - } - } - - private[error] def truncateContext( - rawContextEntries: Seq[(String, String)], - maxBudgetBytes: Int, - ): Seq[(String, String)] = { - val raw: Seq[(String, String)] = rawContextEntries.view - // Make key gRPC compliant - .map { case (k, v) => ValidMetadataKeyRegex.replaceAllIn(k, "").take(63) -> v } - // Discard empty values - .filter(_._1.nonEmpty) - // Discard empty keys - .filter(_._2.nonEmpty) - .toSeq - .sortBy(v => v._1.length + v._2.length) - - val rawSize = raw.size - - raw.view.zipWithIndex - .foldLeft((Vector.empty[(String, String)], maxBudgetBytes)) { - case ((acc, free), ((k, v), idx)) => - // This value can be regarded as an "inverse" moving average, - // (i.e. computed on the remaining entries instead of the past ones). - // Since the series is sorted by the entries size, this value progresses strictly ascending - // which allows more aggressive truncation of the bigger/later entries of this series. - val maxSize = free / (rawSize - idx) - - val maybeNewEntry = if (k.length + v.length > maxSize) { - // We need at least 8 chars per (k,v) entry to get something meaningful after truncation - // since we might suffix each of the k,v strings with `...` (3 chars) - see truncateString - Option.when(maxSize > 7) { - val truncatedKey = truncateString(k, maxSize / 2) - truncatedKey -> truncateString(v, maxSize - truncatedKey.length) - } - } else Some(k -> v) - - maybeNewEntry - .map { case (k, v) => (k, v, encSize(k) + encSize(v)) } - // Check again that unexpected encoding did not lead to exceeding the budget - // (For simplicity, in truncateString, we assume 1-byte per string encoding) - .filter { case (_, _, encodedSize) => encodedSize <= maxSize } - .map { case (newK, newV, encodedSize) => (acc :+ (newK -> newV), free - encodedSize) } - .getOrElse((acc, free)) - } - ._1 - } - - private def truncateResources( - details: Seq[(String, String)], - remaining: Int, - ): Seq[(String, String)] = - details - .foldLeft(Vector.empty[(String, String)] -> remaining) { case ((acc, free), (k, v)) => - // Account for the resource being packed as a ResourceInfo - val newFree = - free - (encSize(k) + encSize(v) + ResourceInfoDetailPackingOverheadBytes) - if (newFree < 0) (acc, free) - else (acc :+ (k -> v), newFree) - } - ._1 - - private def stringsPackedSize(strings: Seq[String]): Int = - strings.map(encSize(_) + MaximumPerTagOverheadBytes).sum - - private def encSize(s: String): Int = s.getBytes("UTF-8").length -} diff --git a/canton/base/errors/src/main/scala/com/digitalasset/base/error/samples/Example.scala b/canton/base/errors/src/main/scala/com/digitalasset/base/error/samples/Example.scala deleted file mode 100644 index 3d9d29d349..0000000000 --- a/canton/base/errors/src/main/scala/com/digitalasset/base/error/samples/Example.scala +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error.samples - -import com.digitalasset.base.error.{BaseErrorLogger, ContextualizedDamlError, NoBaseLogging} - -import scala.concurrent.duration.* - -object DummmyServer { - - import com.digitalasset.base.error.{ - ErrorCategory, - ErrorCategoryRetry, - ErrorClass, - ErrorCode, - ErrorResource, - } - - object ErrorCodeFoo - extends ErrorCode(id = "MY_ERROR_CODE_ID", ErrorCategory.ContentionOnSharedResources)( - ErrorClass.root() - ) { - - implicit val errorLogger: BaseErrorLogger = new NoBaseLogging( - correlationId = Some("full-correlation-id-123456790"), - properties = Map.empty, - ) - - final case class Error(message: String) extends ContextualizedDamlError(cause = message) { - - override def resources: Seq[(ErrorResource, String)] = Seq( - ErrorResource.ContractId -> "someContractId" - ) - - override def retryable: Option[ErrorCategoryRetry] = Some( - ErrorCategoryRetry(123.second + 456.milliseconds) - ) - - override def context: Map[String, String] = Map("foo" -> "bar") - } - - } - - def serviceEndpointDummy(): Unit = - throw ErrorCodeFoo.Error("A user oriented message").asGrpcError - -} - -/** This shows how a user can handle error codes. In particular it shows how to extract useful - * information from the signalled exception with minimal library dependencies. - * - * NOTE: This class is given as an example in the official Daml documentation. If you change it - * here, change it also in the docs. - */ -object SampleClientSide { - - import com.google.rpc.ResourceInfo - import com.google.rpc.{ErrorInfo, RequestInfo, RetryInfo} - import io.grpc.StatusRuntimeException - import scala.jdk.CollectionConverters.* - - def example(): Unit = - try { - DummmyServer.serviceEndpointDummy() - } catch { - case e: StatusRuntimeException => - // Converting to a status object. - val status = io.grpc.protobuf.StatusProto.fromThrowable(e) - - // Extracting gRPC status code. - assert(status.getCode == io.grpc.Status.Code.ABORTED.value()) - assert(status.getCode == 10) - - // Extracting error message, both - // machine oriented part: "MY_ERROR_CODE_ID(2,full-cor):", - // and human oriented part: "A user oriented message". - assert(status.getMessage == "MY_ERROR_CODE_ID(2,full-cor): A user oriented message") - - // Getting all the details - val rawDetails: Seq[com.google.protobuf.Any] = status.getDetailsList.asScala.toSeq - - // Extracting error code id, error category id and optionally additional metadata. - assert { - rawDetails.collectFirst { - case any if any.is(classOf[ErrorInfo]) => - val v = any.unpack(classOf[ErrorInfo]) - assert(v.getReason == "MY_ERROR_CODE_ID") - assert(v.getMetadataMap.asScala.toMap == Map("category" -> "2", "foo" -> "bar")) - }.isDefined - } - - // Extracting full correlation id, if present. - assert { - rawDetails.collectFirst { - case any if any.is(classOf[RequestInfo]) => - val v = any.unpack(classOf[RequestInfo]) - assert(v.getRequestId == "full-correlation-id-123456790") - }.isDefined - } - - // Extracting retry information if the error is retryable. - assert { - rawDetails.collectFirst { - case any if any.is(classOf[RetryInfo]) => - val v = any.unpack(classOf[RetryInfo]) - assert(v.getRetryDelay.getSeconds == 123, v.getRetryDelay.getSeconds) - assert(v.getRetryDelay.getNanos == 456 * 1000 * 1000, v.getRetryDelay.getNanos) - }.isDefined - } - - // Extracting resource if the error pertains to some well defined resource. - assert { - rawDetails.collectFirst { - case any if any.is(classOf[ResourceInfo]) => - val v = any.unpack(classOf[ResourceInfo]) - assert(v.getResourceType == "CONTRACT_ID") - assert(v.getResourceName == "someContractId") - }.isDefined - } - } -} diff --git a/canton/base/errors/src/main/scala/com/digitalasset/base/error/utils/DecodedCantonError.scala b/canton/base/errors/src/main/scala/com/digitalasset/base/error/utils/DecodedCantonError.scala deleted file mode 100644 index c1b384e690..0000000000 --- a/canton/base/errors/src/main/scala/com/digitalasset/base/error/utils/DecodedCantonError.scala +++ /dev/null @@ -1,234 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error.utils - -import cats.implicits.toTraverseOps -import cats.syntax.either.* -import com.digitalasset.base.error.BaseError.RedactedMessage -import com.digitalasset.base.error.ErrorCategory.GenericErrorCategory -import com.digitalasset.base.error.{ - BaseError, - ErrorCategory, - ErrorCategoryRetry, - ErrorClass, - ErrorCode, - ErrorCodeMsg, - ErrorResource, - Grouping, - NoBaseLogging, -} -import com.google.common.annotations.VisibleForTesting -import com.google.protobuf.any -import com.google.rpc.error_details.{ErrorInfo, RequestInfo, ResourceInfo, RetryInfo} -import com.google.rpc.status.Status as RpcStatus -import io.grpc.{Status, StatusRuntimeException} -import org.slf4j.event.Level -import scalapb.{GeneratedMessage, GeneratedMessageCompanion} - -import scala.concurrent.duration.FiniteDuration -import scala.jdk.DurationConverters.* -import scala.util.Try - -/** Generic error class used for creating error instances from deserialized gRPC statuses that - * resulted from serialization of self-service error codes (children of [[BaseError]]). Its aim is - * to be used in client applications and components for simplifying programmatic inspection and/or - * enrichment of Canton errors received over-the-wire or from persistence. - * - * Note: Do NOT use this class for explicitly instantiating errors. Instead, use or create a - * fully-typed error instance. - */ -final case class DecodedCantonError( - code: ErrorCode, - cause: String, - correlationId: Option[String], - traceId: Option[String], - override val context: Map[String, String], - override val resources: Seq[(ErrorResource, String)], - override val definiteAnswerO: Option[Boolean] = None, -) extends BaseError { - def toRpcStatusWithForwardedRequestId: RpcStatus = super.rpcStatus()( - new NoBaseLogging(properties = Map.empty, correlationId = correlationId, traceId = traceId) - ) - - def retryIn: Option[FiniteDuration] = code.category.retryable.map(_.duration) - - def isRetryable: Boolean = retryIn.nonEmpty -} - -object DecodedCantonError { - - /** Deserializes a [[com.google.rpc.status.Status]] to [[DecodedCantonError]]. With the exception - * of throwables, all serialized error information is extracted, making this method an inverse of - * [[BaseError.rpcStatus]]. - */ - def fromGrpcStatus(status: RpcStatus): Either[String, DecodedCantonError] = { - val rawDetails = status.details - - val statusCode = Status.fromCodeValue(status.code).getCode - status.message match { - case RedactedMessage(correlationId, traceId) => - Right( - redactedError( - grpcCode = statusCode, - correlationId = correlationId, - traceId = traceId, - ) - ) - case _ => tryDeserializeStatus(status, rawDetails) - } - } - - def fromStatusRuntimeException( - statusRuntimeException: StatusRuntimeException - ): Either[String, DecodedCantonError] = - Either - .catchOnly[IllegalArgumentException]( - io.grpc.protobuf.StatusProto.fromThrowable(statusRuntimeException) - ) - .leftMap(ex => s"Failed to decode error from exception: ${ex.getMessage}") - .map(RpcStatus.fromJavaProto) - .flatMap(fromGrpcStatus) - - private def tryDeserializeStatus( - status: RpcStatus, - rawDetails: Seq[any.Any], - ): Either[String, DecodedCantonError] = - for { - errorInfoSeq <- extractErrorDetail[ErrorInfo](rawDetails) - errorInfo <- errorInfoSeq.exactlyOne - requestInfoSeq <- extractErrorDetail[RequestInfo](rawDetails) - requestInfoO <- requestInfoSeq.atMostOne - retryInfoSeq <- extractErrorDetail[RetryInfo](rawDetails) - retryInfo <- retryInfoSeq.atMostOne - resourceInfo <- extractErrorDetail[ResourceInfo](rawDetails) - resources = resourceInfo.map { resourceInfo => - ErrorResource(resourceInfo.resourceType) -> resourceInfo.resourceName - } - errorCategory <- extractErrorCategory( - errorInfo = errorInfo, - statusCode = status.code, - retryableDuration = retryInfo.flatMap(_.retryDelay).map(_.asJavaDuration.toScala), - ) - traceId = errorInfo.metadata.get("tid") - cause = extractCause(status) - correlationId = requestInfoO.collect { - case requestInfo if !traceId.contains(requestInfo.requestId) => requestInfo.requestId - } - } yield DecodedCantonError( - code = GenericErrorCode(id = errorInfo.reason, category = errorCategory), - cause = cause, - context = errorInfo.metadata, - resources = resources, - correlationId = correlationId, - traceId = traceId, - ) - - @VisibleForTesting - def unapply(throwable: Throwable): Option[DecodedCantonError] = throwable match { - case statusRuntimeException: StatusRuntimeException => - fromStatusRuntimeException(statusRuntimeException).toOption - case _other => None - } - - private def extractCause(status: RpcStatus) = - ErrorCodeMsg - .extract(status.message) - .map { case (_, _, _, cause) => cause } - // We don't guarantee backwards-compatibility for error message formats - // Hence fallback to the original cause on failure to parse - .getOrElse(status.message) - - private def redactedError( - grpcCode: Status.Code, - correlationId: Option[String], - traceId: Option[String], - ): DecodedCantonError = - DecodedCantonError( - code = GenericErrorCode( - id = "NA", - category = GenericErrorCategory( - grpcCode = Some(grpcCode), - logLevel = Level.ERROR, - retryable = None, - redactDetails = true, - // Security sensitive errors do not carry the category id - asInt = -1, - rank = 1, - ), - ), - cause = "A security-sensitive error has been received", - correlationId = correlationId, - traceId = traceId, - context = Map.empty, - resources = Seq.empty, - ) - - private def extractErrorCategory( - errorInfo: ErrorInfo, - statusCode: Int, - retryableDuration: Option[FiniteDuration], - ): Either[String, ErrorCategory] = { - def unknownCategory(categoryId: Int) = - GenericErrorCategory( - grpcCode = Some(Status.fromCodeValue(statusCode).getCode), - // If we log it, we use INFO since it's received from an - // external component - logLevel = Level.INFO, - retryable = retryableDuration.map(ErrorCategoryRetry.apply), - redactDetails = false, - asInt = categoryId, - rank = -1, - ) - - for { - categoryValue <- errorInfo.metadata - .get("category") - .toRight(s"category key not found in error metadata: ${errorInfo.metadata}") - categoryId <- Try(categoryValue.toInt).toEither.left.map(e => - s"Failed parsing category value: ${e.getMessage}" - ) - } yield ErrorCategory.all.find(_.asInt == categoryId).getOrElse(unknownCategory(categoryId)) - } - - private def extractErrorDetail[T <: GeneratedMessage]( - errorDetails: Seq[com.google.protobuf.any.Any] - )(implicit - expectedTypeCompanion: GeneratedMessageCompanion[T] - ): Either[String, List[T]] = - errorDetails.toList - .filter(_ is expectedTypeCompanion) - .traverse { errDetail => - Try(errDetail.unpack[T]).toEither.left.map(throwable => - s"Could not extract ${expectedTypeCompanion.scalaDescriptor.fullName} from error details: ${throwable.getMessage}" - ) - } - - private implicit class AritySelectors[T <: GeneratedMessage](seq: Seq[T])(implicit - expectedTypeCompanion: GeneratedMessageCompanion[T] - ) { - def atMostOne: Either[String, Option[T]] = - Either.cond(seq.sizeIs <= 1, seq.headOption, invalid("at most one")) - - def exactlyOne: Either[String, T] = seq match { - case Seq(errInfo) => Right(errInfo) - case _ => Left(invalid("exactly one")) - } - - private def invalid(times: String) = - s"Could not extract error detail. Expected $times ${expectedTypeCompanion.scalaDescriptor.fullName} in status details, but got ${seq.size}" - } - - /** Dummy error class for the purpose of creating the [[GenericErrorCode]]. It has no effect on - * documentation as its intended user ([[DecodedCantonError]]) does not appear in documentation. - */ - private implicit val genericErrorClass: ErrorClass = ErrorClass( - List(Grouping("generic", "ErrorClass")) - ) - - /** Generic wrapper for error codes received from deserialized gRPC-statuses */ - private final case class GenericErrorCode( - override val id: String, - override val category: ErrorCategory, - ) extends ErrorCode(id, category) -} diff --git a/canton/base/errors/src/main/scala/com/digitalasset/base/error/utils/ErrorDetails.scala b/canton/base/errors/src/main/scala/com/digitalasset/base/error/utils/ErrorDetails.scala deleted file mode 100644 index 8858ad69a2..0000000000 --- a/canton/base/errors/src/main/scala/com/digitalasset/base/error/utils/ErrorDetails.scala +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error.utils - -import com.digitalasset.base.error.ErrorCode -import com.google.protobuf -import com.google.rpc.{ErrorInfo, RequestInfo, ResourceInfo, RetryInfo} -import io.grpc.StatusRuntimeException -import io.grpc.protobuf.StatusProto - -import scala.concurrent.duration.* -import scala.jdk.CollectionConverters.* - -object ErrorDetails { - - sealed trait ErrorDetail extends Product with Serializable { - type T <: com.google.protobuf.Message - def toRpc: T - def toRpcAny: com.google.protobuf.Any = com.google.protobuf.Any.pack(toRpc) - } - - final case class ResourceInfoDetail(name: String, typ: String) extends ErrorDetail { - type T = ResourceInfo - def toRpc: ResourceInfo = - ResourceInfo.newBuilder().setResourceType(typ).setResourceName(name).build() - } - final case class ErrorInfoDetail(errorCodeId: String, metadata: Map[String, String]) - extends ErrorDetail { - type T = ErrorInfo - def toRpc: ErrorInfo = - ErrorInfo - .newBuilder() - .setReason(errorCodeId) - .putAllMetadata(metadata.asJava) - .build() - } - final case class RetryInfoDetail(duration: Duration) extends ErrorDetail { - type T = RetryInfo - def toRpc: RetryInfo = { - val millis = duration.toMillis - val fullSeconds = millis / 1000 - val remainderMillis = millis % 1000 - // Ensuring that we do not exceed max allowed value of nanos as documented in [[com.google.protobuf.Duration.Builder.setNanos]] - val remainderNanos = Math.min(remainderMillis * 1000 * 1000, 999999999).toInt - val protoDuration = com.google.protobuf.Duration - .newBuilder() - .setNanos(remainderNanos) - .setSeconds(fullSeconds) - .build() - RetryInfo - .newBuilder() - .setRetryDelay(protoDuration) - .build() - } - } - final case class RequestInfoDetail(correlationId: String) extends ErrorDetail { - type T = RequestInfo - def toRpc: RequestInfo = - RequestInfo - .newBuilder() - .setRequestId(correlationId) - .setServingData("") - .build() - } - - def from(status: com.google.rpc.Status): Seq[ErrorDetail] = - from(status.getDetailsList.asScala.toSeq) - def from(e: StatusRuntimeException): Seq[ErrorDetail] = - from(StatusProto.fromThrowable(e)) - - def from(anys: Seq[protobuf.Any]): Seq[ErrorDetail] = anys.toList.map { - case any if any.is(classOf[ResourceInfo]) => - val v = any.unpack(classOf[ResourceInfo]) - ResourceInfoDetail(typ = v.getResourceType, name = v.getResourceName) - - case any if any.is(classOf[ErrorInfo]) => - val v = any.unpack(classOf[ErrorInfo]) - ErrorInfoDetail(errorCodeId = v.getReason, metadata = v.getMetadataMap.asScala.toMap) - - case any if any.is(classOf[RetryInfo]) => - val v = any.unpack(classOf[RetryInfo]) - val delay = v.getRetryDelay - val duration = (delay.getSeconds.seconds + delay.getNanos.nanos).toCoarsest - RetryInfoDetail(duration = duration) - - case any if any.is(classOf[RequestInfo]) => - val v = any.unpack(classOf[RequestInfo]) - RequestInfoDetail(correlationId = v.getRequestId) - - case any => throw new IllegalStateException(s"Could not unpack value of: |$any|") - } - - /** @return - * whether a status runtime exception matches the error code. - * - * NOTE: This method is not suitable for: - * 1. security sensitive error codes (e.g. internal or authentication related) as they are - * stripped from all the details when being converted to instances of - * [[io.grpc.StatusRuntimeException]], - * 1. error codes that do not translate to gRPC level errors (i.e. error codes that don't have - * a corresponding gRPC status) - */ - def matches(e: StatusRuntimeException, errorCode: ErrorCode): Boolean = { - val matchesErrorCodeId = from(e).exists { - case ErrorInfoDetail(errorCodeId, _) => errorCodeId == errorCode.id - case _ => false - } - val matchesMessagePrefix = Option(e.getStatus.getDescription).exists(_.startsWith(errorCode.id)) - val matchesStatusCode = errorCode.category.grpcCode.contains(e.getStatus.getCode) - matchesErrorCodeId && matchesMessagePrefix && matchesStatusCode - } - - def matches(t: Throwable, errorCode: ErrorCode): Boolean = t match { - case e: StatusRuntimeException => matches(e, errorCode) - case _ => false - } -} diff --git a/canton/base/errors/src/test/scala/com/digitalasset/base/error/ErrorCodeSpec.scala b/canton/base/errors/src/test/scala/com/digitalasset/base/error/ErrorCodeSpec.scala deleted file mode 100644 index b385f8ef10..0000000000 --- a/canton/base/errors/src/test/scala/com/digitalasset/base/error/ErrorCodeSpec.scala +++ /dev/null @@ -1,387 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error - -import com.digitalasset.base.error.utils.{DecodedCantonError, ErrorDetails} -import com.google.rpc.Status -import io.grpc.Status.Code -import org.scalatest.EitherValues -import org.scalatest.concurrent.{Eventually, IntegrationPatience} -import org.scalatest.freespec.AnyFreeSpec -import org.scalatest.matchers.should.Matchers -import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks - -import scala.concurrent.duration.* -import scala.jdk.CollectionConverters.* - -class ErrorCodeSpec - extends AnyFreeSpec - with Matchers - with Eventually - with IntegrationPatience - with ErrorsAssertions - with ScalaCheckDrivenPropertyChecks - with EitherValues { - - object FooErrorCodeRedacted - extends ErrorCode( - "FOO_ERROR_CODE_SECURITY_SENSITIVE", - ErrorCategory.SystemInternalAssumptionViolated, - )(ErrorClass.root()) - - object FooErrorCode - extends ErrorCode("FOO_ERROR_CODE", ErrorCategory.InvalidIndependentOfSystemState)( - ErrorClass.root() - ) - - classOf[ErrorCode].getSimpleName - { - - "meet test preconditions" in { - FooErrorCodeRedacted.category.redactDetails shouldBe true - FooErrorCode.category.redactDetails shouldBe false - FooErrorCodeRedacted.category.grpcCode shouldBe Some(Code.INTERNAL) - FooErrorCode.category.grpcCode shouldBe Some(Code.INVALID_ARGUMENT) - } - - val maxLength = 512 - - "create correct message" in { - FooErrorCode.toMsg( - cause = "cause123", - correlationId = Some("123correlationId"), - limit = Some(maxLength), - ) shouldBe "FOO_ERROR_CODE(8,123corre): cause123" - FooErrorCode.toMsg( - cause = "cause123", - correlationId = None, - limit = Some(maxLength), - ) shouldBe "FOO_ERROR_CODE(8,0): cause123" - FooErrorCode.toMsg( - cause = "x" * maxLength * 2, - correlationId = Some("123correlationId"), - limit = Some(maxLength), - ) shouldBe s"FOO_ERROR_CODE(8,123corre): ${"x" * maxLength}..." - FooErrorCodeRedacted.toMsg( - cause = "cause123", - correlationId = Some("123correlationId"), - limit = Some(maxLength), - ) shouldBe "FOO_ERROR_CODE_SECURITY_SENSITIVE(4,123corre): cause123" - } - - "create a minimal grpc status and exception" - { - - "when correlation-id and trace-id are not set" in { - testMinimalGrpcStatus(NoBaseLogging) - } - - "when only correlation-id is set" in { - testMinimalGrpcStatus( - new NoBaseLogging( - properties = Map.empty, - correlationId = Some("123correlationId"), - ) - ) - } - - "when only trace-id is set" in { - testMinimalGrpcStatus( - new NoBaseLogging( - properties = Map.empty, - // correlationId should be the traceId when not set - correlationId = Some("123traceId"), - traceId = Some("123traceId"), - ) - ) - } - - "when correlation-id and trace-id are set" in { - testMinimalGrpcStatus( - new NoBaseLogging( - properties = Map.empty, - correlationId = Some("123correlationId"), - traceId = Some("123traceId"), - ) - ) - } - - } - - "create a big grpc status and exception" - { - class FooErrorBig(override val code: ErrorCode) extends BaseError { - override val cause: String = "cause123" - - override def retryable: Option[ErrorCategoryRetry] = Some( - ErrorCategoryRetry(duration = 123.seconds + 456.milliseconds) - ) - - override def resources: Seq[(ErrorResource, String)] = - super.resources ++ - Seq[(ErrorResource, String)]( - ErrorResource.CommandId -> "commandId1", - ErrorResource.CommandId -> "commandId2", - ErrorResource.Party -> "party1", - ) - - override def context: Map[String, String] = - super.context ++ Map( - "contextKey1" -> "contextValue1", - "key????" -> "keyWithInvalidCharacters", - ) - - override def definiteAnswerO: Option[Boolean] = Some(false) - - override def throwableO: Option[Throwable] = - Some(new RuntimeException("runtimeException123")) - } - - val errorLoggerBig: BaseErrorLogger = new NoBaseLogging( - correlationId = Some("123correlationId"), - properties = Map( - "loggingEntryKey" -> "loggingEntryValue" - ), - ) - val requestInfo = ErrorDetails.RequestInfoDetail("123correlationId") - val retryInfo = ErrorDetails.RetryInfoDetail(123.seconds + 456.milliseconds) - - def getDetails(tested: ErrorCode) = Seq( - ErrorDetails - .ErrorInfoDetail( - tested.id, - Map( - "category" -> tested.category.asInt.toString, - "definite_answer" -> "false", - "loggingEntryKey" -> "loggingEntryValue", - "contextKey1" -> "contextValue1", - "key" -> "keyWithInvalidCharacters", - ), - ), - requestInfo, - retryInfo, - ErrorDetails.ResourceInfoDetail(name = "commandId1", typ = "COMMAND_ID"), - ErrorDetails.ResourceInfoDetail(name = "commandId2", typ = "COMMAND_ID"), - ErrorDetails.ResourceInfoDetail(name = "party1", typ = "PARTY"), - ) - - "not security sensitive" in { - val testedErrorCode = FooErrorCode - val details = getDetails(testedErrorCode) - final case class TestedError() extends FooErrorBig(testedErrorCode) - - val expectedStatus = Status - .newBuilder() - .setMessage("FOO_ERROR_CODE(8,123corre): cause123") - .setCode(testedErrorCode.category.grpcCode.value.value()) - .addAllDetails(details.map(_.toRpcAny).asJava) - .build() - val testedError = TestedError() - - assertStatus( - actual = ErrorCode.asGrpcStatus(testedError)(errorLoggerBig), - expected = expectedStatus, - ) - assertError( - actual = ErrorCode.asGrpcError(testedError)(errorLoggerBig), - expectedStatusCode = testedErrorCode.category.grpcCode.value, - expectedMessage = "FOO_ERROR_CODE(8,123corre): cause123", - expectedDetails = details, - ) - } - - "security sensitive" in { - val testedErrorCode = FooErrorCodeRedacted - final case class FooError() extends FooErrorBig(testedErrorCode) - val expectedStatus = Status - .newBuilder() - .setMessage( - BaseError.RedactedMessage(Some("123correlationId")) - ) - .setCode(testedErrorCode.category.grpcCode.value.value()) - .addDetails(requestInfo.toRpcAny) - .build() - val testedError = FooError() - testedError.logWithContext(Map.empty)(errorLoggerBig) - - assertStatus( - actual = ErrorCode.asGrpcStatus(testedError)(errorLoggerBig), - expected = expectedStatus, - ) - assertError( - actual = ErrorCode.asGrpcError(testedError)(errorLoggerBig), - expectedStatusCode = testedErrorCode.category.grpcCode.value, - expectedMessage = BaseError.RedactedMessage(Some("123correlationId")), - expectedDetails = Seq(requestInfo), - ) - } - - } - - "create a grpc status and exception for input exceeding details size limits" in { - class FooErrorBig(override val code: ErrorCode) extends BaseError { - override val cause: String = "cause123" - - override def context: Map[String, String] = - super.context ++ Map( - ("y" * ErrorCode.MaxErrorContentBytes) -> ("y" * ErrorCode.MaxErrorContentBytes) - ) - - override def retryable: Option[ErrorCategoryRetry] = Some( - ErrorCategoryRetry(duration = 123.seconds + 456.milliseconds) - ) - - override def resources: Seq[(ErrorResource, String)] = - super.resources ++ - Seq[(ErrorResource, String)]( - ErrorResource.CommandId -> "commandId1", - ErrorResource.CommandId -> "commandId2", - ErrorResource.Party -> "party1", - ErrorResource.Party -> ("x" * ErrorCode.MaxErrorContentBytes), - ) - - override def definiteAnswerO: Option[Boolean] = Some(false) - } - val errorLoggerOversized: BaseErrorLogger = new NoBaseLogging( - correlationId = Some("123correlationId"), - properties = Map( - "loggingEntryKey" -> "loggingEntryValue", - "loggingEntryValueTooBig" -> ("x" * ErrorCode.MaxErrorContentBytes), - ("x" * ErrorCode.MaxErrorContentBytes) -> "loggingEntryKeyTooBig", - ), - ) - val requestInfo = ErrorDetails.RequestInfoDetail("123correlationId") - val retryInfo = ErrorDetails.RetryInfoDetail(123.seconds + 456.milliseconds) - - val testedErrorCode = FooErrorCode - final case class TestedError() extends FooErrorBig(FooErrorCode) - val testedError = TestedError() - - val expectedDetails = Seq( - ErrorDetails - .ErrorInfoDetail( - testedErrorCode.id, - Map( - "category" -> testedErrorCode.category.asInt.toString, - "definite_answer" -> "false", - "loggingEntryKey" -> "loggingEntryValue", - "loggingEntryValueTooBig" -> ("x" * 849 + "..."), - "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" -> "loggingEntryKeyTooBig", - "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" -> ("y" * 809 + "..."), - ), - ), - requestInfo, - retryInfo, - ErrorDetails.ResourceInfoDetail(name = "commandId1", typ = "COMMAND_ID"), - ErrorDetails.ResourceInfoDetail(name = "commandId2", typ = "COMMAND_ID"), - ErrorDetails.ResourceInfoDetail(name = "party1", typ = "PARTY"), - ) - val expectedMessage = "FOO_ERROR_CODE(8,123corre): cause123" - val expectedStatus = Status - .newBuilder() - .setMessage(expectedMessage) - .setCode(testedErrorCode.category.grpcCode.value.value()) - .addAllDetails(expectedDetails.map(_.toRpcAny).asJava) - .build() - - assertStatus( - actual = ErrorCode.asGrpcStatus(testedError)(errorLoggerOversized), - expected = expectedStatus, - ) - assertError( - actual = ErrorCode.asGrpcError(testedError)(errorLoggerOversized), - expectedStatusCode = testedErrorCode.category.grpcCode.value, - expectedMessage = expectedMessage, - expectedDetails = expectedDetails, - ) - } - - "do not exceed the safe-to-serialize limit" in { - implicit val generatorDrivenConfig: PropertyCheckConfiguration = - PropertyCheckConfiguration(minSuccessful = 100) - - // The description gets added to the io.grpc.Status and to the StatusProto as well - // so we must leave some space for it - forAll(ErrorGenerator.defaultErrorGen) { err => - whenever( - ErrorCode - // Ensure we evaluate the test only for errors that must be truncated - .asGrpcStatus(err, ErrorCode.MaxErrorContentBytes * 10)(err.logger) - .getSerializedSize > ErrorCode.MaxErrorContentBytes - ) { - val protoResult = err.asGrpcStatus - val serializedSize = protoResult.getSerializedSize - - serializedSize should be <= ErrorCode.MaxErrorContentBytes withClue s"for $err" - } - } - } - - "truncate the trace-id if abnormaly large" in { - val errWithLargeTraceId = - ErrorGenerator.defaultErrorGen.sample.value.copy(traceId = Some("x" * 1000)).asGrpcError - - DecodedCantonError - .fromStatusRuntimeException(errWithLargeTraceId) - .value - .traceId - .value shouldBe ("x" * 253 + "...") - } - - "truncate the correlation-id if abnormaly large" in { - val errWithLargeTraceId = - ErrorGenerator.defaultErrorGen.sample.value - .copy(correlationId = Some("x" * 1000)) - .asGrpcError - - DecodedCantonError - .fromStatusRuntimeException(errWithLargeTraceId) - .value - .correlationId - .value shouldBe ("x" * 253 + "...") - } - } - - def testMinimalGrpcStatus(errorLoggerSmall: BaseErrorLogger): Unit = { - class FooErrorMinimal(override val code: ErrorCode) extends BaseError { - override val cause: String = "cause123" - } - val testedErrorCode = FooErrorCode - val id = errorLoggerSmall.correlationId.orElse(errorLoggerSmall.traceId) - val idTruncated = id.getOrElse("0").take(8) - final case class TestedError() extends FooErrorMinimal(testedErrorCode) - val details = Seq( - ErrorDetails - .ErrorInfoDetail( - testedErrorCode.id, - Map( - "category" -> testedErrorCode.category.asInt.toString - ) ++ errorLoggerSmall.traceId.fold(Map.empty[String, String])(tid => Map("tid" -> tid)), - ) - ) ++ id - .map(correlationId => - ErrorDetails.RequestInfoDetail( - correlationId = correlationId - ) - ) - .toList - - val expected = Status - .newBuilder() - .setMessage(s"FOO_ERROR_CODE(8,$idTruncated): cause123") - .setCode(Code.INVALID_ARGUMENT.value()) - .addAllDetails(details.map(_.toRpcAny).asJava) - .build() - val testedError = TestedError() - - assertStatus( - actual = ErrorCode.asGrpcStatus(testedError)(errorLoggerSmall), - expected = expected, - ) - assertError( - actual = ErrorCode.asGrpcError(testedError)(errorLoggerSmall), - expectedStatusCode = testedErrorCode.category.grpcCode.value, - expectedMessage = s"FOO_ERROR_CODE(8,$idTruncated): cause123", - expectedDetails = details, - ) - } - -} diff --git a/canton/base/errors/src/test/scala/com/digitalasset/base/error/ErrorGenerator.scala b/canton/base/errors/src/test/scala/com/digitalasset/base/error/ErrorGenerator.scala deleted file mode 100644 index 63b4068341..0000000000 --- a/canton/base/errors/src/test/scala/com/digitalasset/base/error/ErrorGenerator.scala +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error - -import org.scalacheck.{Arbitrary, Gen} - -object ErrorGenerator { - final case class RichTestError( - errorCode: ErrorCode, - override val correlationId: Option[String] = None, - override val traceId: Option[String] = None, - contextMap: Map[String, Any] = Map(), - loggingProperties: Map[String, String] = Map(), - override val cause: String, - override val throwableO: Option[Throwable] = None, - override val definiteAnswerO: Option[Boolean] = None, - override val resources: Seq[(ErrorResource, String)] = Seq(), - ) extends ContextualizedDamlError( - cause, - throwableO, - contextMap, - )(errorCode, new NoBaseLogging(loggingProperties, correlationId, traceId)) - - private final case class TestErrorCode(override val id: String, errorCategory: ErrorCategory) - extends ErrorCode(id, errorCategory)(ErrorClass.root()) {} - - private[error] def asciiPrintableStrOfN(maxSize: Int) = for { - chars <- Gen.listOfN(maxSize, Gen.alphaNumChar) - } yield chars.mkString - - private val errorResourceGen = for { - typ <- asciiPrintableStrOfN(256).map(ErrorResource(_)) - msg <- asciiPrintableStrOfN(1024) - } yield (typ, msg) - - private val contextMapGen = for { - contextMap <- Gen.mapOfN( - 50, - for { - k <- asciiPrintableStrOfN(256) - v <- asciiPrintableStrOfN(256) - } yield (k, v), - ) - } yield contextMap - - val defaultErrorGen: Gen[RichTestError] = errorGenerator(None) - - def errorGenerator( - redactDetails: Option[Boolean], - additionalErrorCategoryFilter: ErrorCategory => Boolean = _ => true, - ): Gen[RichTestError] = - for { - errorCodeId <- Gen.listOfN(63, Gen.alphaUpperChar).map(_.mkString) - category <- Gen.oneOf( - redactDetails - .fold(ErrorCategory.all)(redact => ErrorCategory.all.filter(_.redactDetails == redact)) - .filter(additionalErrorCategoryFilter) - ) - errorCode = TestErrorCode(errorCodeId, category) - correlationId <- asciiPrintableStrOfN( - SerializableErrorCodeComponents.MaxTraceIdCorrelationIdSize - ).map(Option(_).filter(_.nonEmpty)) - traceId <- asciiPrintableStrOfN(SerializableErrorCodeComponents.MaxTraceIdCorrelationIdSize) - .map(Option(_).filter(_.nonEmpty)) - message <- asciiPrintableStrOfN(2000) - definiteAnswerO <- Arbitrary.arbitrary[Option[Boolean]] - errorResources <- Gen.listOfN(50, errorResourceGen) - extraContextMap <- contextMapGen - loggingProperties <- contextMapGen - throwableO <- Gen.option(Gen.asciiPrintableStr.map(new RuntimeException(_))) - } yield RichTestError( - errorCode = errorCode, - correlationId = correlationId, - traceId = traceId, - contextMap = extraContextMap, - loggingProperties = loggingProperties, - cause = message, - definiteAnswerO = definiteAnswerO, - throwableO = throwableO, - resources = errorResources, - ) -} diff --git a/canton/base/errors/src/test/scala/com/digitalasset/base/error/ErrorGroupSpec.scala b/canton/base/errors/src/test/scala/com/digitalasset/base/error/ErrorGroupSpec.scala deleted file mode 100644 index 813215d124..0000000000 --- a/canton/base/errors/src/test/scala/com/digitalasset/base/error/ErrorGroupSpec.scala +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error - -import org.scalatest.BeforeAndAfter -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers - -class ErrorGroupSpec extends AnyFlatSpec with Matchers with BeforeAndAfter { - - object ErrorGroupBar extends ErrorGroup()(ErrorClass.root()) - - object ErrorGroupFoo1 extends ErrorGroup()(ErrorClass.root()) { - object ErrorGroupFoo2 extends ErrorGroup() { - object ErrorGroupFoo3 extends ErrorGroup() - } - } - - it should "resolve correct error group names" in { - ErrorGroupFoo1.ErrorGroupFoo2.ErrorGroupFoo3.errorClass shouldBe ErrorClass( - List( - Grouping("ErrorGroupFoo1", ErrorGroupFoo1.fullClassName), - Grouping("ErrorGroupFoo2", ErrorGroupFoo1.ErrorGroupFoo2.fullClassName), - Grouping( - "ErrorGroupFoo3", - ErrorGroupFoo1.ErrorGroupFoo2.ErrorGroupFoo3.fullClassName, - ), - ) - ) - ErrorGroupBar.errorClass shouldBe ErrorClass( - List(Grouping("ErrorGroupBar", ErrorGroupBar.fullClassName)) - ) - } - -} diff --git a/canton/base/errors/src/test/scala/com/digitalasset/base/error/ErrorsAssertions.scala b/canton/base/errors/src/test/scala/com/digitalasset/base/error/ErrorsAssertions.scala deleted file mode 100644 index a476ea71ef..0000000000 --- a/canton/base/errors/src/test/scala/com/digitalasset/base/error/ErrorsAssertions.scala +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error - -import com.digitalasset.base.error.utils.ErrorDetails -import com.digitalasset.base.error.utils.ErrorDetails.{ErrorInfoDetail, RequestInfoDetail} -import io.grpc.Status.Code -import io.grpc.StatusRuntimeException -import io.grpc.protobuf.StatusProto -import org.scalatest.Checkpoints.Checkpoint -import org.scalatest.matchers.should.Matchers -import org.scalatest.{AppendedClues, Assertion, OptionValues} - -import scala.jdk.CollectionConverters.* - -trait ErrorsAssertions extends Matchers with OptionValues with AppendedClues { - - /** NOTE: This method is not suitable for: - * 1. security sensitive error codes (e.g. internal or authentication related) as they are - * stripped from all the details when being converted to instances of - * [[StatusRuntimeException]], - * 1. error codes that do not translate to gRPC level errors (i.e. error codes that don't have - * a corresponding gRPC status) - */ - def assertMatchesErrorCode( - actual: StatusRuntimeException, - expectedErrorCode: ErrorCode, - ): Assertion = { - val actualErrorCodeId = ErrorDetails.from(actual).collectFirst { - case ErrorInfoDetail(errorCodeId, _) => errorCodeId - } - val actualDescription = Option(actual.getStatus.getDescription) - val actualStatusCode = actual.getStatus.getCode - val cp = new Checkpoint - cp(actualErrorCodeId.value shouldBe expectedErrorCode.id) - cp(Some(actualStatusCode) shouldBe expectedErrorCode.category.grpcCode) - cp(actualDescription.value should startWith(expectedErrorCode.id)) - cp.reportAll() - succeed - } - - def assertStatus( - actual: com.google.rpc.Status, - expected: com.google.rpc.Status, - ): Assertion = { - val actualDetails = ErrorDetails.from(actual) - val expectedDetails = ErrorDetails.from(expected) - val actualDescription = Option(actual.getMessage) - val expectedDescription = Option(expected.getMessage) - val actualStatusCode = actual.getCode - val expectedStatusCode = expected.getCode - val cp = new Checkpoint - cp(actualDescription shouldBe expectedDescription) - cp { - actualStatusCode shouldBe expectedStatusCode withClue (s", expecting status code: '$expectedStatusCode''") - } - cp(actualDetails should contain theSameElementsAs expectedDetails) - cp.reportAll() - succeed - } - - /** Asserts that the two errors have the same code, message and details. - */ - def assertError( - actual: StatusRuntimeException, - expected: StatusRuntimeException, - ): Unit = { - val expectedStatus = StatusProto.fromThrowable(expected) - val expectedDetails = expectedStatus.getDetailsList.asScala.toSeq - assertError( - actual = actual, - expectedStatusCode = expected.getStatus.getCode, - expectedMessage = expectedStatus.getMessage, - expectedDetails = ErrorDetails.from(expectedDetails), - ) - } - - /** @param verifyEmptyStackTrace - * should be enabled for the server-side testing and disabled for the client side testing - */ - def assertError( - actual: StatusRuntimeException, - expectedStatusCode: Code, - expectedMessage: String, - expectedDetails: Seq[ErrorDetails.ErrorDetail], - verifyEmptyStackTrace: Boolean = true, - ): Unit = { - val actualStatus = StatusProto.fromThrowable(actual) - val actualDetails = actualStatus.getDetailsList.asScala.toSeq - val cp = new Checkpoint - cp(actual.getStatus.getCode shouldBe expectedStatusCode) - cp(actualStatus.getMessage shouldBe expectedMessage) - cp { - ErrorDetails.from(actualDetails) should contain theSameElementsAs expectedDetails - } - if (verifyEmptyStackTrace) { - cp { - actual.getStackTrace.length shouldBe 0 withClue ("it should contain no stacktrace") - } - } - cp(actual.getCause shouldBe null) - cp.reportAll() - } - - /** Asserts that the error has the expected code and matches the form of the message and details. - */ - def assertError( - actual: StatusRuntimeException, - expectedStatusCode: Code, - expectedMessage: String => String, - expectedDetails: String => String => Seq[ErrorDetails.ErrorDetail], - ): Unit = { - val actualStatus = StatusProto.fromThrowable(actual) - val actualDetails = actualStatus.getDetailsList.asScala.toSeq - val errorDetails = ErrorDetails.from(actualDetails) - val tid = errorDetails.collectFirst { case RequestInfoDetail(tid) => tid }.value - val errorInfoDetail = - errorDetails.collectFirst { case detail: ErrorInfoDetail => detail }.value - val submissionId = errorInfoDetail.metadata.get("submissionId").value - assertError( - actual, - expectedStatusCode, - expectedMessage(tid), - expectedDetails(tid)(submissionId), - verifyEmptyStackTrace = false, - ) - - } - -} diff --git a/canton/base/errors/src/test/scala/com/digitalasset/base/error/GrpcStatusesSpec.scala b/canton/base/errors/src/test/scala/com/digitalasset/base/error/GrpcStatusesSpec.scala deleted file mode 100644 index 6783693c0b..0000000000 --- a/canton/base/errors/src/test/scala/com/digitalasset/base/error/GrpcStatusesSpec.scala +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error - -import com.google.protobuf.any -import com.google.rpc.error_details.ErrorInfo -import com.google.rpc.status.Status -import org.scalatest.matchers.should.Matchers -import org.scalatest.prop.TableDrivenPropertyChecks.* -import org.scalatest.wordspec.AnyWordSpec - -import GrpcStatuses.DefiniteAnswerKey - -class GrpcStatusesSpec extends AnyWordSpec with Matchers { - "isDefiniteAnswer" should { - "return correct value" in { - val anErrorInfo = ErrorInfo.of("reason", "synchronizer", Map.empty) - val testCases = Table( - ("Description", "Error Info", "Expected"), - ( - "ErrorInfo contains definite answer key and its value is true", - Some(anErrorInfo.copy(metadata = Map(DefiniteAnswerKey -> "true"))), - true, - ), - ( - "ErrorInfo contains definite answer key and its value is false", - Some(anErrorInfo.copy(metadata = Map(DefiniteAnswerKey -> "false"))), - false, - ), - ( - "ignore casing of value associated to definite answer key (#1)", - Some(anErrorInfo.copy(metadata = Map(DefiniteAnswerKey -> "TRUE"))), - true, - ), - ( - "ignore casing of value associated to definite answer key (#2)", - Some(anErrorInfo.copy(metadata = Map(DefiniteAnswerKey -> "True"))), - true, - ), - ( - "ErrorInfo does not contain definite answer key", - Some(anErrorInfo.copy(metadata = Map("some" -> "key"))), - false, - ), - ("no ErrorInfo is available", None, false), - ) - - forAll(testCases) { case (_, errorInfoMaybe, expected) => - val details = - errorInfoMaybe.map(errorInfo => any.Any.pack(errorInfo)).map(Seq(_)).getOrElse(Seq.empty) - val inputStatus = Status.of(123, "an error", details) - GrpcStatuses.isDefiniteAnswer(inputStatus) should be(expected) - } - } - } - -} diff --git a/canton/base/errors/src/test/scala/com/digitalasset/base/error/RedactedMessageSpec.scala b/canton/base/errors/src/test/scala/com/digitalasset/base/error/RedactedMessageSpec.scala deleted file mode 100644 index 68cd315d0c..0000000000 --- a/canton/base/errors/src/test/scala/com/digitalasset/base/error/RedactedMessageSpec.scala +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error - -import org.scalacheck.Gen -import org.scalatest.Inside -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks - -import BaseError.RedactedMessage - -class RedactedMessageSpec extends AnyFlatSpec with ScalaCheckPropertyChecks with Inside { - private val traceIdGen = - Gen.option(Gen.asciiPrintableStr).filterNot(_.exists(v => v.isEmpty || v == "")) - private val correlationIdGen = Gen - .option(Gen.asciiPrintableStr) - .filterNot(_.exists(v => v.isEmpty || v == "")) - - RedactedMessage.getClass.getSimpleName should "correctly construct and extract the security message fields" in { - forAll(correlationIdGen, traceIdGen) { (corrIdO, tIdO) => - inside(RedactedMessage(corrIdO, tIdO)) { case RedactedMessage(`corrIdO`, `tIdO`) => - succeed - } - } - } -} diff --git a/canton/base/errors/src/test/scala/com/digitalasset/base/error/SerializableErrorComponentsSpec.scala b/canton/base/errors/src/test/scala/com/digitalasset/base/error/SerializableErrorComponentsSpec.scala deleted file mode 100644 index 67575161ad..0000000000 --- a/canton/base/errors/src/test/scala/com/digitalasset/base/error/SerializableErrorComponentsSpec.scala +++ /dev/null @@ -1,205 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error - -import org.scalacheck.Gen -import org.scalatest.Assertions.fail -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers -import org.scalatest.{EitherValues, Inside, OptionValues} -import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks - -import ErrorGenerator.asciiPrintableStrOfN -import SerializableErrorComponentsSpec.* - -class SerializableErrorComponentsSpec - extends AnyFlatSpec - with Matchers - with EitherValues - with Inside - with OptionValues - with ScalaCheckDrivenPropertyChecks { - - behavior of "truncateDetails" - - it should "not change details if reasonably-sized and valid" in { - val (truncatedContext, truncatedResources) = - NonSecuritySensitiveErrorCodeComponents.truncateDetails( - context = rawContextMap, - errResources = rawErrorResources, - remainingBudgetBytes = 1000, - ) - - truncatedContext should contain theSameElementsAs rawContextMap - truncatedResources should contain theSameElementsAs comparable(rawErrorResources) - } - - it should "truncate context map and resources when oversize" in { - val cMap = - Map( - "k1".padTo(20, 'a') -> cValue(20), - "k2".padTo(30, 'a') -> cValue(40), - "k3".padTo(20, 'a') -> cValue(100), - "k4".padTo(50, 'a') -> cValue(160), - "k5".padTo(60, 'a') -> cValue(320), - ) - - val errRes: Seq[(ErrorResource, String)] = - (1 to 10).map(i => ErrorResource.CommandId -> s"some-command-id-$i".padTo(50, 'a')) - - val (truncatedContext, truncatedResources) = - NonSecuritySensitiveErrorCodeComponents.truncateDetails( - context = cMap, - errResources = errRes, - remainingBudgetBytes = 1000, - ) - - val expected = Map( - "k1".padTo(20, 'a') -> cValue(20), - "k2".padTo(30, 'a') -> truncatedValue("", 31), - "k3".padTo(20, 'a') -> truncatedValue("", 41), - truncatedValue("k4", 30) -> truncatedValue("", 31), - truncatedValue("k5", 30) -> truncatedValue("", 31), - ) - truncatedContext should contain theSameElementsAs expected - truncatedResources should contain theSameElementsAs comparable(errRes.take(5)) - } - - behavior of "truncateContext" - - it should "validate context map keys" in { - val invalidKeyEntry = "%k4!" -> cValue(50) - val invalidKeyEntryCorrectSize = "%k5!".padTo(65, 'a') -> cValue(50) - val invalidKeyEntryOversize = "%k6!".padTo(100, 'a') -> cValue(50) - val correctKeyOversize = "k7-size".padTo(64, 'a') -> cValue(50) - - val truncatedContext = - NonSecuritySensitiveErrorCodeComponents.truncateContext( - rawContextEntries = rawContextMap.toVector ++ Vector( - invalidKeyEntry, - invalidKeyEntryCorrectSize, - invalidKeyEntryOversize, - correctKeyOversize, - ), - maxBudgetBytes = 1000, - ) - - truncatedContext should contain theSameElementsAs rawContextMap.toVector ++ Vector( - "k4" -> cValue(50), - "k5".padTo(63, 'a') -> cValue(50), - "k6".padTo(63, 'a') -> cValue(50), - "k7-size".padTo(63, 'a') -> cValue(50), - ) - } - - it should "return an empty list on empty input" in { - NonSecuritySensitiveErrorCodeComponents.truncateContext( - rawContextEntries = Vector.empty, - maxBudgetBytes = 4096, - ) shouldBe empty - } - - it should "return an empty list on too small budget" in { - NonSecuritySensitiveErrorCodeComponents.truncateContext( - rawContextEntries = Vector("1" -> "23", "123" -> "45", "12345" -> "678"), - maxBudgetBytes = 3, - ) shouldBe empty - } - - it should "not add context map entries if one from the pair is empty" in { - val truncatedContext = - NonSecuritySensitiveErrorCodeComponents.truncateContext( - rawContextEntries = rawContextMap.toVector ++ Vector( - "" -> cValue(50), - "k" -> "", - // After key is cleaned, it remains empty - "$!@" -> cValue(50), - "" -> "", - ), - maxBudgetBytes = 1000, - ) - - truncatedContext should contain theSameElementsAs rawContextMap - } - - it should "pack everything on exact size" in { - val validContextMapGen = for { - contextMap <- Gen.mapOfN( - 50, - for { - k <- asciiPrintableStrOfN(63) - v <- asciiPrintableStrOfN(63) - } yield (k, v), - ) - } yield contextMap - - forAll(validContextMapGen) { map => - whenever(!map.exists { case (k, v) => k.isEmpty || v.isEmpty }) { - val input = map.toVector - NonSecuritySensitiveErrorCodeComponents.truncateContext( - rawContextEntries = input, - maxBudgetBytes = input.map(v => v._1.length + v._2.length).sum, - ) should contain theSameElementsAs input - } - } - } - - it should "too small entries that need to be truncated are skipped instead" in { - NonSecuritySensitiveErrorCodeComponents.truncateContext( - rawContextEntries = Vector("123" -> "456"), - maxBudgetBytes = 5, - ) shouldBe empty - - NonSecuritySensitiveErrorCodeComponents.truncateContext( - rawContextEntries = Vector("1" -> "23", "123" -> "45", "12345" -> "678"), - maxBudgetBytes = 8, - ) should contain theSameElementsAs Vector("12345" -> "678") - } - - it should "truncate bigger entries more" in { - val inputSize = 20 - // Input with entries sorted by increasing size - val input = (1 to inputSize).map(idx => s"k$idx".padTo(5 + idx * 2, 'a') -> cValue(idx * 2)) - val requestedEntriesSize = input.map(v => v._1.length + v._2.length).sum - val output = - NonSecuritySensitiveErrorCodeComponents.truncateContext( - rawContextEntries = input, - maxBudgetBytes = requestedEntriesSize / 2, - ) - - def sum(s: Seq[(String, String)]) = s.map(v => v._1.length + v._2.length).sum - def truncationPercentage(in: Seq[(String, String)], out: Seq[(String, String)]): Double = - sum(out).toDouble / sum(in).toDouble - def avgSize(s: Seq[(String, String)]) = sum(s).toDouble / s.size - - val (inputFirstHalf, inputSecondHalf) = input.splitAt(inputSize / 2) - val (outputFirstHalf, outputSecondHalf) = output.splitAt(inputSize / 2) - - val truncPCSmaller = truncationPercentage(inputFirstHalf, outputFirstHalf) - val truncPCBigger = truncationPercentage(inputSecondHalf, outputSecondHalf) - - // Check that truncation percentage is lower for the for smaller entries - truncPCSmaller should be > truncPCBigger - - // Check that the average size still remains higher for bigger entries - avgSize(outputFirstHalf) < avgSize(outputSecondHalf) - } -} - -private object SerializableErrorComponentsSpec { - val rawContextMap: Map[String, String] = - Map("key" -> cValue(10), "key-max-size".padTo(63, 'a') -> cValue(50)) - val rawErrorResources: Seq[(ErrorResource, String)] = Seq( - ErrorResource.CommandId -> "some-command-id", - ErrorResource.ContractKey -> "some-contract-key", - ) - - private def cValue(size: Int): String = "a" * size - private def truncatedValue(prefix: String, size: Int): String = - if (size < 3 + prefix.length) fail(s"size: $size") - else s"$prefix${cValue(size - 3 - prefix.length)}..." - - private def comparable(res: Seq[(ErrorResource, String)]): Seq[(String, String)] = - res.map { case (k, v) => k.asString -> v } -} diff --git a/canton/base/errors/src/test/scala/com/digitalasset/base/error/samples/SampleClientSideSpec.scala b/canton/base/errors/src/test/scala/com/digitalasset/base/error/samples/SampleClientSideSpec.scala deleted file mode 100644 index 08c1200141..0000000000 --- a/canton/base/errors/src/test/scala/com/digitalasset/base/error/samples/SampleClientSideSpec.scala +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error.samples - -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers - -class SampleClientSideSpec extends AnyFlatSpec with Matchers { - - it should "run successfully" in { - SampleClientSide.example() - } - -} diff --git a/canton/base/errors/src/test/scala/com/digitalasset/base/error/utils/BenignError.scala b/canton/base/errors/src/test/scala/com/digitalasset/base/error/utils/BenignError.scala deleted file mode 100644 index 9dd9aa268d..0000000000 --- a/canton/base/errors/src/test/scala/com/digitalasset/base/error/utils/BenignError.scala +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error.utils - -import com.digitalasset.base.error.{ - BaseErrorLogger, - DamlErrorWithDefiniteAnswer, - ErrorCategory, - ErrorClass, - ErrorCode, - Explanation, - Resolution, -} - -@Explanation("Not the end of the world.") -@Resolution( - "Retry re-submitting the request. If the error persists, contact the participant operator." -) -case object BenignError - extends ErrorCode("BENIGN_ERROR", ErrorCategory.TransientServerFailure)( - ErrorClass.root() - ) { - - final case class Reject(serviceName: String)(implicit - loggingContext: BaseErrorLogger - ) extends DamlErrorWithDefiniteAnswer( - cause = s"Benign problem in $serviceName.", - extraContext = Map("service_name" -> serviceName), - ) -} diff --git a/canton/base/errors/src/test/scala/com/digitalasset/base/error/utils/DecodedCantonErrorSpec.scala b/canton/base/errors/src/test/scala/com/digitalasset/base/error/utils/DecodedCantonErrorSpec.scala deleted file mode 100644 index 3b733b38bb..0000000000 --- a/canton/base/errors/src/test/scala/com/digitalasset/base/error/utils/DecodedCantonErrorSpec.scala +++ /dev/null @@ -1,199 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error.utils - -import com.digitalasset.base.error.{ - BaseError, - BaseErrorLogger, - DamlErrorWithDefiniteAnswer, - ErrorCategory, - ErrorClass, - ErrorCode, - ErrorResource, - NoBaseLogging, -} -import com.google.protobuf.any.Any -import com.google.protobuf.any.Any.toJavaProto -import com.google.rpc.error_details.{ErrorInfo, RequestInfo, ResourceInfo, RetryInfo} -import com.google.rpc.status.Status as RpcStatus -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers -import org.scalatest.{Assertion, EitherValues, OptionValues} -import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks -import scalapb.{GeneratedMessage, GeneratedMessageCompanion} - -import ErrorDetails.ErrorDetail - -class DecodedCantonErrorSpec - extends AnyFlatSpec - with Matchers - with EitherValues - with OptionValues - with ScalaCheckPropertyChecks { - behavior of DecodedCantonError.getClass.getSimpleName - - it should s"correctly deserialize a gRPC status code to a ${BaseError.getClass.getSimpleName}" in - forAll(errorCategoriesTable) { errorCategory => - forAll(propertyMapTable) { propertyMap => - forAll(correlationIdTable) { correlationId => - forAll(traceIdTable) { traceId => - val fromGrpc: RpcStatus => DecodedCantonError = - DecodedCantonError.fromGrpcStatus(_).value - val toGrpc: DecodedCantonError => RpcStatus = _.toRpcStatusWithForwardedRequestId - - val someGrpcStatus = - createGrpcStatus(errorCategory, propertyMap, correlationId, traceId) - - checkEquivalence(someGrpcStatus, toGrpc(fromGrpc(someGrpcStatus))) - } - } - } - } - - it should "fallback to the original cause if error message format is not recognized" in { - implicit val contextErrorLogger: BaseErrorLogger = NoBaseLogging - - val nonStandardMessage = "!!!Some non-standard message" - val error = BenignError.Reject("nvm").rpcStatus().copy(message = nonStandardMessage) - val deserializedError = DecodedCantonError.fromGrpcStatus(error).value - - deserializedError.cause shouldBe nonStandardMessage - } - - private val errorGrpcStatus = - createGrpcStatus(ErrorCategory.TransientServerFailure, Map("1" -> "2"), Some("c"), Some("t")) - - it should "return a Left on missing category information in error metadata" in { - val errInfo = errorGrpcStatus.details.find(_ is ErrorInfo).value.unpack[ErrorInfo] - val modifiedErrInfo = errInfo.copy(metadata = errInfo.metadata.removed("category")) - val modifiedErrorGrpcStatus = errorGrpcStatus.copy(details = - errorGrpcStatus.details.filterNot(_ is ErrorInfo) :+ Any.pack(modifiedErrInfo) - ) - - DecodedCantonError.fromGrpcStatus(modifiedErrorGrpcStatus).left.value should include( - "category key not found in error metadata" - ) - } - - it should s"return a Left on invalid number of ${classOf[ErrorInfo].getSimpleName} in the gRPC status" in { - testErrorDetails[ErrorInfo]( - "exactly one", - Map(0 -> false, 1 -> true, 2 -> false), - ) - } - - it should s"return a Left on invalid number of ${classOf[RequestInfo].getSimpleName} in the gRPC status" in { - testErrorDetails[RequestInfo]("at most one", Map(0 -> true, 1 -> true, 2 -> false)) - } - - it should s"return a Left on invalid number of ${classOf[RetryInfo].getSimpleName} in the gRPC status" in { - testErrorDetails[RetryInfo]("at most one", Map(0 -> true, 1 -> true, 2 -> false)) - } - - it should s"allow any number of ${classOf[ResourceInfo].getSimpleName} in the gRPC status" in { - testErrorDetails[ResourceInfo]("doesn't matter", Map(0 -> true, 1 -> true, 2 -> true)) - } - - it should s"use ${ErrorCategory.UnredactedSecurityAlert} for an unredacted security alert" in { - val status = - createGrpcStatus(ErrorCategory.UnredactedSecurityAlert, Map.empty, Some("c"), Some("t")) - val decoded = DecodedCantonError.fromGrpcStatus(status).value - decoded.code.code.category shouldBe ErrorCategory.UnredactedSecurityAlert - } - - it should s"handle redacted security alerts" in { - val status = createGrpcStatus(ErrorCategory.SecurityAlert, Map.empty, Some("c"), Some("t")) - val decoded = DecodedCantonError.fromGrpcStatus(status).value - decoded.cause shouldBe "A security-sensitive error has been received" - decoded.correlationId shouldBe Some("c") - decoded.traceId shouldBe Some("t") - } - - private def testErrorDetails[T <: GeneratedMessage]( - arityMsg: String, - allowedNumbers: Map[Int, Boolean], - )(implicit - expectedTypeCompanion: GeneratedMessageCompanion[T] - ): Assertion = { - allowedNumbers.foreach { case (times, expectPass) => - val errorDetails = errorGrpcStatus.details - val errDetail = errorDetails.find(_ is expectedTypeCompanion).value - val modifiedStatus = errorGrpcStatus.copy(details = - errorDetails.filterNot(_ is expectedTypeCompanion) ++ (1 to times).map(_ => errDetail) - ) - - val actual = DecodedCantonError.fromGrpcStatus(modifiedStatus) - - if (expectPass) - actual.isRight shouldBe true - else - actual shouldBe Left( - s"Could not extract error detail. Expected $arityMsg ${expectedTypeCompanion.scalaDescriptor.fullName} in status details, but got $times" - ) - } - succeed - } - - private def createGrpcStatus( - errorCategory: ErrorCategory, - propertyMap: Map[String, String], - correlationId: Option[String], - traceId: Option[String], - ) = { - implicit val contextErrorLogger: NoBaseLogging = - new NoBaseLogging(propertyMap, correlationId, traceId) - implicit val errorCode: ErrorCode = - new ErrorCode("SOME_ERROR_CODE_ID", errorCategory)(ErrorClass(List.empty)) {} - { - new DamlErrorWithDefiniteAnswer( - cause = "Some cause", - throwableO = Some(new RuntimeException("oups")), - definiteAnswer = true, - extraContext = Map("key" -> "val"), - ) { - override def resources: Seq[(ErrorResource, String)] = - super.resources :+ (ErrorResource.CommandId -> "some-cmd-id") - } - }.rpcStatus() - } - - private def propertyMapTable = Table( - "property map", - Map("p1" -> "k1", "p2" -> "k2"), - Map("p1" -> "k1"), - Map.empty[String, String], - ) - - private def correlationIdTable = Table( - "correlation id", - Some("corr-id"), - None, - ) - - private def traceIdTable = Table("trace id", Some("trace-id"), None) - - private def errorCategoriesTable = { - val grpcAwareErrorCats = ErrorCategory.all.filter(_.grpcCode.nonEmpty) - Table( - "error category", - grpcAwareErrorCats* - ) - } - - // Manual equality check for rpc statuses since encoding of maps (error details) - // in ByteString is non-deterministic - private def checkEquivalence( - rpcStatusOriginal: RpcStatus, - rpcStatusFromReconstructedError: RpcStatus, - ): Assertion = { - def refine(details: Seq[com.google.protobuf.any.Any]): Seq[ErrorDetail] = - ErrorDetails.from(details.map(toJavaProto)) - - rpcStatusFromReconstructedError.code shouldBe rpcStatusOriginal.code - rpcStatusFromReconstructedError.message shouldBe rpcStatusOriginal.message - val recontructedErrorDetails = refine(rpcStatusFromReconstructedError.details) - val originalErrorDetails = refine(rpcStatusOriginal.details) - recontructedErrorDetails shouldBe originalErrorDetails - } -} diff --git a/canton/base/errors/src/test/scala/com/digitalasset/base/error/utils/ErrorDetailsSpec.scala b/canton/base/errors/src/test/scala/com/digitalasset/base/error/utils/ErrorDetailsSpec.scala deleted file mode 100644 index 0bc5c267c1..0000000000 --- a/canton/base/errors/src/test/scala/com/digitalasset/base/error/utils/ErrorDetailsSpec.scala +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error.utils - -import com.digitalasset.base.error.ErrorCategory.BackgroundProcessDegradationWarning -import com.digitalasset.base.error.{ErrorClass, ErrorCode, NoBaseLogging} -import com.google.protobuf -import io.grpc.{Status, StatusRuntimeException} -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers - -import scala.concurrent.duration.* - -class ErrorDetailsSpec extends AnyFlatSpec with Matchers { - - private val errorLogger = NoBaseLogging - - behavior of classOf[ErrorDetails.type].getName - - it should "correctly match exception to error codes " in { - val securitySensitive = - SevereError.Reject("some internal failure")(errorLogger).asGrpcError - val notSecuritySensitive = - BenignError.Reject("some service")(errorLogger).asGrpcError - - ErrorDetails.matches( - securitySensitive, - SevereError, - ) shouldBe false - - ErrorDetails.matches( - notSecuritySensitive, - BenignError, - ) shouldBe true - - ErrorDetails.matches( - new StatusRuntimeException(Status.ABORTED), - BenignError, - ) shouldBe false - - ErrorDetails.matches( - new Exception, - BenignError, - ) shouldBe false - - object NonGrpcErrorCode - extends ErrorCode( - id = "NON_GRPC_ERROR_CODE_123", - BackgroundProcessDegradationWarning, - )(ErrorClass.root()) - NonGrpcErrorCode.category.grpcCode shouldBe empty - ErrorDetails.matches( - new StatusRuntimeException(Status.ABORTED), - NonGrpcErrorCode, - ) shouldBe false - } - - it should "should preserve details when going through grpc Any" in { - val details = Seq( - ErrorDetails - .ErrorInfoDetail(errorCodeId = "errorCodeId1", metadata = Map("a" -> "b", "c" -> "d")), - ErrorDetails.ResourceInfoDetail(name = "name1", typ = "type1"), - ErrorDetails.RequestInfoDetail(correlationId = "correlationId1"), - ErrorDetails.RetryInfoDetail(1.seconds + 2.milliseconds), - ) - val anys: Seq[protobuf.Any] = details.map(_.toRpcAny) - ErrorDetails.from(anys) shouldBe details - } -} diff --git a/canton/base/errors/src/test/scala/com/digitalasset/base/error/utils/SevereError.scala b/canton/base/errors/src/test/scala/com/digitalasset/base/error/utils/SevereError.scala deleted file mode 100644 index 8f81b700f8..0000000000 --- a/canton/base/errors/src/test/scala/com/digitalasset/base/error/utils/SevereError.scala +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.base.error.utils - -import com.digitalasset.base.error.{ - BaseErrorLogger, - DamlErrorWithDefiniteAnswer, - ErrorCategory, - ErrorClass, - ErrorCode, - Explanation, - Resolution, -} - -@Explanation("Things happen.") -@Resolution("Turn it off and on again.") -case object SevereError - extends ErrorCode("BLUE_SCREEN", ErrorCategory.SystemInternalAssumptionViolated)( - ErrorClass.root() - ) { - final case class Reject( - message: String, - override val throwableO: Option[Throwable] = None, - )(implicit - loggingContext: BaseErrorLogger - ) extends DamlErrorWithDefiniteAnswer( - cause = message, - extraContext = Map("throwableO" -> throwableO.toString), - ) -} diff --git a/canton/base/testing-utils/src/main/resources/logback-test.xml b/canton/base/testing-utils/src/main/resources/logback-test.xml new file mode 100644 index 0000000000..bab07ca00c --- /dev/null +++ b/canton/base/testing-utils/src/main/resources/logback-test.xml @@ -0,0 +1,61 @@ + + + + + true + + + + + + %highlight(%-5level %logger{10} %replace(tid:%mdc{trace-id} ){'tid: ', ''}- %msg%replace(, context: %marker){', context: $', ''}%replace( err-context:%mdc{err-context} ){' err-context: ', ''}%n) + + + WARN + + + + + ${LOG_FILE_NAME:-log/canton_test.log} + ${LOG_APPEND:-true} + + + %date [%thread] %-5level %logger{10} %replace(tid:%mdc{trace-id} ){'tid: ', ''}- %msg%replace(, context: %marker){', context: $', ''}%replace( err-context:%mdc{err-context} ){' err-context: ', ''}%n + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/canton/base/testing-utils/src/main/scala/com/daml/testing/utils/GrpcServerResource.scala b/canton/base/testing-utils/src/main/scala/com/daml/testing/utils/GrpcServerResource.scala index 6ae3d9fc47..81820186d6 100644 --- a/canton/base/testing-utils/src/main/scala/com/daml/testing/utils/GrpcServerResource.scala +++ b/canton/base/testing-utils/src/main/scala/com/daml/testing/utils/GrpcServerResource.scala @@ -6,6 +6,7 @@ package com.daml.testing.utils import io.grpc.* import java.net.SocketAddress +import java.util.UUID import java.util.concurrent.TimeUnit final class GrpcServerResource( @@ -17,7 +18,11 @@ final class GrpcServerResource( override protected def construct(): ServerWithChannelProvider = { boundServices = services() - ServerWithChannelProvider.fromServices(boundServices, port, "server") + ServerWithChannelProvider.fromServices( + boundServices, + port, + s"server_${UUID.randomUUID().toString}", + ) } override protected def destruct(resource: ServerWithChannelProvider): Unit = { diff --git a/canton/base/util-external/src/main/scala/com/digitalasset/canton/config/ConfidentialConfigWriter.scala b/canton/base/util-external/src/main/scala/com/digitalasset/canton/config/ConfidentialConfigWriter.scala deleted file mode 100644 index d6d9216043..0000000000 --- a/canton/base/util-external/src/main/scala/com/digitalasset/canton/config/ConfidentialConfigWriter.scala +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.canton.config - -import pureconfig.ConfigWriter -import pureconfig.generic.DerivedConfigWriter -import pureconfig.generic.semiauto.deriveWriter -import shapeless.Lazy - -class ConfidentialConfigWriter(confidential: Boolean) { - def apply[Config]( - map: Config => Config - )(implicit writer: Lazy[DerivedConfigWriter[Config]]): ConfigWriter[Config] = { - val parent = deriveWriter[Config] - if (confidential) - (a: Config) => parent.to(map(a)) - else - parent - } -} diff --git a/canton/base/util-external/src/main/scala/com/digitalasset/canton/config/KeyStoreConfig.scala b/canton/base/util-external/src/main/scala/com/digitalasset/canton/config/KeyStoreConfig.scala deleted file mode 100644 index a288005939..0000000000 --- a/canton/base/util-external/src/main/scala/com/digitalasset/canton/config/KeyStoreConfig.scala +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.canton.config - -import pureconfig.ConfigReader -import pureconfig.generic.semiauto.deriveReader - -/** Password wrapper for keystores to prevent the values being printed in logs. - * @param pw - * password value - public for supporting PureConfig parsing but callers should prefer accessing - * through unwrap - */ -final case class Password(pw: String) extends AnyVal { - def unwrap: String = pw - - def toCharArray: Array[Char] = pw.toCharArray - - // We do not want to print out the password in log files - override def toString: String = s"Password(****)" -} - -object Password { - implicit val passwordReader: ConfigReader[Password] = deriveReader[Password] - - def empty: Password = Password("") -} diff --git a/canton/base/util-external/src/main/scala/com/digitalasset/canton/config/PemFileOrString.scala b/canton/base/util-external/src/main/scala/com/digitalasset/canton/config/PemFileOrString.scala deleted file mode 100644 index 9ae2c05b92..0000000000 --- a/canton/base/util-external/src/main/scala/com/digitalasset/canton/config/PemFileOrString.scala +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.canton.config - -import better.files.* -import com.digitalasset.canton.config.RequireTypes.ExistingFile -import com.google.protobuf.ByteString - -/** A class representing either an existing PEM file path or an inlined PEM string for configuration - * file fields - */ -sealed trait PemFileOrString { - def pemBytes: ByteString - def pemStream: java.io.InputStream = pemBytes.newInput() -} - -/** A class representing an existing PEM file path for configuration file fields - */ -final case class PemFile(pemFile: ExistingFile) extends PemFileOrString { - override lazy val pemBytes: ByteString = - ByteString.copyFrom(File(pemFile.unwrap.getAbsolutePath).loadBytes) -} - -/** A class representing an inlined PEM string for configuration file fields - */ -final case class PemString(override val pemBytes: ByteString) extends PemFileOrString { - lazy val pemString: String = pemBytes.toStringUtf8 -} - -object PemString { - def apply(pemString: String): PemString = new PemString(ByteString.copyFromUtf8(pemString)) -} diff --git a/canton/base/util-external/src/main/scala/com/digitalasset/canton/config/RequireTypes.scala b/canton/base/util-external/src/main/scala/com/digitalasset/canton/config/RequireTypes.scala deleted file mode 100644 index 4b50bcba6f..0000000000 --- a/canton/base/util-external/src/main/scala/com/digitalasset/canton/config/RequireTypes.scala +++ /dev/null @@ -1,418 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.canton.config - -import cats.Monoid -import cats.syntax.either.* -import com.digitalasset.canton.config.RequireTypes.NonNegativeNumeric.SubtractionResult -import pureconfig.error.{CannotConvert, FailureReason} -import pureconfig.{ConfigReader, ConfigWriter} -import slick.jdbc.{GetResult, SetParameter} - -import java.io.File - -/** Encapsulates those classes and their utility methods which enforce a given invariant via the use - * of require. - */ -object RequireTypes { - final case class InvariantViolation(message: String) - - final case class Port private (private val n: Int) extends Ordered[Port] { - def unwrap: Int = n - - require( - n >= Port.minValidPort && n <= Port.maxValidPort, - s"Unable to create Port as value $n was given, but only values between ${Port.minValidPort} and ${Port.maxValidPort} (inclusive) are allowed.", - ) - - def +(n: Int): Port = Port.tryCreate(this.unwrap + n) - override def compare(that: Port): Int = this.unwrap compare that.unwrap - - override def toString: String = n.toString - } - - object Port { - val (minValidPort, maxValidPort) = (0, scala.math.pow(2, 16).toInt - 1) - - def create(n: Int): Either[InvariantViolation, Port] = - Either.cond( - n >= Port.minValidPort && n <= Port.maxValidPort, - new Port(n), - InvariantViolation( - s"Unable to create Port as value $n was given, but only values between ${Port.minValidPort} and ${Port.maxValidPort} are allowed." - ), - ) - - def tryCreate(n: Int): Port = - new Port(n) - - lazy implicit val portReader: ConfigReader[Port] = - ConfigReader.fromString[Port] { str => - def err(message: String) = - CannotConvert(str, Port.getClass.getName, message) - - Either - .catchOnly[NumberFormatException](str.toInt) - .leftMap[FailureReason](error => err(error.getMessage)) - .flatMap(n => create(n).leftMap(_ => InvalidPort(n))) - } - - implicit val portWriter: ConfigWriter[Port] = ConfigWriter.toString(x => x.unwrap.toString) - - final case class InvalidPort(n: Int) extends FailureReason { - override def description: String = - s"Unable to create Port as value $n was given, but only values between ${Port.minValidPort} and ${Port.maxValidPort} are allowed" - } - - /** This instructs the server to automatically choose a free port. - */ - lazy val Dynamic: Port = Port.tryCreate(0) - } - - sealed trait RefinedNumeric[T] extends Ordered[RefinedNumeric[T]] { - protected def value: T - implicit def num: Numeric[T] - - def unwrap: T = value - - override def compare(that: RefinedNumeric[T]): Int = num.compare(value, that.value) - - override def toString: String = value.toString - } - - final case class NonNegativeNumeric[T] private (value: T)(implicit val num: Numeric[T]) - extends RefinedNumeric[T] { - import num.* - - def map[U](f: T => U)(implicit num: Numeric[U]) = NonNegativeNumeric.tryCreate(f(value)) - def increment: PositiveNumeric[T] = PositiveNumeric.tryCreate(value + num.one) - def toPositiveNumeric: Option[PositiveNumeric[T]] = PositiveNumeric.create(value).toOption - - def +(other: NonNegativeNumeric[T]): NonNegativeNumeric[T] = - NonNegativeNumeric.tryCreate(value + other.value) - def *(other: NonNegativeNumeric[T]): NonNegativeNumeric[T] = - NonNegativeNumeric.tryCreate(value * other.value) - def /(other: NonNegativeNumeric[T])(implicit fractional: Fractional[T]): NonNegativeNumeric[T] = - NonNegativeNumeric.tryCreate(fractional.div(value, other.value)) - def tryAdd(other: T): NonNegativeNumeric[T] = NonNegativeNumeric.tryCreate(value + other) - - /** Subtract other from this. Subtracts as much as possible of "other" from "this" such that - * "this" stays >= 0. Any remaining amount will be the remainder. e.g: - * {{{ - * NonNegativeNumeric(5).subtract(NonNegativeNumeric(3)) == SubtractionResult(NonNegativeNumeric(2), NonNegativeNumeric(0)) - * NonNegativeNumeric(2).subtract(NonNegativeNumeric(3)) == SubtractionResult(NonNegativeNumeric(0), NonNegativeNumeric(1)) - * }}} - * @param other - * value to subtract to this - */ - def subtract(other: NonNegativeNumeric[T]): SubtractionResult[T] = { - val difference = value - other.value - if (difference < num.zero) { - SubtractionResult(NonNegativeNumeric(num.zero), NonNegativeNumeric(-difference)) - } else { - SubtractionResult(NonNegativeNumeric.tryCreate(difference), NonNegativeNumeric(num.zero)) - } - } - } - - object NonNegativeNumeric { - - final case class SubtractionResult[T]( - result: NonNegativeNumeric[T], - remainder: NonNegativeNumeric[T], - ) - def tryCreate[T](t: T)(implicit num: Numeric[T]): NonNegativeNumeric[T] = - create(t).valueOr(err => throw new IllegalArgumentException(err.message)) - - def create[T]( - t: T - )(implicit num: Numeric[T]): Either[InvariantViolation, NonNegativeNumeric[T]] = - Either.cond( - num.compare(t, num.zero) >= 0, - NonNegativeNumeric(t), - InvariantViolation( - s"Received the negative $t as argument, but we require a non-negative value here." - ), - ) - - implicit val readNonNegativeLong: GetResult[NonNegativeLong] = GetResult { r => - NonNegativeLong.tryCreate(r.nextLong()) - } - - implicit val readNonNegativeInt: GetResult[NonNegativeInt] = GetResult { r => - NonNegativeInt.tryCreate(r.nextInt()) - } - - implicit val readNonNegativeIntOption: GetResult[Option[NonNegativeInt]] = GetResult { r => - r.nextIntOption().map(NonNegativeInt.tryCreate) - } - - implicit val readNonNegativeLongOption: GetResult[Option[NonNegativeLong]] = GetResult { r => - r.nextLongOption().map(NonNegativeLong.tryCreate) - } - - implicit def writeNonNegativeNumeric[T](implicit - f: SetParameter[T] - ): SetParameter[NonNegativeNumeric[T]] = - (s, pp) => { - pp >> s.unwrap - } - - implicit def writeNonNegativeNumericOption[T](implicit - f: SetParameter[Option[T]] - ): SetParameter[Option[NonNegativeNumeric[T]]] = (s, pp) => { - pp >> s.map(_.unwrap) - } - - implicit def nonNegativeNumericReader[T](implicit - num: Numeric[T] - ): ConfigReader[NonNegativeNumeric[T]] = - ConfigReader.fromString[NonNegativeNumeric[T]] { str => - def err(message: String) = - CannotConvert(str, NonNegativeNumeric.getClass.getName, message) - - num - .parseString(str) - .toRight[FailureReason](err("Cannot convert `str` to numeric")) - .flatMap(n => Either.cond(num.compare(n, num.zero) >= 0, tryCreate(n), NegativeValue(n))) - } - - implicit def nonNegativeNumericWriter[T]: ConfigWriter[NonNegativeNumeric[T]] = - ConfigWriter.toString(x => x.unwrap.toString) - - final case class NegativeValue[T](t: T) extends FailureReason { - override def description: String = - s"The value you gave for this configuration setting ($t) was negative, but we require a non-negative value for this configuration setting" - } - } - - type NonNegativeInt = NonNegativeNumeric[Int] - - object NonNegativeInt { - lazy val zero: NonNegativeInt = NonNegativeInt.tryCreate(0) - lazy val one: NonNegativeInt = NonNegativeInt.tryCreate(1) - lazy val two: NonNegativeInt = NonNegativeInt.tryCreate(2) - lazy val three: NonNegativeInt = NonNegativeInt.tryCreate(3) - lazy val maxValue: NonNegativeInt = NonNegativeInt.tryCreate(Int.MaxValue) - - def create(n: Int): Either[InvariantViolation, NonNegativeInt] = NonNegativeNumeric.create(n) - def tryCreate(n: Int): NonNegativeInt = NonNegativeNumeric.tryCreate(n) - def size[T](collection: Iterable[T]): NonNegativeInt = tryCreate(collection.size) - } - - type NonNegativeDouble = NonNegativeNumeric[Double] - - object NonNegativeDouble { - lazy val zero: NonNegativeDouble = NonNegativeDouble.tryCreate(0.0) - lazy val one: NonNegativeDouble = NonNegativeDouble.tryCreate(1.0) - lazy val maxValue: NonNegativeDouble = NonNegativeDouble.tryCreate(Double.MaxValue) - - def create(n: Double): Either[InvariantViolation, NonNegativeDouble] = - NonNegativeNumeric.create(n) - def tryCreate(n: Double): NonNegativeDouble = NonNegativeNumeric.tryCreate(n) - } - - final case class NonNegativeProportion(n: NonNegativeDouble) { - require(n <= NonNegativeDouble.one, "proportion may not be larger than 1") - } - - object NonNegativeProportion { - lazy val zero: NonNegativeProportion = NonNegativeProportion(NonNegativeDouble.zero) - } - - type NonNegativeLong = NonNegativeNumeric[Long] - - object NonNegativeLong { - implicit val nonNegativeLongMonoid: Monoid[NonNegativeLong] = new Monoid[NonNegativeLong] { - override def empty: NonNegativeLong = NonNegativeLong.zero - override def combine(x: NonNegativeLong, y: NonNegativeLong): NonNegativeLong = x + y - } - lazy val zero: NonNegativeLong = NonNegativeLong.tryCreate(0) - lazy val one: NonNegativeLong = NonNegativeLong.tryCreate(1) - lazy val maxValue: NonNegativeLong = NonNegativeLong.tryCreate(Long.MaxValue) - - def create(n: Long): Either[InvariantViolation, NonNegativeLong] = NonNegativeNumeric.create(n) - def tryCreate(n: Long): NonNegativeLong = NonNegativeNumeric.tryCreate(n) - def size[T](collection: Iterable[T]): NonNegativeLong = tryCreate(collection.size.toLong) - } - - final case class PositiveNumeric[T] private (value: T)(implicit val num: Numeric[T]) - extends RefinedNumeric[T] { - import num.* - - def +(other: PositiveNumeric[T]): PositiveNumeric[T] = - PositiveNumeric.tryCreate(value + other.value) - - def +(other: NonNegativeNumeric[T]): PositiveNumeric[T] = - PositiveNumeric.tryCreate(value + other.value) - - def *(other: PositiveNumeric[T]): PositiveNumeric[T] = - PositiveNumeric.tryCreate(value * other.value) - - def max(other: PositiveNumeric[T]): PositiveNumeric[T] = - PositiveNumeric.tryCreate(num.max(value, other.value)) - - def increment: PositiveNumeric[T] = PositiveNumeric.tryCreate(value + num.one) - def decrement: NonNegativeNumeric[T] = NonNegativeNumeric.tryCreate(value - num.one) - - def tryAdd(other: T): PositiveNumeric[T] = PositiveNumeric.tryCreate(value + other) - - def toNonNegative: NonNegativeNumeric[T] = - NonNegativeNumeric.tryCreate(value) // always possible to convert positive to non negative num - } - - type PositiveInt = PositiveNumeric[Int] - - object PositiveInt { - def create(n: Int): Either[InvariantViolation, PositiveInt] = PositiveNumeric.create(n) - def tryCreate(n: Int): PositiveInt = PositiveNumeric.tryCreate(n) - - lazy val one: PositiveInt = PositiveInt.tryCreate(1) - lazy val two: PositiveInt = PositiveInt.tryCreate(2) - lazy val three: PositiveInt = PositiveInt.tryCreate(3) - lazy val four: PositiveInt = PositiveInt.tryCreate(4) - lazy val MaxValue: PositiveInt = PositiveInt.tryCreate(Int.MaxValue) - } - - type PositiveLong = PositiveNumeric[Long] - - object PositiveLong { - def create(n: Long): Either[InvariantViolation, PositiveLong] = PositiveNumeric.create(n) - def tryCreate(n: Long): PositiveLong = PositiveNumeric.tryCreate(n) - - lazy val one: PositiveLong = PositiveLong.tryCreate(1) - lazy val MaxValue: PositiveLong = PositiveLong.tryCreate(Long.MaxValue) - } - - type PositiveDouble = PositiveNumeric[Double] - object PositiveDouble { - def create(n: Double): Either[InvariantViolation, PositiveDouble] = PositiveNumeric.create(n) - def tryCreate(n: Double): PositiveDouble = PositiveNumeric.tryCreate(n) - } - - object PositiveNumeric { - def tryCreate[T](t: T)(implicit num: Numeric[T]): PositiveNumeric[T] = - create(t).valueOr(err => throw new IllegalArgumentException(err.message)) - - def create[T]( - t: T - )(implicit num: Numeric[T]): Either[InvariantViolation, PositiveNumeric[T]] = - Either.cond( - num.compare(t, num.zero) > 0, - PositiveNumeric(t), - InvariantViolation( - s"Received the non-positive $t as argument, but we require a positive value here." - ), - ) - - implicit def positiveNumericReader[T](implicit - num: Numeric[T], - tReader: ConfigReader[T], - ): ConfigReader[PositiveNumeric[T]] = - tReader.emap(n => - Either.cond(num.compare(n, num.zero) >= 0, tryCreate(n), NonPositiveValue(n)) - ) - - implicit def readPositiveDouble: GetResult[PositiveDouble] = GetResult { r => - PositiveNumeric.tryCreate(r.nextDouble()) - } - - implicit def writePositiveNumeric[T](implicit - f: SetParameter[T] - ): SetParameter[PositiveNumeric[T]] = - (s, pp) => { - pp >> s.unwrap - } - - implicit def positiveNumericWriter[T](implicit - tWriter: ConfigWriter[T] - ): ConfigWriter[PositiveNumeric[T]] = - tWriter.contramap[PositiveNumeric[T]](pn => pn.value) - - final case class NonPositiveValue[T](t: T) extends FailureReason { - override def description: String = - s"The value you gave for this configuration setting ($t) was non-positive, but we require a positive value for this configuration setting" - } - } - - final case class DoubleGreaterEqual1 private (override val value: Double) - extends RefinedNumeric[Double] { - override implicit def num: Numeric[Double] = Numeric.DoubleIsFractional - } - - object DoubleGreaterEqual1 { - def tryCreate(t: Double): DoubleGreaterEqual1 = - create(t).valueOr(err => throw new IllegalArgumentException(err.message)) - - def create( - value: Double - ): Either[InvariantViolation, DoubleGreaterEqual1] = - Either.cond( - value >= 1.0, - DoubleGreaterEqual1(value), - InvariantViolation( - s"Received $value < 1 as argument, but we require a value greter or equal to 1." - ), - ) - - implicit def doubleGreaterThanEqual1Reader[T]: ConfigReader[DoubleGreaterEqual1] = - ConfigReader.fromString[DoubleGreaterEqual1] { str => - def err(message: String) = - CannotConvert(str, NonNegativeNumeric.getClass.getName, message) - - Numeric[Double] - .parseString(str) - .toRight[FailureReason](err("Cannot convert `str` to numeric")) - .flatMap(n => Either.cond(n >= 1, tryCreate(n), LessThan1Value(n))) - } - - implicit def doubleGreaterThanEqual1Writer[T]: ConfigWriter[DoubleGreaterEqual1] = - ConfigWriter.toString(x => x.unwrap.toString) - - final case class LessThan1Value[T](t: T) extends FailureReason { - override def description: String = - s"The value you gave for this configuration setting ($t) was less than one, but we require a value >= 1 for this configuration setting" - } - } - - final case class ExistingFile private (private val file: File) { - def unwrap: File = file - require(file.exists(), s"Unable to create ExistingFile as non-existing file $file was given.") - } - - object ExistingFile { - - def create(file: File): Either[InvariantViolation, ExistingFile] = - Either.cond( - file.exists, - new ExistingFile(file), - InvariantViolation( - s"The specified file $file does not exist/was not found. Please specify an existing file" - ), - ) - - def tryCreate(file: File): ExistingFile = - new ExistingFile(file) - - def tryCreate(path: String): ExistingFile = - new ExistingFile(new File(path)) - - lazy implicit val existingFileReader: ConfigReader[ExistingFile] = - ConfigReader.fromString[ExistingFile] { str => - def err(message: String) = - CannotConvert(str, ExistingFile.getClass.getName, message) - - Either - .catchOnly[NullPointerException](new File(str)) - .leftMap[FailureReason](error => err(error.getMessage)) - .flatMap(f => create(f).leftMap(_ => NonExistingFile(f))) - } - - final case class NonExistingFile(file: File) extends FailureReason { - override def description: String = - s"The specified file $file does not exist/was not found. Please specify an existing file" - } - } - -} diff --git a/canton/base/util-external/src/main/scala/com/digitalasset/canton/discard/Implicits.scala b/canton/base/util-external/src/main/scala/com/digitalasset/canton/discard/Implicits.scala deleted file mode 100644 index 03d2794bf0..0000000000 --- a/canton/base/util-external/src/main/scala/com/digitalasset/canton/discard/Implicits.scala +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.canton.discard - -object Implicits { - - /** Evaluate the expression and discard the result. */ - implicit final class DiscardOps[A](private val a: A) extends AnyVal { - @inline - def discard[B](implicit ev: A =:= B): Unit = () - } - -} diff --git a/canton/base/util-external/src/main/scala/com/digitalasset/canton/time/TimeProvider.scala b/canton/base/util-external/src/main/scala/com/digitalasset/canton/time/TimeProvider.scala deleted file mode 100644 index dd02c40567..0000000000 --- a/canton/base/util-external/src/main/scala/com/digitalasset/canton/time/TimeProvider.scala +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.canton.time - -trait TimeProvider { - - /** Potentially non-monotonic time provider - */ - def nowInMicrosecondsSinceEpoch: Long - -} diff --git a/canton/base/util-external/src/main/scala/com/digitalasset/canton/util/BytesUnit.scala b/canton/base/util-external/src/main/scala/com/digitalasset/canton/util/BytesUnit.scala deleted file mode 100644 index 4bd17b01b7..0000000000 --- a/canton/base/util-external/src/main/scala/com/digitalasset/canton/util/BytesUnit.scala +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.canton.util - -import scala.math.Numeric.LongIsIntegral - -final case class BytesUnit(bytes: Long) { - - def *(that: Long): BytesUnit = BytesUnit(this.bytes * that) - - override def toString: String = { - val factorL = BytesUnit.factor - val factorD = BytesUnit.factor.toDouble - val (convertedValue: Double, unit) = bytes match { - case v if v < factorL => (v.toDouble, "B") - case v if v >= factorL && v < factorL * factorL => (v / factorD, "KB") - case v if v >= factorL * factorL && v < factorL * factorL * factorL => - (v / (factorD * factorD), "MB") - case v => - (v / (factorD * factorD * factorD), "GB") - } - f"$convertedValue%.2f $unit" - } -} - -object BytesUnit { - private[BytesUnit] val factor = 1024L - - val zero: BytesUnit = BytesUnit(0L) - - def KB(value: Long): BytesUnit = BytesUnit(value) * factor - def MB(value: Long): BytesUnit = KB(value) * factor - - implicit val bytesUnitIsNumeric: Numeric[BytesUnit] = new Numeric[BytesUnit] { - override def plus(x: BytesUnit, y: BytesUnit): BytesUnit = BytesUnit(x.bytes + y.bytes) - - override def minus(x: BytesUnit, y: BytesUnit): BytesUnit = BytesUnit(x.bytes - y.bytes) - - override def times(x: BytesUnit, y: BytesUnit): BytesUnit = BytesUnit(x.bytes * y.bytes) - - override def negate(x: BytesUnit): BytesUnit = BytesUnit(-x.bytes) - - override def fromInt(x: Int): BytesUnit = BytesUnit(x.toLong) - - override def parseString(str: String): Option[BytesUnit] = - LongIsIntegral.parseString(str).map(BytesUnit(_)) - - override def toInt(x: BytesUnit): Int = x.bytes.toInt - - override def toLong(x: BytesUnit): Long = x.bytes - - override def toFloat(x: BytesUnit): Float = x.bytes.toFloat - - override def toDouble(x: BytesUnit): Double = x.bytes.toDouble - - override def compare(x: BytesUnit, y: BytesUnit): Int = java.lang.Long.compare(x.bytes, y.bytes) - } - -} diff --git a/canton/base/util-external/src/main/scala/com/digitalasset/canton/util/JarResourceUtils.scala b/canton/base/util-external/src/main/scala/com/digitalasset/canton/util/JarResourceUtils.scala deleted file mode 100644 index bd103fca6f..0000000000 --- a/canton/base/util-external/src/main/scala/com/digitalasset/canton/util/JarResourceUtils.scala +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.canton.util - -import java.io.File -import java.nio.file.{Files, Paths, StandardCopyOption} -import scala.util.Using - -/** Utility methods for loading resource test files. - */ -object JarResourceUtils { - // Extract a resource and store it in a temporary file. - // This method works for resources embedded in a JAR. - def extractFileFromJar(path: String): File = - Using(getClass.getClassLoader.getResourceAsStream(path)) { inputStream => - if (inputStream == null) throw new RuntimeException(s"Resource for $path not found") - - // In case of absolute path, get only the file name - // (to be used as temp file name) - val tmpFileName = new File(path).getName - val tmpFilePath = Files.createTempFile(tmpFileName, "tmp") - Files.copy(inputStream, tmpFilePath, StandardCopyOption.REPLACE_EXISTING) - tmpFilePath.toFile - }.getOrElse(throw new RuntimeException(s"Resource for $path not found")) - - // Use resource file directly wrapping it in a File. This method works in unit tests. - // It doesn't work for resources from a JAR - def resourceFile(path: String): File = - Option(getClass.getClassLoader.getResource(path)) - .map(_.toURI) - .map(Paths.get(_).toFile) - .getOrElse(throw new RuntimeException(s"Resource for $path not found")) -} diff --git a/canton/base/util-external/src/main/scala/com/digitalasset/canton/util/Mutex.scala b/canton/base/util-external/src/main/scala/com/digitalasset/canton/util/Mutex.scala deleted file mode 100644 index 6e47d8f5a5..0000000000 --- a/canton/base/util-external/src/main/scala/com/digitalasset/canton/util/Mutex.scala +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.canton.util - -import com.digitalasset.canton.util.Mutex.MutexBlocker - -import java.lang.Thread.onSpinWait -import java.util.concurrent.ForkJoinPool -import java.util.concurrent.ForkJoinPool.ManagedBlocker -import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.locks.ReentrantLock -import scala.annotation.tailrec - -/** Lock to be used instead of blocking synchronized - * - * The fork join pool is leaking threads potentially with every invocation of blocking. Therefore, - * only invoke it if really necessary. - */ -class Mutex { - - private val lock = new ReentrantLock() - - @deprecated("use exclusive, not synchronize", since = "3.4") - def synchronized[T](body: => T): T = sys.error("use exclusive to distinguish") - - def exclusive[T](f: => T): T = - // We perform lock inflation to reduce the chance of having to use expensive locks - // First level: Immediate acquisition - if (lock.tryLock()) { - try { - f - } finally { - lock.unlock() - } - } else { - // Second level: Spinning - Trying to avoid parking the thread - val maxSpins = Mutex.MaxSpins.get() - @tailrec - def retry(spins: Int): T = - if (spins < maxSpins) { - if (lock.tryLock()) { - try { - f - } finally { - lock.unlock() - } - } else { - onSpinWait() - retry(spins + 1) - } - } else { - // 3. "Heavy Lock" (Inflation) - Trigger FJP compensation - // Only at this point do we notify the ForkJoinPool. - val blocker = new MutexBlocker(lock) - ForkJoinPool.managedBlock(blocker) - try { - f - } finally { - lock.unlock() - } - } - - retry(0) - } -} - -object Mutex { - def apply(): Mutex = new Mutex - val MaxSpins = new AtomicInteger(3000) - // Inner class to handle the official FJP integration - @SuppressWarnings(Array("org.wartremover.warts.Var")) - private class MutexBlocker(lock: ReentrantLock) extends ManagedBlocker { - private var acquired = false - - // FJP calls this to see if it even needs to block/compensate - override def isReleasable: Boolean = { - acquired = lock.tryLock() - acquired - } - - // FJP calls this if isReleasable returned false. - // This is where thread compensation (spawning a new thread) happens. - override def block(): Boolean = { - if (!acquired) { - lock.lock() // This is the heavy-weight park - acquired = true - } - true - } - } -} diff --git a/canton/base/util-external/src/main/scala/com/digitalasset/canton/util/VersionUtil.scala b/canton/base/util-external/src/main/scala/com/digitalasset/canton/util/VersionUtil.scala deleted file mode 100644 index d52ce0b64d..0000000000 --- a/canton/base/util-external/src/main/scala/com/digitalasset/canton/util/VersionUtil.scala +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.canton.util - -import cats.syntax.traverse.* - -object VersionUtil { - def create( - rawVersion: String, - baseName: String, - ): Either[String, (Int, Int, Int, Option[String])] = { - // `?:` removes the capturing group, so we get a cleaner pattern-match statement - val regex = raw"([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,4})(?:-(.*))?".r - - rawVersion match { - case regex(rawMajor, rawMinor, rawPatch, suffix) => - val parsedDigits = List(rawMajor, rawMinor, rawPatch).traverse(raw => - raw.toIntOption.toRight(s"Couldn't parse number `$raw`") - ) - parsedDigits.flatMap { - case List(major, minor, patch) => - // `suffix` is `null` if no suffix is given - Right((major, minor, patch, Option(suffix))) - case _ => Left(s"Unexpected error while parsing version `$rawVersion`") - } - - case _ => - Left( - s"Unable to convert string `$rawVersion` to a valid $baseName. A $baseName is similar to a semantic version. For example, '1.2.3' or '1.2.3-SNAPSHOT' are valid ${baseName}s." - ) - } - } -} diff --git a/canton/base/util-external/src/test/scala/com/digitalasset/canton/config/RequireTypesTest.scala b/canton/base/util-external/src/test/scala/com/digitalasset/canton/config/RequireTypesTest.scala deleted file mode 100644 index 44f964db58..0000000000 --- a/canton/base/util-external/src/test/scala/com/digitalasset/canton/config/RequireTypesTest.scala +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.canton.config - -import com.digitalasset.canton.config.RequireTypes.NonNegativeNumeric -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AnyWordSpec - -class RequireTypesTest extends AnyWordSpec with Matchers { - "NonNegativeNumeric" should { - "subtract" in { - val ten = NonNegativeNumeric.tryCreate(10) - val three = NonNegativeNumeric.tryCreate(3) - val tenMinusThree = ten.subtract(three) - tenMinusThree.result.value shouldBe 7 - tenMinusThree.remainder.value shouldBe 0 - - val threeMinusTen = three.subtract(ten) - threeMinusTen.result.value shouldBe 0 - threeMinusTen.remainder.value shouldBe 7 - } - } -} diff --git a/canton/build.sbt b/canton/build.sbt index 2db3b9fdad..8bc1022332 100644 --- a/canton/build.sbt +++ b/canton/build.sbt @@ -464,6 +464,10 @@ lazy val `model-based-testing-drivers` = CommunityProjects.`model-based-testing-drivers` lazy val `model-based-testing-integration-tests` = CommunityProjects.`model-based-testing-integration-tests` +lazy val `traffic-enforcement-api` = + CommunityProjects.`traffic-enforcement-api` +lazy val `traffic-enforcement-component` = + CommunityProjects.`traffic-enforcement-component` lazy val `scalatest-utils` = DamlProjects.`scalatest-utils` lazy val `scala-utils` = DamlProjects.`scala-utils` diff --git a/canton/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/LedgerApiCommands.scala b/canton/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/LedgerApiCommands.scala index 4ec8a5b3f6..9d02427fa6 100644 --- a/canton/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/LedgerApiCommands.scala +++ b/canton/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/LedgerApiCommands.scala @@ -190,10 +190,18 @@ import com.digitalasset.canton.networking.grpc.ForwardingStreamObserver import com.digitalasset.canton.platform.apiserver.execution.CommandStatus import com.digitalasset.canton.protocol.LfContractId import com.digitalasset.canton.serialization.ProtoConverter +import com.digitalasset.canton.tea.v1.TrafficServiceGrpc.TrafficServiceStub +import com.digitalasset.canton.tea.v1.{ + GetAccountRequest, + GetAccountResponse, + TrafficServiceGrpc, + UpdateAccountRequest, + UpdateAccountResponse, +} import com.digitalasset.canton.topology.transaction.TopologyTransaction.GenericTopologyTransaction import com.digitalasset.canton.topology.{ParticipantId, Party, PartyId, SynchronizerId} import com.digitalasset.canton.util.BinaryFileUtil -import com.digitalasset.canton.{LfPackageId, LfPackageName, LfPartyId} +import com.digitalasset.canton.{LfPackageId, LfPackageName, LfPartyId, config} import com.google.protobuf.empty.Empty import com.google.protobuf.field_mask.FieldMask import io.grpc.* @@ -1747,6 +1755,7 @@ object LedgerApiCommands { minLedgerTimeAbs: Option[Instant], deduplicationPeriod: Option[DeduplicationPeriod], hashingSchemeVersion: HashingSchemeVersion, + optTimeout: Option[config.NonNegativeDuration], ) extends BaseCommand[ ExecuteSubmissionAndWaitRequest, ExecuteSubmissionAndWaitResponse, @@ -1776,7 +1785,8 @@ object LedgerApiCommands { ): Either[String, ExecuteSubmissionAndWaitResponse] = Right(response) - override def timeoutType: TimeoutType = DefaultUnboundedTimeout + override def timeoutType: TimeoutType = + optTimeout.map(CustomClientTimeout(_)).getOrElse(DefaultUnboundedTimeout) } final case class ExecuteAndWaitForTransactionCommand( @@ -1790,6 +1800,7 @@ object LedgerApiCommands { transactionShape: Option[TransactionShape], includeCreatedEventBlob: Boolean, customEventFormat: Option[EventFormat], + optTimeout: Option[config.NonNegativeDuration], ) extends BaseCommand[ ExecuteSubmissionAndWaitForTransactionRequest, ExecuteSubmissionAndWaitForTransactionResponse, @@ -1851,7 +1862,8 @@ object LedgerApiCommands { ): Either[String, ExecuteSubmissionAndWaitForTransactionResponse] = Right(response) - override def timeoutType: TimeoutType = DefaultUnboundedTimeout + override def timeoutType: TimeoutType = + optTimeout.map(CustomClientTimeout(_)).getOrElse(DefaultUnboundedTimeout) } final case class PreferredPackageVersion( @@ -2450,4 +2462,48 @@ object LedgerApiCommands { } } + object Traffic { + + abstract class BaseCommand[Req, Res] extends GrpcAdminCommand[Req, Res, Res] { + override type Svc = TrafficServiceStub + + override def createService(channel: ManagedChannel): TrafficServiceStub = + TrafficServiceGrpc.stub(channel) + + override protected def handleResponse(response: Res): Either[String, Res] = Right(response) + } + + final case class GetAccount(accountId: String) + extends BaseCommand[ + GetAccountRequest, + GetAccountResponse, + ] { + override protected def createRequest(): Either[String, GetAccountRequest] = + Right(GetAccountRequest(accountId)) + + override protected def submitRequest( + service: TrafficServiceStub, + request: GetAccountRequest, + ): Future[GetAccountResponse] = + service.getAccount(request) + } + + final case class UpdateAccount( + accountId: String, + balance: Option[Long], + deduplicationId: String, + ) extends BaseCommand[ + UpdateAccountRequest, + UpdateAccountResponse, + ] { + override protected def createRequest(): Either[String, UpdateAccountRequest] = + Right(UpdateAccountRequest(accountId, balance, deduplicationId)) + + override protected def submitRequest( + service: TrafficServiceStub, + request: UpdateAccountRequest, + ): Future[UpdateAccountResponse] = + service.updateAccount(request) + } + } } diff --git a/canton/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/SequencerBftAdminCommands.scala b/canton/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/SequencerBftAdminCommands.scala index db253637a1..fd997ead63 100644 --- a/canton/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/SequencerBftAdminCommands.scala +++ b/canton/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/SequencerBftAdminCommands.scala @@ -32,6 +32,7 @@ import com.digitalasset.canton.synchronizer.sequencer.block.bftordering.admin.Se endpointToProto, } import com.digitalasset.canton.synchronizer.sequencer.block.bftordering.bindings.p2p.grpc.P2PGrpcNetworking.P2PEndpoint +import com.digitalasset.canton.topology.SequencerId import io.grpc.ManagedChannel import scala.concurrent.Future @@ -100,7 +101,7 @@ object SequencerBftAdminCommands { extends BaseSequencerBftAdministrationCommand[ ListConfiguredEndpointsRequest, ListConfiguredEndpointsResponse, - Seq[P2PEndpoint], + Seq[(P2PEndpoint, Option[SequencerId])], ] { override protected def createRequest(): Either[String, ListConfiguredEndpointsRequest] = Right( @@ -115,10 +116,22 @@ object SequencerBftAdminCommands { override protected def handleResponse( response: ListConfiguredEndpointsResponse - ): Either[String, Seq[P2PEndpoint]] = + ): Either[String, Seq[(P2PEndpoint, Option[SequencerId])]] = response.endpoints - .map(endpointFromProto) + .map { peerEndpointAndSequencerId => + for { + endpoint <- endpointFromProto(peerEndpointAndSequencerId) + sequencerIdO <- peerEndpointAndSequencerId.sequencerId + .map( + SequencerId + .fromProtoPrimitive(_, "sequencerId") + .leftMap(err => s"Failed to parse sequencerId: $err") + ) + .sequence + } yield (endpoint, sequencerIdO) + } .sequence + .leftMap(err => s"Failed to parse response: $err") } final case class GetPeerNetworkStatus(endpoints: Option[Iterable[P2PEndpoint.Id]]) diff --git a/canton/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/data/SynchronizerConnectionConfig.scala b/canton/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/data/SynchronizerConnectionConfig.scala index 6bdf09702e..5debe1cf55 100644 --- a/canton/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/data/SynchronizerConnectionConfig.scala +++ b/canton/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/data/SynchronizerConnectionConfig.scala @@ -243,7 +243,10 @@ final case class SynchronizerConnectionConfig( ) def toInternal: SynchronizerConnectionConfigInternal = - this.transformInto[SynchronizerConnectionConfigInternal] + this + .into[SynchronizerConnectionConfigInternal] + .withFieldRenamed(_.synchronizerId, _.psid) + .transform } object SynchronizerConnectionConfig { @@ -320,5 +323,5 @@ object SynchronizerConnectionConfig { private[canton] def fromInternal( internal: SynchronizerConnectionConfigInternal ): SynchronizerConnectionConfig = - internal.transformInto[SynchronizerConnectionConfig] + internal.into[SynchronizerConnectionConfig].withFieldRenamed(_.psid, _.synchronizerId).transform } diff --git a/canton/community/app-base/src/main/scala/com/digitalasset/canton/config/CantonConfig.scala b/canton/community/app-base/src/main/scala/com/digitalasset/canton/config/CantonConfig.scala index 1f9490006b..098d1a1f53 100644 --- a/canton/community/app-base/src/main/scala/com/digitalasset/canton/config/CantonConfig.scala +++ b/canton/community/app-base/src/main/scala/com/digitalasset/canton/config/CantonConfig.scala @@ -70,6 +70,8 @@ import com.digitalasset.canton.platform.config.{ InteractiveSubmissionServiceConfig, StateServiceConfig, TopologyAwarePackageSelectionConfig, + TrafficEnforcementConfig, + TrafficEnforcementServerConfig, UpdateServiceConfig, } import com.digitalasset.canton.pureconfigutils.SharedConfigReaders.catchConvertError @@ -84,6 +86,7 @@ import com.digitalasset.canton.synchronizer.block.{SequencerDriver, SequencerDri import com.digitalasset.canton.synchronizer.config.{DeclarativeSequencerConfig, PublicServerConfig} import com.digitalasset.canton.synchronizer.mediator.{ DeduplicationStoreConfig, + DelayedVerdictSenderConfig, MediatorConfig, MediatorNodeConfig, MediatorNodeParameterConfig, @@ -99,6 +102,7 @@ import com.digitalasset.canton.synchronizer.sequencer.SequencerConfig.{ import com.digitalasset.canton.synchronizer.sequencer.block.DriverBlockSequencerFactory import com.digitalasset.canton.synchronizer.sequencer.block.bftordering.bindings.canton.sequencing.BftSequencerFactory import com.digitalasset.canton.synchronizer.sequencer.block.bftordering.core.BftBlockOrdererConfig +import com.digitalasset.canton.synchronizer.sequencer.block.bftordering.core.BftBlockOrdererConfig.SequencerCoreSubscriptionConfig import com.digitalasset.canton.synchronizer.sequencer.block.bftordering.framework.data.topology.BlacklistLeaderSelectionPolicyConfig import com.digitalasset.canton.synchronizer.sequencer.config.{ AsyncWriterConfig, @@ -381,13 +385,10 @@ final case class CantonFeatures( enableRepairCommands: Boolean = false, ) { def featureFlags: Set[FeatureFlag] = - (Seq(FeatureFlag.Stable) ++ (if (enableTestingCommands) Seq(FeatureFlag.Testing) - else Seq()) ++ (if (enablePreviewCommands) Seq(FeatureFlag.Preview) - else Seq()) ++ (if (enableRepairCommands) - Seq( - FeatureFlag.Repair - ) - else Seq())).toSet + (Seq(FeatureFlag.Stable) + ++ (if (enableTestingCommands) Seq(FeatureFlag.Testing) else Seq()) + ++ (if (enablePreviewCommands) Seq(FeatureFlag.Preview) else Seq()) + ++ (if (enableRepairCommands) Seq(FeatureFlag.Repair) else Seq())).toSet } /** The commonality between [[CantonConfig]] and `SpliceConfig` in the Splice repo, so that either @@ -498,7 +499,7 @@ trait SharedCantonConfig[Self] extends ConfigDefaults[Option[DefaultPorts], Self commitmentUseDbSnapshotForParticipantLookup = participantParameters.commitmentUseDbSnapshotForParticipantLookup, autoSyncProtocolFeatureFlags = participantParameters.autoSyncProtocolFeatureFlags, - alphaMultiSynchronizerSupport = participantParameters.alphaMultiSynchronizerSupport, + enableAllLedgerApiReassignments = participantParameters.enableAllLedgerApiReassignments, commitAfterFailedActivenessCheck = participantParameters.commitAfterFailedActivenessCheck, validateLegacyContractsV11 = participantParameters.validateLegacyContractsV11, ) @@ -533,11 +534,14 @@ trait SharedCantonConfig[Self] extends ConfigDefaults[Option[DefaultPorts], Self .map(DisasterRecoverySequencingTimeUpperBound(_)), delayRequestsBeforeLsuTrafficInit = sequencerNodeConfig.parameters.delayRequestsBeforeLsuTrafficInit, + enableRejectDeliveredAggregationsOnPv35 = + sequencerNodeConfig.parameters.enableRejectDeliveredAggregationsOnPv35, disableSubmissionChecksForTesting = sequencerNodeConfig.parameters.disableSubmissionChecksForTesting, disableReleaseVersionHandshakeCheck = sequencerNodeConfig.parameters.disableReleaseVersionHandshakeCheck, lsuConfig = sequencerNodeConfig.parameters.lsu, + enablePrevalidation = sequencerNodeConfig.parameters.enablePrevalidation, ) } @@ -553,6 +557,7 @@ trait SharedCantonConfig[Self] extends ConfigDefaults[Option[DefaultPorts], Self MediatorNodeParameters( general = CantonNodeParameterConverter.general(this, mediatorNodeConfig), protocol = CantonNodeParameterConverter.protocol(this, mediatorNodeConfig.parameters), + delayedVerdictSender = mediatorNodeConfig.parameters.delayedVerdictSender, ) } @@ -640,9 +645,8 @@ final case class CantonConfig( /** run a validation on the current config and return possible warning messages */ private def validate( ensurePortsSet: Boolean - ): Validated[NonEmpty[Seq[String]], Unit] = { + ): Validated[NonEmpty[Seq[String]], Unit] = ConfigValidations.validate(this, ensurePortsSet = ensurePortsSet) - } /** Produces a message in the structure * "da:admin-api=1,public-api=2;participant1:admin-api=3,ledger-api=4". Helpful for diagnosing @@ -699,7 +703,7 @@ final case class CantonConfig( .modify(mapWithDefaults) } - def mergeDynamicChanges(newConfig: CantonConfig): CantonConfig = { + override def mergeDynamicChanges(newConfig: CantonConfig): CantonConfig = { def merge[T](cur: Map[InstanceName, T], newConfig: Map[InstanceName, T], merger: (T, T) => T) = cur.map { case (name, config) => (name, newConfig.get(name).map(merger(config, _)).getOrElse(config)) @@ -1226,6 +1230,16 @@ object CantonConfig { lazy implicit val bftBlockOrdererLeaderSelectionPolicyHowLongToBlacklistLinearConfigReader : ConfigReader[BlacklistLeaderSelectionPolicyConfig.HowLongToBlacklist.Linear] = deriveReader[BlacklistLeaderSelectionPolicyConfig.HowLongToBlacklist.Linear] + lazy implicit val bftBlockOrdererLeaderSelectionPolicyHowLongToBlacklistLinearWithParametersConfigReader + : ConfigReader[ + BlacklistLeaderSelectionPolicyConfig.HowLongToBlacklist.LinearWithParameters + ] = + deriveReader[BlacklistLeaderSelectionPolicyConfig.HowLongToBlacklist.LinearWithParameters] + lazy implicit val bftBlockOrdererLeaderSelectionPolicyHowLongToBlacklistExponentialConfigReader + : ConfigReader[ + BlacklistLeaderSelectionPolicyConfig.HowLongToBlacklist.Exponential + ] = + deriveReader[BlacklistLeaderSelectionPolicyConfig.HowLongToBlacklist.Exponential] lazy implicit val bftBlockOrdererLeaderSelectionPolicyHowLongToBlacklistNoBlacklistingConfigReader : ConfigReader[ BlacklistLeaderSelectionPolicyConfig.HowLongToBlacklist.NoBlacklisting.type @@ -1237,6 +1251,9 @@ object CantonConfig { lazy implicit val bftBlockOrdererLeaderSelectionPolicyConfigReader : ConfigReader[BlacklistLeaderSelectionPolicyConfig] = deriveReader[BlacklistLeaderSelectionPolicyConfig] + lazy implicit val bftBlockOrdererSequencerCoreSubscriptionConfigReader + : ConfigReader[SequencerCoreSubscriptionConfig] = + deriveReader[SequencerCoreSubscriptionConfig] lazy implicit val bftBlockOrdererConfigReader: ConfigReader[BftBlockOrdererConfig] = deriveReader[BftBlockOrdererConfig] lazy implicit val sequencerConfigBftSequencerReader @@ -1326,16 +1343,35 @@ object CantonConfig { lazy implicit final val remoteSequencerConfigReader: ConfigReader[RemoteSequencerConfig] = deriveReader[RemoteSequencerConfig] + lazy implicit final val mediatorNodeParameterConfigReader - : ConfigReader[MediatorNodeParameterConfig] = + : ConfigReader[MediatorNodeParameterConfig] = { + implicit val verdictSenderReaderConfig: ConfigReader[DelayedVerdictSenderConfig] = { + import NonNegativeNumeric.* + deriveReader[DelayedVerdictSenderConfig] + } deriveReader[MediatorNodeParameterConfig] + } lazy implicit final val mediatorConfigReader: ConfigReader[MediatorConfig] = { implicit val mediatorPruningConfigReader: ConfigReader[MediatorPruningConfig] = deriveReader[MediatorPruningConfig] implicit val deduplicationStoreConfigReader: ConfigReader[DeduplicationStoreConfig] = deriveReader[DeduplicationStoreConfig] - deriveReader[MediatorConfig] + + implicit val deprecatedFields: DeprecatedFieldsFor[MediatorConfig] = + new DeprecatedFieldsFor[MediatorConfig] { + + override def deprecatePath: List[DeprecatedConfigPath[?]] = + List( + DeprecatedConfigPath[Boolean]( + "asynchronous-processing", + since = "3.5.1", + ) + ) + } + + deriveReader[MediatorConfig].applyDeprecations } lazy implicit final val remoteMediatorConfigReader: ConfigReader[RemoteMediatorConfig] = deriveReader[RemoteMediatorConfig] @@ -1503,6 +1539,11 @@ object CantonConfig { since = "3.5.0", to = Seq("alpha-online-party-replication-support"), ), + DeprecatedConfigUtils.MovedConfigPath( + "alpha-multi-synchronizer-support", + since = "3.5.4", + to = Seq("enable-all-ledger-api-reassignments"), + ), ) override def deprecatePath: List[DeprecatedConfigPath[?]] = List( @@ -1559,7 +1600,20 @@ object CantonConfig { implicit val reassignmentsReader: ConfigReader[ReassignmentsConfig] = deriveReader[ReassignmentsConfig] implicit val purgeReader: ConfigReader[PurgeConfig] = deriveReader[PurgeConfig] - implicit val lsuReader: ConfigReader[LsuConfig] = deriveReader[LsuConfig] + implicit val lsuHandshakeReader: ConfigReader[LsuHandshake] = deriveReader[LsuHandshake] + + implicit val deprecatedFieldsLsuConfig: DeprecatedFieldsFor[LsuConfig] = + new DeprecatedFieldsFor[LsuConfig] { + override def movedFields: List[DeprecatedConfigUtils.MovedConfigPath] = List( + DeprecatedConfigUtils.MovedConfigPath( + "handshake-retry", + since = "3.5.1", + to = Seq("handshake.retry"), + ) + ) + } + + implicit val lsuReader: ConfigReader[LsuConfig] = deriveReader[LsuConfig].applyDeprecations deriveReader[ParticipantNodeParameterConfig].applyDeprecations } lazy implicit final val timeTrackerConfigReader: ConfigReader[SynchronizerTimeTrackerConfig] = { @@ -1649,6 +1703,21 @@ object CantonConfig { import DeclarativeSequencerConfig.Readers.* deriveReader[SequencerNodeConfig] } + + lazy implicit val trafficEnforcementProjectionConfigReader + : ConfigReader[TrafficEnforcementServerConfig.ProjectionConfig] = + deriveReader[TrafficEnforcementServerConfig.ProjectionConfig] + + lazy implicit val trafficEnforcementConfigInternalReader + : ConfigReader[TrafficEnforcementServerConfig.Internal] = + deriveReader[TrafficEnforcementServerConfig.Internal] + + lazy implicit val TrafficEnforcementServerConfigReader + : ConfigReader[TrafficEnforcementServerConfig] = + deriveReader[TrafficEnforcementServerConfig] + + lazy implicit val trafficEnforcementConfigReader: ConfigReader[TrafficEnforcementConfig] = + deriveReader[TrafficEnforcementConfig] } private implicit def cantonConfigReader(implicit @@ -2046,6 +2115,16 @@ object CantonConfig { lazy implicit val bftBlockOrdererLeaderSelectionPolicyHowLongToBlacklistLinearConfigWriter : ConfigWriter[BlacklistLeaderSelectionPolicyConfig.HowLongToBlacklist.Linear] = deriveWriter[BlacklistLeaderSelectionPolicyConfig.HowLongToBlacklist.Linear] + lazy implicit val bftBlockOrdererLeaderSelectionPolicyHowLongToBlacklistLinearWithParametersConfigWriter + : ConfigWriter[ + BlacklistLeaderSelectionPolicyConfig.HowLongToBlacklist.LinearWithParameters + ] = + deriveWriter[BlacklistLeaderSelectionPolicyConfig.HowLongToBlacklist.LinearWithParameters] + lazy implicit val bftBlockOrdererLeaderSelectionPolicyHowLongToBlacklistExponentialConfigWriter + : ConfigWriter[ + BlacklistLeaderSelectionPolicyConfig.HowLongToBlacklist.Exponential + ] = + deriveWriter[BlacklistLeaderSelectionPolicyConfig.HowLongToBlacklist.Exponential] lazy implicit val bftBlockOrdererLeaderSelectionPolicyHowLongToBlacklistNoBlacklistingConfigWriter : ConfigWriter[ BlacklistLeaderSelectionPolicyConfig.HowLongToBlacklist.NoBlacklisting.type @@ -2057,6 +2136,9 @@ object CantonConfig { lazy implicit val bftBlockOrdererLeaderSelectionPolicyConfigWriter : ConfigWriter[BlacklistLeaderSelectionPolicyConfig] = deriveWriter[BlacklistLeaderSelectionPolicyConfig] + lazy implicit val bftBlockOrdererSequencerCoreSubscriptionConfigWriter + : ConfigWriter[SequencerCoreSubscriptionConfig] = + deriveWriter[SequencerCoreSubscriptionConfig] lazy implicit val bftBlockOrdererConfigWriter: ConfigWriter[BftBlockOrdererConfig] = deriveWriter[BftBlockOrdererConfig] @@ -2137,8 +2219,11 @@ object CantonConfig { deriveWriter[MediatorConfig] } lazy implicit final val mediatorNodeParameterConfigWriter - : ConfigWriter[MediatorNodeParameterConfig] = + : ConfigWriter[MediatorNodeParameterConfig] = { + implicit val verdictSenderConfigWriter: ConfigWriter[DelayedVerdictSenderConfig] = + deriveWriter[DelayedVerdictSenderConfig] deriveWriter[MediatorNodeParameterConfig] + } lazy implicit final val remoteMediatorConfigWriter: ConfigWriter[RemoteMediatorConfig] = deriveWriter[RemoteMediatorConfig] @@ -2275,6 +2360,7 @@ object CantonConfig { implicit val reassignmentsConfigWriter: ConfigWriter[ReassignmentsConfig] = deriveWriter[ReassignmentsConfig] implicit val purgeWriter: ConfigWriter[PurgeConfig] = deriveWriter[PurgeConfig] + implicit val lsuHandshakeWriter: ConfigWriter[LsuHandshake] = deriveWriter[LsuHandshake] implicit val lsuWriter: ConfigWriter[LsuConfig] = deriveWriter[LsuConfig] deriveWriter[ParticipantNodeParameterConfig] } @@ -2349,6 +2435,21 @@ object CantonConfig { import DeclarativeSequencerConfig.Writers.* deriveWriter[SequencerNodeConfig] } + + lazy implicit val trafficEnforcementProjectionConfigWriter + : ConfigWriter[TrafficEnforcementServerConfig.ProjectionConfig] = + deriveWriter[TrafficEnforcementServerConfig.ProjectionConfig] + + lazy implicit val trafficEnforcementConfigInternalWriter + : ConfigWriter[TrafficEnforcementServerConfig.Internal] = + deriveWriter[TrafficEnforcementServerConfig.Internal] + + lazy implicit val trafficEnforcementServerConfigWriter + : ConfigWriter[TrafficEnforcementServerConfig] = + deriveWriter[TrafficEnforcementServerConfig] + + lazy implicit val trafficEnforcementConfigWriter: ConfigWriter[TrafficEnforcementConfig] = + deriveWriter[TrafficEnforcementConfig] } private def makeWriter(confidential: Boolean): ConfigWriter[CantonConfig] = { diff --git a/canton/community/app-base/src/main/scala/com/digitalasset/canton/console/InstanceReference.scala b/canton/community/app-base/src/main/scala/com/digitalasset/canton/console/InstanceReference.scala index a1e338dc90..c802377877 100644 --- a/canton/community/app-base/src/main/scala/com/digitalasset/canton/console/InstanceReference.scala +++ b/canton/community/app-base/src/main/scala/com/digitalasset/canton/console/InstanceReference.scala @@ -1352,7 +1352,7 @@ abstract class SequencerReference( } @Help.Summary("List peer endpoints configured and stored in DB") - def list_configured_peer_endpoints(): Seq[P2PEndpoint] = + def list_configured_peer_endpoints(): Seq[(P2PEndpoint, Option[SequencerId])] = consoleEnvironment.run { runner.adminCommand(SequencerBftAdminCommands.ListConfiguredEndpoints) } diff --git a/canton/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/LedgerApiAdministration.scala b/canton/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/LedgerApiAdministration.scala index 73fa7e5623..3682bfd782 100644 --- a/canton/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/LedgerApiAdministration.scala +++ b/canton/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/LedgerApiAdministration.scala @@ -102,6 +102,7 @@ import com.digitalasset.canton.networking.grpc.{ import com.digitalasset.canton.participant.ledger.api.client.JavaDecodeUtil import com.digitalasset.canton.platform.apiserver.execution.CommandStatus import com.digitalasset.canton.protocol.LfContractId +import com.digitalasset.canton.tea.v1.{GetAccountResponse, UpdateAccountResponse} import com.digitalasset.canton.topology.transaction.TopologyTransaction.GenericTopologyTransaction import com.digitalasset.canton.topology.{ ExternalParty, @@ -836,6 +837,7 @@ trait BaseLedgerApiAdministration extends NoTracing with StreamingCommandHelper userId: String = userId, deduplicationPeriod: Option[DeduplicationPeriod] = None, minLedgerTimeAbs: Option[Instant] = None, + optTimeout: Option[config.NonNegativeDuration] = Some(timeouts.ledgerCommand), ): ExecuteAndWaitResponseProto = consoleEnvironment.run { ledgerApiCommand( @@ -847,6 +849,7 @@ trait BaseLedgerApiAdministration extends NoTracing with StreamingCommandHelper deduplicationPeriod = deduplicationPeriod, minLedgerTimeAbs = minLedgerTimeAbs, hashingSchemeVersion = hashingSchemeVersion, + optTimeout = optTimeout, ) ) } @@ -873,6 +876,7 @@ trait BaseLedgerApiAdministration extends NoTracing with StreamingCommandHelper minLedgerTimeAbs: Option[Instant] = None, includeCreatedEventBlob: Boolean = false, customEventFormat: Option[EventFormat] = None, + optTimeout: Option[config.NonNegativeDuration] = Some(timeouts.ledgerCommand), ): ApiTransaction = consoleEnvironment.run { ledgerApiCommand( @@ -887,6 +891,7 @@ trait BaseLedgerApiAdministration extends NoTracing with StreamingCommandHelper transactionShape = transactionShape, includeCreatedEventBlob = includeCreatedEventBlob, customEventFormat = customEventFormat, + optTimeout = optTimeout, ) ) }.getTransaction @@ -3573,6 +3578,33 @@ trait BaseLedgerApiAdministration extends NoTracing with StreamingCommandHelper .pipe(GetEventsByContractIdResponse.toJavaProto) } } + + @Help.Summary("Participant user traffic service") + @Help.Group("Traffic") + object traffic extends Helpful { + @Help.Summary("Get account details", FeatureFlag.Testing) + @Help.Description("Get the details for the specified account-id") + def get_account(accountId: String): GetAccountResponse = + consoleEnvironment.run { + ledgerApiCommand(LedgerApiCommands.Traffic.GetAccount(accountId)) + } + + @Help.Summary("Update details for the account-id", FeatureFlag.Testing) + @Help.Description( + """Update the account details (the balance) for the specified account-id. + |If unset, the balance will not be updated + |subsequent balance updates with the same deduplicationId will be ignored""" + ) + def update_account( + accountId: String, + balance: Option[Long], + deduplicationId: String = UUID.randomUUID().toString, + ): UpdateAccountResponse = consoleEnvironment.run { + ledgerApiCommand( + LedgerApiCommands.Traffic.UpdateAccount(accountId, balance, deduplicationId) + ) + } + } } /** @return diff --git a/canton/community/app-base/src/main/scala/com/digitalasset/canton/console/declarative/DeclarativeParticipantApi.scala b/canton/community/app-base/src/main/scala/com/digitalasset/canton/console/declarative/DeclarativeParticipantApi.scala index e88d2946b1..33c081bf88 100644 --- a/canton/community/app-base/src/main/scala/com/digitalasset/canton/console/declarative/DeclarativeParticipantApi.scala +++ b/canton/community/app-base/src/main/scala/com/digitalasset/canton/console/declarative/DeclarativeParticipantApi.scala @@ -190,8 +190,7 @@ class DeclarativeParticipantApi( val mapping = SynchronizerTrustCertificate( participantId, synchronizerId, - featureFlags = - (ParticipantTopologyFeatureFlag.EnableAlphaMultiSynchronizer +: oldFeatureFlags), + featureFlags = (ParticipantTopologyFeatureFlag.EnableMultiSynchronizer +: oldFeatureFlags), ) queryAdminApi( TopologyAdminCommands.Write.Propose( @@ -212,7 +211,7 @@ class DeclarativeParticipantApi( current <- fetchSynchronizerTrustCertificate(sid.synchronizerId) currentFeatureFlags = current.headOption.map(_.item.featureFlags).getOrElse(Seq.empty) shouldUpdate = !currentFeatureFlags.contains( - ParticipantTopologyFeatureFlag.EnableAlphaMultiSynchronizer + ParticipantTopologyFeatureFlag.EnableMultiSynchronizer ) done <- if (shouldUpdate) { diff --git a/canton/community/app-base/src/main/scala/com/digitalasset/canton/environment/Environment.scala b/canton/community/app-base/src/main/scala/com/digitalasset/canton/environment/Environment.scala index 182d0c25b9..10f346924b 100644 --- a/canton/community/app-base/src/main/scala/com/digitalasset/canton/environment/Environment.scala +++ b/canton/community/app-base/src/main/scala/com/digitalasset/canton/environment/Environment.scala @@ -45,7 +45,7 @@ import com.digitalasset.canton.time.* import com.digitalasset.canton.tracing.TraceContext.withNewTraceContext import com.digitalasset.canton.tracing.{NoTracing, TraceContext, TracerProvider} import com.digitalasset.canton.util.FutureInstances.parallelFuture -import com.digitalasset.canton.util.{MonadUtil, Mutex, PekkoUtil, SingleUseCell} +import com.digitalasset.canton.util.{EitherTUtil, MonadUtil, Mutex, PekkoUtil, SingleUseCell} import com.google.common.annotations.VisibleForTesting import io.circe.Encoder import io.circe.generic.semiauto.deriveEncoder @@ -84,7 +84,7 @@ abstract class Environment[Config <: SharedCantonConfig[Config]]( noTracingLogger, ) - final def config: Config = currentConfig.get() + def config: Config = currentConfig.get() def pokeOrUpdateConfig( newConfig: Option[Either[String, Config]] )(implicit traceContext: TraceContext): Unit = { @@ -375,6 +375,7 @@ abstract class Environment[Config <: SharedCantonConfig[Config]]( runner writePortsFile() } // write ports after the runner has completed + // log results startup .leftMap(error => logger.error(s"Failed to start ${error.name}: ${error.message}")) @@ -427,12 +428,19 @@ abstract class Environment[Config <: SharedCantonConfig[Config]]( } EitherT.rightT(()) case Some(node) => - node - .reconnectSynchronizersIgnoreFailures(isTriggeredManually = false) - .leftMap(err => StartFailed(instance.name.unwrap, err.toString)) - .onShutdown(Left(StartFailed(instance.name.unwrap, "aborted due to shutdown"))) - + if (node.config.parameters.connectToSynchronizersOnStartup) + node + .reconnectSynchronizersIgnoreFailures(isTriggeredManually = false) + .leftMap(err => StartFailed(instance.name.unwrap, err.toString)) + .onShutdown(Left(StartFailed(instance.name.unwrap, "aborted due to shutdown"))) + else { + logger.info( + s"Not reconnecting $node to synchronizers because reconnect on startup is disabled" + ) + EitherTUtil.unit + } } + config.parameters.timeouts.processing.unbounded.await("reconnect-participants")( MonadUtil .parTraverseWithLimit_(config.parameters.getStartupParallelism(numThreads))( diff --git a/canton/community/app/src/main/resources/sandbox/bootstrap.canton b/canton/community/app/src/main/resources/sandbox/bootstrap.canton index d3877aa525..cd89f751f8 100644 --- a/canton/community/app/src/main/resources/sandbox/bootstrap.canton +++ b/canton/community/app/src/main/resources/sandbox/bootstrap.canton @@ -15,7 +15,7 @@ val syncDefs = mediators.local .zipWithIndex .map { case ((m, s), i) => SyncDef(m, s, s"synchronizer-${i + 1}") } -val featureFlag = Seq(SynchronizerTrustCertificate.ParticipantTopologyFeatureFlag.EnableAlphaMultiSynchronizer) +val featureFlag = Seq(SynchronizerTrustCertificate.ParticipantTopologyFeatureFlag.EnableMultiSynchronizer) syncDefs.foreach{ syncDef => val staticSynchronizerParameters = StaticSynchronizerParameters.defaults( diff --git a/canton/community/app/src/main/scala/com/digitalasset/canton/CantonAppDriver.scala b/canton/community/app/src/main/scala/com/digitalasset/canton/CantonAppDriver.scala index c73b474917..538cd19802 100644 --- a/canton/community/app/src/main/scala/com/digitalasset/canton/CantonAppDriver.scala +++ b/canton/community/app/src/main/scala/com/digitalasset/canton/CantonAppDriver.scala @@ -63,7 +63,8 @@ abstract class CantonAppDriver extends App with NamedLogging with NoTracing { Console.out.println(s"$name: $version") } - protected def logAppVersion(): Unit = logger.info(s"Starting Canton version ${BuildInfo.version}") + protected def logAppVersion(): Unit = + logger.info(s"Starting Canton version ${BuildInfo.version}") // BE CAREFUL: Set the environment variables before you touch anything related to // logback as otherwise, the logback configuration will be read without these diff --git a/canton/community/app/src/pack/examples/05-composability/composability-auto-reassignment.canton b/canton/community/app/src/pack/examples/05-composability/composability-auto-reassignment.canton index 0ca0a797fd..fa3fd4d790 100644 --- a/canton/community/app/src/pack/examples/05-composability/composability-auto-reassignment.canton +++ b/canton/community/app/src/pack/examples/05-composability/composability-auto-reassignment.canton @@ -32,7 +32,7 @@ paintSynchronizerOwner.topology.synchronizer_parameters .propose_update(paintId, _.update(assignmentExclusivityTimeout = 2 seconds)) // enable multi-synchronizer topology feature flag for all participants for both synchronizers -val featureFlag = Seq(SynchronizerTrustCertificate.ParticipantTopologyFeatureFlag.EnableAlphaMultiSynchronizer) +val featureFlag = Seq(SynchronizerTrustCertificate.ParticipantTopologyFeatureFlag.EnableMultiSynchronizer) participant1.topology.synchronizer_trust_certificates.propose(participant1, iouId, featureFlags = featureFlag) participant2.topology.synchronizer_trust_certificates.propose(participant2, iouId, featureFlags = featureFlag) participant3.topology.synchronizer_trust_certificates.propose(participant3, iouId, featureFlags = featureFlag) diff --git a/canton/community/app/src/pack/examples/05-composability/composability1.canton b/canton/community/app/src/pack/examples/05-composability/composability1.canton index e2b98b63f5..f6b1ebe15c 100644 --- a/canton/community/app/src/pack/examples/05-composability/composability1.canton +++ b/canton/community/app/src/pack/examples/05-composability/composability1.canton @@ -41,7 +41,7 @@ participant2.synchronizers.connect_local(paint_sequencer, alias = paintAlias) participant3.synchronizers.connect_local(paint_sequencer, alias = paintAlias) // enable multi-synchronizer topology feature flag for all participants for both synchronizers -val featureFlag = Seq(SynchronizerTrustCertificate.ParticipantTopologyFeatureFlag.EnableAlphaMultiSynchronizer) +val featureFlag = Seq(SynchronizerTrustCertificate.ParticipantTopologyFeatureFlag.EnableMultiSynchronizer) participant1.topology.synchronizer_trust_certificates.propose(participant1, iouId, featureFlags = featureFlag) participant2.topology.synchronizer_trust_certificates.propose(participant2, iouId, featureFlags = featureFlag) participant3.topology.synchronizer_trust_certificates.propose(participant3, iouId, featureFlags = featureFlag) diff --git a/canton/community/app/src/pack/examples/05-composability/composability2.canton b/canton/community/app/src/pack/examples/05-composability/composability2.canton index b9e7bde715..8359962b68 100644 --- a/canton/community/app/src/pack/examples/05-composability/composability2.canton +++ b/canton/community/app/src/pack/examples/05-composability/composability2.canton @@ -33,7 +33,7 @@ paintSynchronizerOwner.topology.synchronizer_parameters .propose_update(paintId, _.update(assignmentExclusivityTimeout = 2 seconds)) // enable multi-synchronizer topology feature flag for all participants for both synchronizers -val featureFlag = Seq(SynchronizerTrustCertificate.ParticipantTopologyFeatureFlag.EnableAlphaMultiSynchronizer) +val featureFlag = Seq(SynchronizerTrustCertificate.ParticipantTopologyFeatureFlag.EnableMultiSynchronizer) participant1.topology.synchronizer_trust_certificates.propose(participant1, iouId, featureFlags = featureFlag) participant2.topology.synchronizer_trust_certificates.propose(participant2, iouId, featureFlags = featureFlag) participant3.topology.synchronizer_trust_certificates.propose(participant3, iouId, featureFlags = featureFlag) diff --git a/canton/community/app/src/pack/examples/08-interactive-submission/requirements.txt b/canton/community/app/src/pack/examples/08-interactive-submission/requirements.txt index a4a5bbbc4a..2b959f353e 100644 --- a/canton/community/app/src/pack/examples/08-interactive-submission/requirements.txt +++ b/canton/community/app/src/pack/examples/08-interactive-submission/requirements.txt @@ -1,3 +1,3 @@ -cryptography==44.0.1 +cryptography>=46.0.7 grpcio-tools==1.70.0 protobuf==5.29.3 diff --git a/canton/community/app/src/pack/examples/13-observability/grafana/dashboards/canton-network/sequencer.json b/canton/community/app/src/pack/examples/13-observability/grafana/dashboards/canton-network/sequencer.json index e8cbf3b4eb..0839ea2ead 100644 --- a/canton/community/app/src/pack/examples/13-observability/grafana/dashboards/canton-network/sequencer.json +++ b/canton/community/app/src/pack/examples/13-observability/grafana/dashboards/canton-network/sequencer.json @@ -1321,7 +1321,7 @@ "timepicker": {}, "timezone": "", "title": "Sequencer Traffic", - "uid": "fdjrxql2alblsd", + "uid": "fdjrxql3alclsd", "version": 9, "weekStart": "" } diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/config/CantonConfigTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/config/CantonConfigTest.scala index c60f39e833..138f823390 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/config/CantonConfigTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/config/CantonConfigTest.scala @@ -605,7 +605,7 @@ class CantonConfigTest extends AnyWordSpec with BaseTest { participant.httpLedgerApi.requestTimeout.toMinutes shouldBe 105 // verify that `crypto.sessionSigningKeys` is configured with the expected values - participant.crypto.sessionSigningKeys shouldBe SessionSigningKeysConfig.default + participant.crypto.sessionSigningKeys shouldBe SessionSigningKeysConfig.enabled } // In this test case, both deprecated and new fields are set with opposite values, we make sure the new fields @@ -696,5 +696,71 @@ class CantonConfigTest extends AnyWordSpec with BaseTest { ) } } - + "AuthServiceConfig parsing" should { + "correctly parse max-token-life for all provider types" in { + import com.digitalasset.canton.config.AuthServiceConfig.* + import scala.concurrent.duration.* + + val authConfigStr = + """ + |canton.participants.participant1.ledger-api.auth-services = [ + | { + | type = "unsafe-jwt-hmac-256" + | secret = "super-secret-test-key-here" + | max-token-life = "10m" + | }, + | { + | type = "jwt-rs-256-crt" + | certificate = "path/to/rsa-cert.crt" + | max-token-life = "20m" + | }, + | { + | type = "jwt-jwks" + | url = "https://example.com/.well-known/jwks.json" + | max-token-life = "30m" + | }, + | { + | type = "jwt-es-256-crt" + | certificate = "path/to/cert256.crt" + | max-token-life = "40m" + | }, + | { + | type = "jwt-es-512-crt" + | certificate = "path/to/cert512.crt" + | max-token-life = "50m" + | } + |] + |""".stripMargin + + File.usingTemporaryFile("auth-services-test", ".conf") { tempFile => + tempFile.writeText(authConfigStr) + + val parsedConfig = CantonConfig + .parseAndLoad( + Seq(simpleConf.toJava, tempFile.toJava), + Some(DefaultPorts.create()), + ) + .valueOrFail("Failed to parse config with auth services") + + val authServices = parsedConfig.participantsByString("participant1").ledgerApi.authServices + + authServices should have size 5 + + inside(authServices) { + case Seq( + unsafe: UnsafeJwtHmac256, + rs256: JwtRs256Crt, + jwks: JwtJwks, + es256: JwtEs256Crt, + es512: JwtEs512Crt, + ) => + unsafe.maxTokenLife.duration shouldBe 10.minutes + rs256.maxTokenLife.duration shouldBe 20.minutes + jwks.maxTokenLife.duration shouldBe 30.minutes + es256.maxTokenLife.duration shouldBe 40.minutes + es512.maxTokenLife.duration shouldBe 50.minutes + } + } + } + } } diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/config/ConfigValidationsTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/config/ConfigValidationsTest.scala index de61db16d2..4716b25e1a 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/config/ConfigValidationsTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/config/ConfigValidationsTest.scala @@ -689,7 +689,7 @@ class ConfigValidationsTest extends BaseTestWordSpec { ), kms = Some(KmsConfig.Aws.defaultTestConfig), sessionSigningKeys = - SessionSigningKeysConfig.default.copy(signingAlgorithmSpec = signingAlgorithmSpec), + SessionSigningKeysConfig.enabled.copy(signingAlgorithmSpec = signingAlgorithmSpec), ) ) ), @@ -750,7 +750,7 @@ class ConfigValidationsTest extends BaseTestWordSpec { ), kms = Some(KmsConfig.Aws.defaultTestConfig), sessionSigningKeys = - SessionSigningKeysConfig.default.copy(signingKeySpec = sessionSigningKeySpec), + SessionSigningKeysConfig.enabled.copy(signingKeySpec = sessionSigningKeySpec), ) ) ) @@ -767,16 +767,16 @@ class ConfigValidationsTest extends BaseTestWordSpec { crypto = CryptoConfig( provider = Kms, kms = Some(KmsConfig.Aws.defaultTestConfig), - sessionSigningKeys = SessionSigningKeysConfig.default, + sessionSigningKeys = SessionSigningKeysConfig.enabled, ) ) val config = CantonConfig(participants = Map(InstanceName.tryCreate("p1") -> participantNodeConfig)) - val defaultKeyValidityDuration = SessionSigningKeysConfig.default.keyValidityDuration - val defaultToleranceShiftDuration = SessionSigningKeysConfig.default.toleranceShiftDuration - val defaultCutOffDuration = SessionSigningKeysConfig.default.cutOffDuration - val defaultKeyEvictionPeriod = SessionSigningKeysConfig.default.keyEvictionPeriod + val defaultKeyValidityDuration = SessionSigningKeysConfig.enabled.keyValidityDuration + val defaultToleranceShiftDuration = SessionSigningKeysConfig.enabled.toleranceShiftDuration + val defaultCutOffDuration = SessionSigningKeysConfig.enabled.cutOffDuration + val defaultKeyEvictionPeriod = SessionSigningKeysConfig.enabled.keyEvictionPeriod def changeSessionSigningKeyParams( keyValidityDuration: Option[PositiveFiniteDuration], @@ -784,7 +784,7 @@ class ConfigValidationsTest extends BaseTestWordSpec { cutOffDuration: Option[NonNegativeFiniteDuration] = None, keyEvictionPeriod: Option[PositiveFiniteDuration] = None, ) = { - val params = SessionSigningKeysConfig.default.copy( + val params = SessionSigningKeysConfig.enabled.copy( keyValidityDuration = keyValidityDuration.getOrElse(defaultKeyValidityDuration), toleranceShiftDuration = toleranceShiftDuration.getOrElse(defaultToleranceShiftDuration), cutOffDuration = cutOffDuration.getOrElse(defaultCutOffDuration), @@ -832,7 +832,7 @@ class ConfigValidationsTest extends BaseTestWordSpec { )( s"participant p1: The selected session signing key tolerance shift duration " + s"of $invalidToleranceShiftDuration must be longer than the cut-off " + - s"(${SessionSigningKeysConfig.default.cutOffDuration})." + s"(${SessionSigningKeysConfig.enabled.cutOffDuration})." ) assertErrors( @@ -845,14 +845,14 @@ class ConfigValidationsTest extends BaseTestWordSpec { .focus(_.sequencerClient.defaultMaxSequencingTimeOffset) .replace( NonNegativeFiniteDuration.ofMinutes( - SessionSigningKeysConfig.default.keyValidityDuration.duration.toMinutes + SessionSigningKeysConfig.enabled.keyValidityDuration.duration.toMinutes ) ) ) ) )( s"participant p1: The selected session signing key validity parameters do not align with " + - s"the current default max sequencing time offset (${SessionSigningKeysConfig.default.keyValidityDuration}). " + + s"the current default max sequencing time offset (${SessionSigningKeysConfig.enabled.keyValidityDuration}). " + s"Parameters must be chosen so that " + s"`keyValidityDuration` - `cutOffDuration` > `defaultMaxSequencingTimeOffset`." ) @@ -866,13 +866,13 @@ class ConfigValidationsTest extends BaseTestWordSpec { )( s"participant p1: The selected session signing key eviction period " + s"of $invalidKeyEvictionPeriod must be longer than the key validity duration " + - s"(${SessionSigningKeysConfig.default.keyValidityDuration})." + s"(${SessionSigningKeysConfig.enabled.keyValidityDuration})." ) } "pass config validation with parameters out of bounds and checks disabled" in { - val invalidKeyValidityDuration = SessionSigningKeysConfig.default.keyEvictionPeriod + val invalidKeyValidityDuration = SessionSigningKeysConfig.enabled.keyEvictionPeriod val config = CantonConfig( parameters = CantonParameters(nonStandardConfig = true), participants = Map( @@ -881,7 +881,7 @@ class ConfigValidationsTest extends BaseTestWordSpec { crypto = CryptoConfig( provider = Kms, kms = Some(KmsConfig.Aws.defaultTestConfig), - sessionSigningKeys = SessionSigningKeysConfig.default.copy( + sessionSigningKeys = SessionSigningKeysConfig.enabled.copy( keyValidityDuration = invalidKeyValidityDuration, disableBoundChecks = true, ), diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/console/ConsoleEnvironmentTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/console/ConsoleEnvironmentTest.scala index 8368305f4d..d7ccbf8b75 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/console/ConsoleEnvironmentTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/console/ConsoleEnvironmentTest.scala @@ -7,7 +7,7 @@ import com.daml.metrics.OnDemandMetricsReader.NoOpOnDemandMetricsReader$ import com.digitalasset.canton.BaseTest import com.digitalasset.canton.config.CantonConfig import com.digitalasset.canton.console.CommandErrors.GenericCommandError -import com.digitalasset.canton.environment.Environment +import com.digitalasset.canton.environment.CantonEnvironment import com.digitalasset.canton.logging.{NamedEventCapturingLogger, SuppressingLogger} import com.digitalasset.canton.telemetry.ConfiguredOpenTelemetry import com.digitalasset.canton.tracing.TracerProvider @@ -36,9 +36,9 @@ class ConsoleEnvironmentTest extends AnyWordSpec with BaseTest { val testConsoleOutput: TestConsoleOutput = new TestConsoleOutput(capturingLoggerFactory) // Setup environment to inject capturing loggerFactory - val environment = mock[Environment] + val environment = mock[CantonEnvironment] when(environment.loggerFactory).thenReturn(capturingLoggerFactory) - when(environment.config).thenReturn(CantonConfig()) + doReturn(CantonConfig()).when(environment).config when(environment.tracerProvider).thenReturn(mock[TracerProvider]) when(environment.configuredOpenTelemetry).thenReturn( ConfiguredOpenTelemetry( @@ -49,7 +49,7 @@ class ConsoleEnvironmentTest extends AnyWordSpec with BaseTest { ) // The ConsoleEnvironment to be tested - val consoleEnvironment: ConsoleEnvironment = new ConsoleEnvironment( + val consoleEnvironment: CantonConsoleEnvironment = new CantonConsoleEnvironment( environment, consoleOutput = testConsoleOutput, ) diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/console/ConsoleTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/console/ConsoleTest.scala index ce6b4cf486..fde97a17a4 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/console/ConsoleTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/console/ConsoleTest.scala @@ -82,7 +82,7 @@ final class ConsoleTest extends AnyWordSpec with BaseTest { val adminToken: String = "0" * 64 when(environment.tracerProvider).thenReturn(mock[TracerProvider]) - when(environment.config).thenReturn(config) + doReturn(config).when(environment).config when(environment.testingConfig).thenReturn( TestingConfigInternal(initializeGlobalOpenTelemetry = false) ) diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/environment/CommunityEnvironmentFixture.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/environment/CommunityEnvironmentFixture.scala index 176b380f02..f0dcbf94ca 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/environment/CommunityEnvironmentFixture.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/environment/CommunityEnvironmentFixture.scala @@ -123,7 +123,7 @@ trait CommunityEnvironmentFixture extends BaseTest with HasExecutionContext { th def mockParticipant: ParticipantNodeBootstrap = mockParticipantAndNode._1 - val environment = new Environment( + val environment = new CantonEnvironment( config, TestingConfigInternal(initializeGlobalOpenTelemetry = false, warnOnJwtScopeUsage = false), new ParticipantNodeBootstrapFactory { diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/environment/NodesTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/environment/NodesTest.scala index db0093367d..8aeb13c3ef 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/environment/NodesTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/environment/NodesTest.scala @@ -31,6 +31,7 @@ import com.digitalasset.canton.lifecycle.{LifeCycle, ShutdownFailedException} import com.digitalasset.canton.metrics.ActiveRequestsMetrics.GrpcServerMetricsX import com.digitalasset.canton.metrics.{ CommonMockMetrics, + CryptoMetrics, DbStorageMetrics, DeclarativeApiMetrics, LedgerApiServerMetrics, @@ -126,6 +127,7 @@ class NodesTest extends FixtureAnyWordSpec with BaseTest with HasExecutionContex (LedgerApiServerMetrics.ForTesting.grpc, LedgerApiServerMetrics.ForTesting.requests), healthMetrics: HealthMetrics = LedgerApiServerMetrics.ForTesting.health, storageMetrics: DbStorageMetrics = CommonMockMetrics.dbStorage, + cryptoMetrics: CryptoMetrics = CommonMockMetrics.cryptoMetrics, ) extends BaseMetrics { override val declarativeApiMetrics: DeclarativeApiMetrics = diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/DownloadTopologyForInitIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/DownloadTopologyForInitIntegrationTest.scala index 66ae4011c5..3b2f4f87f9 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/DownloadTopologyForInitIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/DownloadTopologyForInitIntegrationTest.scala @@ -6,6 +6,7 @@ package com.digitalasset.canton.integration.tests import com.digitalasset.canton.config.RequireTypes.{NonNegativeInt, PositiveInt} import com.digitalasset.canton.config.{DbConfig, DefaultProcessingTimeouts} import com.digitalasset.canton.data.CantonTimestamp +import com.digitalasset.canton.integration.bootstrap.NetworkTopologyDescription.MediatorSequencersConfiguration import com.digitalasset.canton.integration.bootstrap.{ NetworkBootstrapper, NetworkTopologyDescription, @@ -46,9 +47,17 @@ abstract class DownloadTopologyForInitIntegrationTest Map( // A threshold of 2 ensures that the mediators connect to all sequencers. // TODO(#19911) Make this properly configurable - mediator1 -> (Seq(sequencer1, sequencer2), PositiveInt.two, NonNegativeInt.zero), + mediator1 -> MediatorSequencersConfiguration( + Seq(sequencer1, sequencer2), + trustThreshold = PositiveInt.two, + livenessMargin = NonNegativeInt.zero, + ), // Have this so that mediator2 gets registered in the topology state. - mediator2 -> (Seq(sequencer2), PositiveInt.one, NonNegativeInt.zero), + mediator2 -> MediatorSequencersConfiguration( + Seq(sequencer2), + trustThreshold = PositiveInt.one, + livenessMargin = NonNegativeInt.zero, + ), ) ), ) diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/DumpIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/DumpIntegrationTest.scala index 3f656853a4..63d0ac37ce 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/DumpIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/DumpIntegrationTest.scala @@ -18,6 +18,7 @@ import com.digitalasset.canton.integration.{ EnvironmentDefinition, SharedEnvironment, } +import com.digitalasset.canton.metrics.CommonMockMetrics import com.digitalasset.canton.protocol.messages.AcsCommitment import com.digitalasset.canton.protocol.v30 import com.digitalasset.canton.sequencing.PossiblyIgnoredProtocolEvent @@ -64,6 +65,7 @@ sealed trait DumpIntegrationTest extends CommunityIntegrationTest with SharedEnv CryptoSchemes .fromConfig(config.crypto) .valueOrFail("fail to validate crypto schemes from the configuration file"), + CommonMockMetrics.cryptoMetrics, loggerFactory, ) .valueOr(err => throw new RuntimeException(s"Failed to create pure crypto api: $err")) diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/MultipleMediatorsMultipleSynchronizersIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/MultipleMediatorsMultipleSynchronizersIntegrationTest.scala index 219c069419..a7f59d023f 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/MultipleMediatorsMultipleSynchronizersIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/MultipleMediatorsMultipleSynchronizersIntegrationTest.scala @@ -46,7 +46,7 @@ final class MultipleMediatorsMultipleSynchronizersIntegrationTest ).withManualStart .addConfigTransforms( ProgrammableSequencer.configOverride(this.getClass.toString, loggerFactory), - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ProtobufCompatibilityTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ProtobufCompatibilityTest.scala index a9c399f1d5..6f634df783 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ProtobufCompatibilityTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ProtobufCompatibilityTest.scala @@ -167,10 +167,41 @@ final class ProtobufCompatibilityReaderTest """com/digitalasset/canton/topology/admin/v30/topology_manager_read_service.proto:Previously present RPC "ListSequencerConnectionSuccessor" on service "TopologyManagerReadService" was deleted.""", """com/digitalasset/canton/topology/admin/v30/topology_manager_read_service.proto:Previously present RPC "ListSynchronizerUpgradeAnnouncement" on service "TopologyManagerReadService" was deleted.""", """com/digitalasset/canton/topology/admin/v30/topology_manager_read_service.proto:Previously present RPC "LogicalUpgradeState" on service "TopologyManagerReadService" was deleted.""", + + /// Backward compatibility + """com/digitalasset/canton/admin/sequencer/v30/sequencer_connection.proto:Previously present field "3" with name "confirmation_response_factor" on message "SubmissionRequestAmplification" was deleted.""", + """com/digitalasset/canton/admin/sequencer/v30/sequencer_connection.proto:Previously present field "4" with name "confirmation_response_patience" on message "SubmissionRequestAmplification" was deleted.""", + """com/digitalasset/canton/mediator/admin/v30/mediator_inspection_service.proto:Previously present field "2" with name "complete" on message "VerdictsResponse" was deleted.""", + """com/digitalasset/canton/mediator/admin/v30/mediator_inspection_service.proto:Previously present oneof "payload" on message "VerdictsResponse" was deleted.""", + """com/digitalasset/canton/mediator/admin/v30/mediator_inspection_service.proto:Field "1" with name "verdict" on message "VerdictsResponse" moved from inside to outside a oneof.""", + """com/digitalasset/canton/mediator/admin/v30/mediator_inspection_service.proto:Previously present field "4" with name "view_hash" on message "TransactionView" was deleted.""", + """com/digitalasset/canton/participant/protocol/v30/submission_tracking.proto:Previously present field "6" with name "paid_traffic_cost" on message "CompletionInfo" was deleted.""", + """com/digitalasset/canton/protocol/v30/synchronization.proto:Previously present field "7" with name "lsu_sequencing_test_message" on message "EnvelopeContent" was deleted.""", + """com/digitalasset/canton/protocol/v30/topology.proto:Previously present enum value "2" on enum "ParticipantFeatureFlag" was deleted.""", + """com/digitalasset/canton/protocol/v30/topology.proto:Field "17" with name "synchronizer_upgrade_announcement" on message "TopologyMapping" changed type from "com.digitalasset.canton.protocol.v30.LsuAnnouncement" to "com.digitalasset.canton.protocol.v30.SynchronizerUpgradeAnnouncement".""", + """com/digitalasset/canton/protocol/v30/topology.proto:Field "18" with name "sequencer_connection_successor" on message "TopologyMapping" changed type from "com.digitalasset.canton.protocol.v30.LsuSequencerConnectionSuccessor" to "com.digitalasset.canton.protocol.v30.SequencerConnectionSuccessor".""", + """com/digitalasset/canton/sequencer/admin/v30/sequencer_bft_administration_service.proto:Previously present field "3" with name "dynamic_sequencing_parameters_payload" on message "GetOrderingTopologyResponse" was deleted.""", + """com/digitalasset/canton/sequencer/admin/v30/sequencer_bft_administration_service.proto:Previously present field "4" with name "dynamic_sequencing_parameters_payload31" on message "GetOrderingTopologyResponse" was deleted.""", + """com/digitalasset/canton/sequencer/admin/v30/sequencer_bft_administration_service.proto:Previously present oneof "dynamic_sequencing_parameters" on message "GetOrderingTopologyResponse" was deleted.""", + """com/digitalasset/canton/sequencer/api/v30/sequencer_authentication_service.proto:Previously present field "3" with name "client_version" on message "ChallengeRequest" was deleted.""", + """com/digitalasset/canton/sequencer/api/v30/sequencer_connect_service.proto:Previously present field "3" with name "client_version" on message "HandshakeRequest" was deleted.""", + """com/digitalasset/canton/sequencer/api/v30/sequencer_connect_service.proto:Previously present reserved name "failure" on message "HandshakeResponse" was deleted.""", + """com/digitalasset/canton/sequencer/api/v30/sequencer_connect_service.proto:Previously present reserved range "[3]" on message "HandshakeResponse" is missing values: [3] were removed.""", + """com/digitalasset/canton/sequencer/api/v30/sequencer_connect_service.proto:Previously present reserved name "failure" on message "VerifyActiveResponse" was deleted.""", + """com/digitalasset/canton/sequencer/api/v30/sequencer_connect_service.proto:Previously present reserved range "[2]" on message "VerifyActiveResponse" is missing values: [2] were removed.""", + """com/digitalasset/canton/synchronizer/sequencing/sequencer/bftordering/v30/bft_ordering_service.proto:Previously present field "5" with name "ordering_start_instant" on message "OrderingRequest" was deleted.""", + """com/digitalasset/canton/synchronizer/sequencing/sequencer/bftordering/v30/bft_ordering_service.proto:Field "3" with name "payload" on message "OrderingRequest" changed type from "string" to "bytes".""", + """com/digitalasset/canton/synchronizer/sequencing/sequencer/bftordering/v30/bft_ordering_service.proto:Field "4" with name "ordering_start_instant" on message "OrderingRequest" changed cardinality from "optional with implicit presence" to "optional with explicit presence".""", + """com/digitalasset/canton/synchronizer/sequencing/sequencer/bftordering/v30/bft_ordering_service.proto:Field "4" with name "ordering_start_instant" on message "OrderingRequest" changed type from "bytes" to "message".""", + """com/digitalasset/canton/synchronizer/v30/synchronizer.proto:Previously present field "3" with name "is_late_upgrade" on message "SynchronizerPredecessor" was deleted.""", + """com/digitalasset/canton/admin/health/v30/status_service.proto:Previously present field "3" with name "version" on message "NotInitialized" was deleted.""", + // Added DABFT leaders and blacklisted nodes to `get_ordering_topology` console admin function's output + """com/digitalasset/canton/sequencer/admin/v30/sequencer_bft_administration_service.proto:Previously present field "5" with name "leader_sequencer_ids" on message "GetOrderingTopologyResponse" was deleted.""", + """com/digitalasset/canton/sequencer/admin/v30/sequencer_bft_administration_service.proto:Previously present field "6" with name "blacklisted_sequencer_ids" on message "GetOrderingTopologyResponse" was deleted.""", + // undefined epoch_number case is handled + """com/digitalasset/canton/synchronizer/sequencing/sequencer/bftordering/v30/bft_ordering_service.proto:Previously present field "2" with name "epoch_number" on message "BatchRequest" was deleted.""", ), (3, 5) -> Seq( - // Expose the release version in NotInitialized, is going to be added to 3.5.1-rc4+ - """com/digitalasset/canton/admin/health/v30/status_service.proto:Previously present field "3" with name "version" on message "NotInitialized" was deleted.""" ), ) diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ReplicatedMediatorIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ReplicatedMediatorIntegrationTest.scala index d60de8e3ca..14f3f55a3d 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ReplicatedMediatorIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ReplicatedMediatorIntegrationTest.scala @@ -14,11 +14,11 @@ import com.digitalasset.canton.integration.bootstrap.{ } import com.digitalasset.canton.integration.plugins.{UseBftSequencer, UsePostgres, UseSharedStorage} import com.digitalasset.canton.integration.{ + CantonEnvironmentSetup, CommunityIntegrationTest, ConfigTransform, ConfigTransforms, EnvironmentDefinition, - EnvironmentSetup, EnvironmentSetupPlugin, SharedEnvironment, TestConsoleEnvironment, @@ -31,7 +31,7 @@ import monocle.macros.syntax.lens.* import org.slf4j.event.Level trait ReplicatedMediatorTestSetup extends ReplicatedNodeHelper { - self: CommunityIntegrationTest & EnvironmentSetup => + self: CommunityIntegrationTest & CantonEnvironmentSetup => protected lazy val mediator1Name = "mediatorReplicated1" protected lazy val mediator2Name = "mediatorReplicated2" diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ReplicatedParticipantTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ReplicatedParticipantTest.scala index 2b165dd538..0ce14dfb6b 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ReplicatedParticipantTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ReplicatedParticipantTest.scala @@ -29,10 +29,10 @@ import com.digitalasset.canton.integration.bootstrap.{ } import com.digitalasset.canton.integration.plugins.* import com.digitalasset.canton.integration.{ + CantonEnvironmentSetup, CommunityIntegrationTest, ConfigTransforms, EnvironmentDefinition, - EnvironmentSetup, EnvironmentSetupPlugin, SharedEnvironment, TestConsoleEnvironment, @@ -113,7 +113,7 @@ trait ReplicatedNodeHelper { self: CommunityIntegrationTest => } trait ReplicatedParticipantTestSetup extends ReplicatedNodeHelper { - self: CommunityIntegrationTest with EnvironmentSetup => + self: CommunityIntegrationTest with CantonEnvironmentSetup => protected def setupPlugins( storagePlugin: EnvironmentSetupPlugin, diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SequencerIdsRetrieverIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SequencerIdsRetrieverIntegrationTest.scala index d8580dc776..859d6c972a 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SequencerIdsRetrieverIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SequencerIdsRetrieverIntegrationTest.scala @@ -13,7 +13,8 @@ import com.digitalasset.canton.admin.api.client.data.{ } import com.digitalasset.canton.config.RequireTypes.{NonNegativeInt, PositiveInt} import com.digitalasset.canton.config.{ExponentialBackoffConfig, NonNegativeDuration} -import com.digitalasset.canton.console.{MediatorReference, SequencerReference} +import com.digitalasset.canton.console.MediatorReference +import com.digitalasset.canton.integration.bootstrap.NetworkTopologyDescription.MediatorSequencersConfiguration import com.digitalasset.canton.integration.bootstrap.{ NetworkBootstrapper, NetworkTopologyDescription, @@ -50,12 +51,18 @@ final class SequencerIdsRetrieverIntegrationTest Ensure each mediator is connected to a single sequencer. The goal is that if a sequencer is stopped (sequencer4 in this test) it does not impact */ - val mediatorToSequencers - : Map[MediatorReference, (Seq[SequencerReference], PositiveInt, NonNegativeInt)] = + val mediatorToSequencers: Map[MediatorReference, MediatorSequencersConfiguration] = sequencers .zip(mediators) .map { case (sequencer, mediator) => - (mediator, (Seq(sequencer), PositiveInt.one, NonNegativeInt.zero)) + ( + mediator, + MediatorSequencersConfiguration( + Seq(sequencer), + trustThreshold = PositiveInt.one, + livenessMargin = NonNegativeInt.zero, + ), + ) } .toMap diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SubmissionRequestAmplificationIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SubmissionRequestAmplificationIntegrationTest.scala index d26918eb3c..1fd18fe855 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SubmissionRequestAmplificationIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SubmissionRequestAmplificationIntegrationTest.scala @@ -24,6 +24,7 @@ import com.digitalasset.canton.console.{ } import com.digitalasset.canton.integration.EnvironmentDefinition.S2M2 import com.digitalasset.canton.integration.bootstrap.NetworkBootstrapper +import com.digitalasset.canton.integration.bootstrap.NetworkTopologyDescription.MediatorSequencersConfiguration import com.digitalasset.canton.integration.plugins.{ UseBftSequencer, UsePostgres, @@ -79,8 +80,16 @@ abstract class SubmissionRequestAmplificationIntegrationTest Map( // A threshold of two ensures that the mediators connect to both sequencers. // TODO(#19911) Make this properly configurable - mediator1 -> (Seq(sequencer1, sequencer2), PositiveInt.two, NonNegativeInt.zero), - mediator2 -> (Seq(sequencer1, sequencer2), PositiveInt.two, NonNegativeInt.zero), + mediator1 -> MediatorSequencersConfiguration( + Seq(sequencer1, sequencer2), + trustThreshold = PositiveInt.two, + livenessMargin = NonNegativeInt.zero, + ), + mediator2 -> MediatorSequencersConfiguration( + Seq(sequencer1, sequencer2), + trustThreshold = PositiveInt.two, + livenessMargin = NonNegativeInt.zero, + ), ) ) ) diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SynchronizerChangeIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SynchronizerChangeIntegrationTest.scala index 89064323f8..add3754a4b 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SynchronizerChangeIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SynchronizerChangeIntegrationTest.scala @@ -110,7 +110,7 @@ abstract class SynchronizerChangeIntegrationTest(config: SynchronizerChangeInteg .updateTargetTimestampForwardTolerance( config.targetTimestampForwardTolerance.duration ), - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .addConfigTransforms(additionalConfigTransforms*) .withSetup(setUp) diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SynchronizerConnectivityIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SynchronizerConnectivityIntegrationTest.scala index 1e816b0938..a5f4b4f6cc 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SynchronizerConnectivityIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SynchronizerConnectivityIntegrationTest.scala @@ -144,7 +144,21 @@ sealed trait SynchronizerConnectivityIntegrationTest participant2.synchronizers.list_connected().map(_.synchronizerAlias) shouldBe Seq(daName) participant2.synchronizers.disconnect_all() participant2.synchronizers.list_connected() shouldBe empty + } + } + + "A participant" must { + "Be able to change the config" in { implicit env => + import env.* + + participant1.synchronizers.modify(daName, _.focus(_.priority).modify(_ + 1)) + participant1.synchronizers.modify( + daName, + _.focus(_.priority).modify(_ + 1), + physicalSynchronizerId = Some(daId), + ) + succeed } } } diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SynchronizerRouterIntegrationTestSetup.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SynchronizerRouterIntegrationTestSetup.scala index aea5e11b14..a0dbf4a21a 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SynchronizerRouterIntegrationTestSetup.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SynchronizerRouterIntegrationTestSetup.scala @@ -49,7 +49,7 @@ trait SynchronizerRouterIntegrationTestSetup EnvironmentDefinition .P4_S1M1_S1M1_S1M1() .addConfigTransforms( - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/TickRequestIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/TickRequestIntegrationTest.scala index a2cec73877..a9c0076260 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/TickRequestIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/TickRequestIntegrationTest.scala @@ -89,7 +89,7 @@ sealed trait TickRequestIntegrationTest ConfigTransforms.useStaticTime, ConfigTransforms.updateSynchronizerTimeTrackerConfigs_(_ => synchronizerTimeTrackerConfig), ConfigTransforms.updateTargetTimestampForwardTolerance(Duration.ofHours(1)), - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .addConfigTransforms( ConfigTransforms.setTopologyTransactionRegistrationTimeout( diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/acs/ActiveContractsIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/acs/ActiveContractsIntegrationTest.scala index 1704370b90..bd61cd163e 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/acs/ActiveContractsIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/acs/ActiveContractsIntegrationTest.scala @@ -68,7 +68,7 @@ import scala.jdk.CollectionConverters.* import ActiveContractsIntegrationTestBase.* -abstract class ActiveContractsIntegrationTestBase(alphaMultiSynchronizerSupport: Boolean = false) +abstract class ActiveContractsIntegrationTestBase(enableAllLedgerApiReassignments: Boolean = false) extends CommunityIntegrationTest with SharedEnvironment with AcsInspection @@ -96,9 +96,10 @@ abstract class ActiveContractsIntegrationTestBase(alphaMultiSynchronizerSupport: // Ensure reassignments are not tripped up by some participants being a little behind. ConfigTransforms.updateTargetTimestampForwardTolerance(30.seconds), ConfigTransforms.updateAllParticipantConfigs_( - _.focus(_.parameters.alphaMultiSynchronizerSupport).replace(alphaMultiSynchronizerSupport) + _.focus(_.parameters.enableAllLedgerApiReassignments) + .replace(enableAllLedgerApiReassignments) ), - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .withSetup { implicit env => import env.* @@ -263,7 +264,7 @@ abstract class ActiveContractsIntegrationTestBase(alphaMultiSynchronizerSupport: val createdEvent = eventually() { val endOffset = participant1.ledger_api.state.end() - if (participant1.config.parameters.alphaMultiSynchronizerSupport) { + if (participant1.config.parameters.enableAllLedgerApiReassignments) { participant1.ledger_api.updates .reassignments( partyIds = Set(signatory), @@ -900,4 +901,4 @@ private object ActiveContractsIntegrationTestBase { final class ActiveContractsIntegrationTest extends ActiveContractsIntegrationTestBase final class ActiveContractsReassignmentIntegrationTest - extends ActiveContractsIntegrationTestBase(alphaMultiSynchronizerSupport = true) + extends ActiveContractsIntegrationTestBase(enableAllLedgerApiReassignments = true) diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/acs/commitment/AcsCommitmentProcessorIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/acs/commitment/AcsCommitmentProcessorIntegrationTest.scala index bf17f6cba9..0224256376 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/acs/commitment/AcsCommitmentProcessorIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/acs/commitment/AcsCommitmentProcessorIntegrationTest.scala @@ -90,7 +90,7 @@ sealed trait AcsCommitmentProcessorIntegrationTest ConfigTransforms.useStaticTime, // this only sets/enables session signing keys when running with PV35 or higher ConfigTransforms.setSigningKeysIfPV35OrHigher( - SessionSigningKeysConfig.default.copy( + SessionSigningKeysConfig.enabled.copy( // we evict the session key cache right away to make sure we use a fresh session signing key for each request keyEvictionPeriod = config.PositiveFiniteDuration.ofMillis(1), // we must disable bound checks because `keyEvictionPeriod` is shorter than `keyValidityDuration` diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/acs/commitment/AcsCommitmentToolingIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/acs/commitment/AcsCommitmentToolingIntegrationTest.scala index 9d08efae33..179c4250f1 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/acs/commitment/AcsCommitmentToolingIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/acs/commitment/AcsCommitmentToolingIntegrationTest.scala @@ -101,7 +101,7 @@ trait AcsCommitmentToolingIntegrationTest ConfigTransforms.useStaticTime, ConfigTransforms.updateMaxDeduplicationDurations(maxCommandDeduplicationDuration), ConfigTransforms.updateTargetTimestampForwardTolerance(24.hours), - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .updateTestingConfig( _.focus(_.commitmentSendDelay).replace( diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/acs/commitment/util/CommitmentTestUtil.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/acs/commitment/util/CommitmentTestUtil.scala index da391bef40..3817ac298f 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/acs/commitment/util/CommitmentTestUtil.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/acs/commitment/util/CommitmentTestUtil.scala @@ -379,32 +379,74 @@ trait CommitmentTestUtil protected def awaitNextTick( participant: LocalParticipantReference, - counterparticipant: ParticipantReference, - )(implicit env: TestConsoleEnvironment, intervalDuration: IntervalDuration): CommitmentPeriod = { + counterParticipant: LocalParticipantReference, + )(implicit + env: TestConsoleEnvironment, + intervalDuration: IntervalDuration, + ): CommitmentPeriod = { import env.* val simClock = environment.simClock.value val tick1 = tickAfter(simClock.uniqueTime()) simClock.advanceTo(tick1.forgetRefinement.immediateSuccessor) + // Await the synchronizer time. Internally this will trigger a fetch of the synchronizer time. participant.testing.await_synchronizer_time(daId, tick1.forgetRefinement.immediateSuccessor) + counterParticipant.testing.await_synchronizer_time( + daId, + tick1.forgetRefinement.immediateSuccessor, + ) - val p1Computed = eventually() { - val p1Computed = participant.commitments.computed( + val participantComputed = eventually() { + val participantComputed = participant.commitments.computed( daName, tick1.toInstant.minusMillis(1), tick1.toInstant, - Some(counterparticipant.id), + Some(counterParticipant.id), ) - p1Computed should have size 1L - p1Computed + participantComputed should have size 1L + + val counterParticipantComputed = counterParticipant.commitments.computed( + daName, + tick1.toInstant.minusMillis(1), + tick1.toInstant, + Some(participant.id), + ) + counterParticipantComputed should have size 1L + + participantComputed } - val (period, _participant, commitment) = p1Computed.loneElement + // the values are the same for the participant and counter participant, but it is better to wait for both in wallClock time + val (period, _participantId, commitment) = participantComputed.loneElement period } - protected def checkReceivedCommitment( + protected def checkSentCommitmentTo( + recipients: Seq[ParticipantReference] + )( + period: CommitmentPeriod, + participant: ParticipantReference, + synchronizer: SynchronizerId, + expected: Int = 1, + ): Unit = eventually() { + val timeRange = + TimeRange(period.fromExclusive.forgetRefinement, period.toInclusive.forgetRefinement) + val sentCommitments = participant.commitments.lookup_sent_acs_commitments( + synchronizerTimeRanges = Seq(SynchronizerTimeRange(synchronizer, Some(timeRange))), + counterParticipants = Seq.empty, + commitmentState = Seq.empty, + verboseMode = false, + ) + + val sentCommitmentsOnSynchronizer = sentCommitments.get(synchronizer).value + sentCommitmentsOnSynchronizer.size should be >= expected + sentCommitmentsOnSynchronizer.map( + _.destCounterParticipant.uid + ) should contain theSameElementsAs (recipients.map(_.uid)) + } + + protected def checkReceivedCommitments( period: CommitmentPeriod, participant: ParticipantReference, synchronizer: SynchronizerId, diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/benchmarks/ReplayingParticipant.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/benchmarks/ReplayingParticipant.scala index f851f09fde..7f98b54fe8 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/benchmarks/ReplayingParticipant.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/benchmarks/ReplayingParticipant.scala @@ -267,6 +267,7 @@ object ReplayingParticipant extends FutureHelpers with EitherValues with OptionV testedReleaseProtocolVersion, futureSupervisor, clock, + CommonMockMetrics.cryptoMetrics, executionContext, timeouts, BatchingConfig(), diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/bftsynchronizer/ReassignmentTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/bftsynchronizer/ReassignmentTest.scala index 24f071cfb0..973ae97d4b 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/bftsynchronizer/ReassignmentTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/bftsynchronizer/ReassignmentTest.scala @@ -28,7 +28,7 @@ trait ReassignmentTest extends CommunityIntegrationTest with SharedEnvironment { override def environmentDefinition: EnvironmentDefinition = EnvironmentDefinition.P5S4M4_Manual - .addConfigTransform(ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag) + .addConfigTransform(ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag) protected val sequencerGroups: MultiSynchronizer = MultiSynchronizer( Seq( diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/connection/BftSequencerConnectionsIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/connection/BftSequencerConnectionsIntegrationTest.scala index 5d53112e61..cac15cd3a8 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/connection/BftSequencerConnectionsIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/connection/BftSequencerConnectionsIntegrationTest.scala @@ -14,6 +14,7 @@ import com.digitalasset.canton.annotations.UnstableTest import com.digitalasset.canton.config.DbConfig import com.digitalasset.canton.config.RequireTypes.{NonNegativeInt, PositiveInt} import com.digitalasset.canton.console.InstanceReference +import com.digitalasset.canton.integration.bootstrap.NetworkTopologyDescription.MediatorSequencersConfiguration import com.digitalasset.canton.integration.bootstrap.{ NetworkBootstrapper, NetworkTopologyDescription, @@ -106,8 +107,11 @@ sealed trait BftSequencerConnectionsIntegrationTest mediators = Seq(mediator1), overrideMediatorToSequencers = Some( Map( - mediator1 -> (sequencers.remote, - /* trust threshold */ PositiveInt.two, /* liveness margin */ NonNegativeInt.zero) + mediator1 -> MediatorSequencersConfiguration( + sequencers.remote, + trustThreshold = PositiveInt.two, + livenessMargin = NonNegativeInt.zero, + ) ) ), ) diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/connection/ConnectionPoolHealthIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/connection/ConnectionPoolHealthIntegrationTest.scala index 6c4e691301..0bdcc4ef80 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/connection/ConnectionPoolHealthIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/connection/ConnectionPoolHealthIntegrationTest.scala @@ -13,6 +13,7 @@ import com.digitalasset.canton.admin.api.client.data.{ } import com.digitalasset.canton.config.RequireTypes.{NonNegativeInt, PositiveInt} import com.digitalasset.canton.console.{InstanceReference, LocalInstanceReference} +import com.digitalasset.canton.integration.bootstrap.NetworkTopologyDescription.MediatorSequencersConfiguration import com.digitalasset.canton.integration.bootstrap.{ NetworkBootstrapper, NetworkTopologyDescription, @@ -46,8 +47,11 @@ sealed trait ConnectionPoolHealthIntegrationTest mediators = Seq(mediator1), overrideMediatorToSequencers = Some( Map( - mediator1 -> (sequencers.local, - /* trust threshold */ PositiveInt.two, /* liveness margin */ NonNegativeInt.one) + mediator1 -> MediatorSequencersConfiguration( + sequencers.local, + trustThreshold = PositiveInt.two, + livenessMargin = NonNegativeInt.one, + ) ) ), ) diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/connection/SequencerConnectionServiceIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/connection/SequencerConnectionServiceIntegrationTest.scala index da67689410..8f01ab9b4f 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/connection/SequencerConnectionServiceIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/connection/SequencerConnectionServiceIntegrationTest.scala @@ -11,6 +11,7 @@ import com.digitalasset.canton.admin.api.client.data.{ } import com.digitalasset.canton.config.RequireTypes.{NonNegativeInt, PositiveInt} import com.digitalasset.canton.console.InstanceReference +import com.digitalasset.canton.integration.bootstrap.NetworkTopologyDescription.MediatorSequencersConfiguration import com.digitalasset.canton.integration.bootstrap.{ NetworkBootstrapper, NetworkTopologyDescription, @@ -61,8 +62,11 @@ sealed trait SequencerConnectionServiceIntegrationTest mediators = Seq(mediator1), overrideMediatorToSequencers = Some( Map( - mediator1 -> (Seq(sequencer1, sequencer2), - /* trust threshold */ PositiveInt.one, /* liveness margin */ NonNegativeInt.zero) + mediator1 -> MediatorSequencersConfiguration( + Seq(sequencer1, sequencer2), + trustThreshold = PositiveInt.one, + livenessMargin = NonNegativeInt.zero, + ) ) ), ) diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/crashrecovery/AcsCommitmentCrashIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/crashrecovery/AcsCommitmentCrashIntegrationTest.scala index aa9c237624..75f26753ea 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/crashrecovery/AcsCommitmentCrashIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/crashrecovery/AcsCommitmentCrashIntegrationTest.scala @@ -152,6 +152,11 @@ class AcsCommitmentCrashIntegrationTest IouSyntax.createIou(participant1)(participant1.adminParty, participant2.adminParty) val period = awaitNextTick(participant1, participant2) - checkReceivedCommitment(period, participant2, daId, Match) + + // Check that we sent out the commitments (even if the send delay is zero) + checkSentCommitmentTo(Seq(participant2))(period, participant1, daId) + checkSentCommitmentTo(Seq(participant1))(period, participant2, daId) + + checkReceivedCommitments(period, participant2, daId, Match) } } diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/crashrecovery/MediatorFailoverIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/crashrecovery/MediatorFailoverIntegrationTest.scala index b68df2a2da..be215539fc 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/crashrecovery/MediatorFailoverIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/crashrecovery/MediatorFailoverIntegrationTest.scala @@ -21,9 +21,9 @@ import com.digitalasset.canton.integration.plugins.{ } import com.digitalasset.canton.integration.tests.* import com.digitalasset.canton.integration.{ + CantonEnvironmentSetup, CommunityIntegrationTest, EnvironmentDefinition, - EnvironmentSetup, EnvironmentSetupPlugin, SharedEnvironment, TestConsoleEnvironment, @@ -34,7 +34,7 @@ import com.digitalasset.canton.sequencing.client.SequencerClient trait MediatorFailoverIntegrationTest extends ReliabilityTestSuite with ReplicatedMediatorTestSetup { - self: CommunityIntegrationTest with EnvironmentSetup => + self: CommunityIntegrationTest with CantonEnvironmentSetup => protected def startAndGet(external: UseExternalProcess)( mediatorName: String diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/crashrecovery/ParticipantRestartTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/crashrecovery/ParticipantRestartTest.scala index 158cbafa07..71b1ea8ef1 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/crashrecovery/ParticipantRestartTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/crashrecovery/ParticipantRestartTest.scala @@ -84,6 +84,7 @@ import com.digitalasset.canton.integration.util.{EntitySyntax, PartiesAllocator} import com.digitalasset.canton.ledger.error.groups.ConsistencyErrors.SubmissionAlreadyInFlight import com.digitalasset.canton.logging.ErrorLoggingContext import com.digitalasset.canton.logging.SuppressingLogger.LogEntryOptionality +import com.digitalasset.canton.metrics.CommonMockMetrics import com.digitalasset.canton.networking.Endpoint import com.digitalasset.canton.participant.ParticipantNodeParameters import com.digitalasset.canton.participant.admin.inspection.SyncStateInspection @@ -391,6 +392,7 @@ abstract class ParticipantRestartTest testedReleaseProtocolVersion, futureSupervisor, wallClock, + CommonMockMetrics.cryptoMetrics, executionContext, timeouts, BatchingConfig(), @@ -610,7 +612,7 @@ class ParticipantRestartCausalityIntegrationTest extends ParticipantRestartTest EnvironmentDefinition.P4S2M2_Manual .addConfigTransforms( ConfigTransforms.updateTargetTimestampForwardTolerance(30.seconds), - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .withSetup { implicit env => NetworkBootstrapper(EnvironmentDefinition.S1M1_S1M1) @@ -975,7 +977,7 @@ class ParticipantRestartRealClockIntegrationTest extends ParticipantRestartTest override lazy val environmentDefinition: EnvironmentDefinition = EnvironmentDefinition.P3S2M2_Manual .addConfigTransforms( - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ProgrammableSequencer.configOverride(getClass.toString, loggerFactory), ) @@ -1589,7 +1591,7 @@ class ParticipantRestartRealClockIntegrationTest extends ParticipantRestartTest } abstract class ParticipantRestartStaticTimeIntegrationTestBase( - alphaMultiSynchronizerSupport: Boolean = false + enableAllLedgerApiReassignments: Boolean = false ) extends ParticipantRestartTest { private val overrideMaxRequestSize = NonNegativeInt.tryCreate(100 * 1024) @@ -1608,7 +1610,8 @@ abstract class ParticipantRestartStaticTimeIntegrationTestBase( _.focus(_.sequencerClient.overrideMaxRequestSize).replace(Some(overrideMaxRequestSize)) ), ConfigTransforms.updateAllParticipantConfigs_( - _.focus(_.parameters.alphaMultiSynchronizerSupport).replace(alphaMultiSynchronizerSupport) + _.focus(_.parameters.enableAllLedgerApiReassignments) + .replace(enableAllLedgerApiReassignments) ), ) .withSetup { implicit env => @@ -1746,7 +1749,7 @@ abstract class ParticipantRestartStaticTimeIntegrationTestBase( participant1.repair.purge(daName, Seq(baselineContractId), ignoreAlreadyPurged = false) - val (repairOffset, repairRecordTime) = if (alphaMultiSynchronizerSupport) { + val (repairOffset, repairRecordTime) = if (enableAllLedgerApiReassignments) { participant1.ledger_api.updates .reassignments( Set(party), @@ -2199,7 +2202,7 @@ class ParticipantRestartStaticTimeIntegrationTest @UnstableTest // TODO(#30408) class ParticipantRestartStaticTimeReassignmentIntegrationTest - extends ParticipantRestartStaticTimeIntegrationTestBase(alphaMultiSynchronizerSupport = true) + extends ParticipantRestartStaticTimeIntegrationTestBase(enableAllLedgerApiReassignments = true) @nowarn("msg=match may not be exhaustive") class ParticipantRestartContractKeyIntegrationTest extends ParticipantRestartTest { diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/crashrecovery/SequencerRestartTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/crashrecovery/SequencerRestartTest.scala index 2bb28e2e51..c007974e86 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/crashrecovery/SequencerRestartTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/crashrecovery/SequencerRestartTest.scala @@ -350,7 +350,7 @@ abstract class BaseSynchronizerRestartTest } -class SequencerRestartTest +final class SequencerRestartTest extends BaseSynchronizerRestartTest with FlagCloseable with HasCloseContext { diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/examples/ExampleIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/examples/ExampleIntegrationTest.scala index 55051ae349..1702cc7577 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/examples/ExampleIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/examples/ExampleIntegrationTest.scala @@ -8,7 +8,7 @@ import com.digitalasset.canton.ConsoleScriptRunner import com.digitalasset.canton.config.CantonConfig import com.digitalasset.canton.console.BufferedProcessLogger import com.digitalasset.canton.discard.Implicits.DiscardOps -import com.digitalasset.canton.environment.Environment +import com.digitalasset.canton.environment.CantonEnvironment import com.digitalasset.canton.integration.{ CantonBaseIntegrationTest, ConfigTransform, @@ -20,7 +20,7 @@ import com.digitalasset.canton.util.Mutex import com.digitalasset.canton.util.ShowUtil.* abstract class ExampleIntegrationTest(configPaths: File*) - extends BaseIntegrationTest + extends CantonBaseIntegrationTest with IsolatedEnvironments with HasConsoleScriptRunner { @@ -73,7 +73,7 @@ abstract class ExampleIntegrationTest(configPaths: File*) trait HasConsoleScriptRunner { this: NamedLogging => import org.scalatest.EitherValues.* - def runScript(scriptPath: File)(implicit env: Environment): Unit = + def runScript(scriptPath: File)(implicit env: CantonEnvironment): Unit = ConsoleScriptRunner.run(env, scriptPath.toJava, logger = logger).value.discard } diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/examples/MultisyncExampleIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/examples/MultisyncExampleIntegrationTest.scala index 84c83ea9b7..9ad4c9cdb3 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/examples/MultisyncExampleIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/examples/MultisyncExampleIntegrationTest.scala @@ -26,7 +26,7 @@ import org.slf4j.event.Level import scala.sys.process.Process class MultisyncExampleIntegrationTest - extends BaseIntegrationTest + extends CantonBaseIntegrationTest with IsolatedEnvironments with CommunityIntegrationTest { diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/health/RemoteDumpIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/health/RemoteDumpIntegrationTest.scala index 344eb6402c..3bd8c748bb 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/health/RemoteDumpIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/health/RemoteDumpIntegrationTest.scala @@ -14,7 +14,7 @@ import com.digitalasset.canton.console.{ HealthDumpGenerator, InstanceReference, } -import com.digitalasset.canton.environment.{Environment, EnvironmentFactory} +import com.digitalasset.canton.environment.{CantonEnvironment, CantonEnvironmentFactory} import com.digitalasset.canton.integration.plugins.{ UseBftSequencer, UseExternalProcess, @@ -334,13 +334,13 @@ class NegativeRemoteDumpIntegrationTest registerPlugin(new UseBftSequencer(loggerFactory)) // Customize the environment factory to tweak the health dump generation - override protected val environmentFactory: EnvironmentFactory = + override protected val environmentFactory: CantonEnvironmentFactory = ( config: CantonConfig, loggerFactory: NamedLoggerFactory, testingConfigInternal: TestingConfigInternal, ) => - new Environment( + new CantonEnvironment( config, testingConfigInternal, ParticipantNodeBootstrapFactoryImpl, diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/AchsInitIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/AchsInitIntegrationTest.scala new file mode 100644 index 0000000000..161eaac2c9 --- /dev/null +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/AchsInitIntegrationTest.scala @@ -0,0 +1,162 @@ +// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.integration.tests.ledgerapi + +import com.digitalasset.canton.config.RequireTypes.{NonNegativeInt, NonNegativeLong} +import com.digitalasset.canton.examples.java.iou.Dummy +import com.digitalasset.canton.integration.plugins.{UseBftSequencer, UsePostgres} +import com.digitalasset.canton.integration.{ + CommunityIntegrationTest, + ConfigTransform, + ConfigTransforms, + EnvironmentDefinition, + IsolatedEnvironments, + TestConsoleEnvironment, +} +import com.digitalasset.canton.logging.SuppressionRule +import com.digitalasset.canton.platform.indexer.IndexerConfig.AchsConfig +import com.digitalasset.canton.util.ResourceUtil.withResource +import monocle.macros.syntax.lens.* +import org.slf4j.event.Level + +import scala.concurrent.duration.* +import scala.concurrent.{Await, Future} +import scala.jdk.CollectionConverters.* + +trait AchsInitIntegrationTest extends CommunityIntegrationTest with IsolatedEnvironments { + + override lazy val environmentDefinition: EnvironmentDefinition = + EnvironmentDefinition.P1_S1M1 + .addConfigTransforms( + ConfigTransforms.disableAchs, + ConfigTransforms.disableAdditionalConsistencyChecks, + ) + + private val achsLogRule: SuppressionRule = + SuppressionRule.LoggerNameContains("InitializeParallelIngestion") && SuppressionRule + .LevelAndAbove(Level.INFO) + + private def stopAllNodes(implicit env: TestConsoleEnvironment): Unit = { + import env.* + participants.all.synchronizers.disconnect(daName) + nodes.local.stop() + } + + "ACHS initialization is interrupted when participant is shut down during startup" when { + "shutdown is triggered via participant.stop()" in { implicit env => + runAchsInitInterruptionTest { participant => + participant.stop() + } + } + + "shutdown is triggered via participant.close()" in { implicit env => + runAchsInitInterruptionTest { participant => + // Bypass Nodes.stopAndWait and close the bootstrap directly. This + // exercises the isClosing-driven cancellation path (no + // cancelInitializationHint() call): OnShutdownRunner.close() flips + // isClosing as its first action, which the ACHS init pipe observes + // via externalShutdownSignal. + val bootstrap = participant.consoleEnvironment.environment.participants + .getStarting(participant.name) + .getOrElse(fail(s"participant ${participant.name} is not in StartingUp state")) + bootstrap.close() + } + } + } + + private def runAchsInitInterruptionTest( + triggerShutdown: com.digitalasset.canton.console.LocalParticipantReference => Unit + )(implicit env: TestConsoleEnvironment): Unit = { + import env.* + + participant1.synchronizers.connect_local(sequencer1, daName) + + participant1.dars.upload(CantonTestsPath) + + val alice = participant1.parties.enable("Alice") + + // Generate a lot of ACHS work (while ACHS is disabled) so that the + // eventual ACHS initialization, with `initParallelism = 1` and + // `initAggregationThreshold = 1`, takes a meaningful amount of time and + // can be reliably interrupted by a shutdown mid-init. + val createAndArchiveDummy = + new Dummy(alice.toProtoPrimitive) + .createAnd() + .exerciseArchive() + .commands + .asScala + .toSeq + val commandsPerTx = 100 + val txCount = 20 + for (i <- 1 to txCount) { + participant1.ledger_api.javaapi.commands.submit( + Seq(alice), + (1 to commandsPerTx).flatMap(_ => createAndArchiveDummy), + commandId = s"setup-dummy-$i", + ) + } + + val slowInitAchsConfig = AchsConfig( + validAtDistanceTarget = NonNegativeLong.tryCreate(10L), + lastPopulatedDistanceTarget = NonNegativeLong.tryCreate(5L), + aggregationThreshold = 5L, + initParallelism = NonNegativeInt.tryCreate(1), + initAggregationThreshold = 1L, + ) + val slowInitAchsTransform: ConfigTransform = + ConfigTransforms.updateAllParticipantConfigs_( + _.focus(_.parameters.ledgerApiServer.indexer.achsConfig) + .replace(Some(slowInitAchsConfig)) + ) + // Force manual start so we drive participant startup ourselves and can race + // a shutdown against ACHS initialization. + val manualStartTransform: ConfigTransform = + c => c.copy(parameters = c.parameters.copy(manualStart = true)) + + stopAllNodes(env) + val newEnv = manualCreateEnvironmentWithPreviousState( + env.actualConfig, + _ => manualStartTransform(slowInitAchsTransform(env.actualConfig)), + ) + withResource(newEnv) { achsEnv => + import achsEnv.* + + sequencer1.start() + mediator1.start() + + val interruptedMessage = "ACHS snapshot initialization interrupted by shutdown request" + loggerFactory.assertLogsSeq(achsLogRule)( + { + // Start the participant in the background, start() blocks on full init, + // which in turn blocks on ACHS initialization completing. + val startF = Future(participant1.start()) + + eventually(60.seconds, maxPollInterval = 100.millis) { + loggerFactory.fetchRecordedLogEntries.exists( + _.message.contains("Initializing ACHS snapshot") + ) shouldBe true + } + + triggerShutdown(participant1) + scala.util.Try(Await.result(startF, 5.minute)) + }, + logEntries => { + val interruptedLogs = + logEntries.filter(_.message.contains(interruptedMessage)) + withClue( + s"Expected '$interruptedMessage' log message during shutdown. " + + s"All ACHS-related log entries:\n${logEntries.map(_.message).mkString("\n")}" + ) { + interruptedLogs should not be empty + } + }, + ) + } + } +} + +class AchsInitIntegrationTestPostgres extends AchsInitIntegrationTest { + registerPlugin(new UsePostgres(loggerFactory)) + registerPlugin(new UseBftSequencer(loggerFactory)) +} diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/AchsIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/AchsIntegrationTest.scala index d27ecc8683..254fa23ac2 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/AchsIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/AchsIntegrationTest.scala @@ -52,8 +52,8 @@ trait AchsIntegrationTest extends CommunityIntegrationTest with SharedEnvironmen storageBackendFactory.createParameterStorageBackend(ledgerApiStore.stringInterningView) ledgerApiStore.ledgerApiDbSupport.dbDispatcher .executeSql( - DatabaseMetrics.ForTesting("fetchACHSState") - )(parameterStorageBackend.fetchACHSState)( + DatabaseMetrics.ForTesting("fetchAchsState") + )(parameterStorageBackend.fetchAchsState)( LoggingContextWithTrace.empty ) .futureValue diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/AchsRepairIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/AchsRepairIntegrationTest.scala index d5a78be1f3..544717a02a 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/AchsRepairIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/AchsRepairIntegrationTest.scala @@ -50,8 +50,8 @@ sealed trait AchsRepairIntegrationTest extends RepairServiceIntegrationTest { storageBackendFactory.createParameterStorageBackend(ledgerApiStore.stringInterningView) ledgerApiStore.ledgerApiDbSupport.dbDispatcher .executeSql( - DatabaseMetrics.ForTesting("fetchACHSState") - )(parameterStorageBackend.fetchACHSState)( + DatabaseMetrics.ForTesting("fetchAchsState") + )(parameterStorageBackend.fetchAchsState)( LoggingContextWithTrace.empty ) .futureValue diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/LedgerApiParticipantPruningTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/LedgerApiParticipantPruningTest.scala index 3590dbc093..60a5db52e9 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/LedgerApiParticipantPruningTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/LedgerApiParticipantPruningTest.scala @@ -38,11 +38,17 @@ import com.digitalasset.canton.ledger.error.LedgerApiErrors.ParticipantContractP import com.digitalasset.canton.ledger.error.groups.RequestValidationErrors.ParticipantPruningInProgress import com.digitalasset.canton.logging.SuppressionRule import com.digitalasset.canton.participant.ledger.api.client.JavaDecodeUtil +import com.digitalasset.canton.platform.store.backend.DataSourceStorageBackend.DataSourceConfig +import com.digitalasset.canton.platform.store.backend.common.ComposableQuery.SqlStringInterpolation +import com.digitalasset.canton.platform.store.backend.common.QueryStrategy +import com.digitalasset.canton.platform.store.cache.MutableLedgerEndCache +import com.digitalasset.canton.platform.store.interning.MockStringInterning import com.digitalasset.canton.topology.SynchronizerId import com.digitalasset.daml.lf.value.Value.ContractId import monocle.macros.syntax.lens.* import org.slf4j.event +import java.sql.Connection import java.time.Duration as JDuration import java.util.UUID import java.util.concurrent.atomic.AtomicReference @@ -94,7 +100,7 @@ trait LedgerApiParticipantPruningTest ConfigTransforms.useStaticTime, ConfigTransforms.updateMaxDeduplicationDurations(transactionTolerance.asJava), lowerLedgerApiServerBatchSize, - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .withSetup { implicit env => import env.* @@ -174,8 +180,7 @@ trait LedgerApiParticipantPruningTest participant1.pruning.get_offset_by_time(tsOfLastPrunedEvent.toInstant) // Simulate concurrent pruning from another replica by issuing the pruning lock - val lockedPruning = - participant1.underlying.value.sync.participantNodePersistentState.value.ledgerApiStore.lockPruning + val lockedPruning = withConnectionForTest(participant1)(lockPruning(participant1)) // Prune and remember offsets. val pruneF = Future(participant1.pruning.prune(offsetAtTheBeginning)) @@ -194,16 +199,14 @@ trait LedgerApiParticipantPruningTest lockedPruning.commitAndClose() pruneF.futureValue participant1.testing.state_inspection.internalContractIdOf(cidBeginningContractId) shouldBe None - participant1.testing.state_inspection.internalContractIdOf( - cidMiddleContractId - ) should not be empty + val cidMiddleInternalContractIdOpt = + participant1.testing.state_inspection.internalContractIdOf(cidMiddleContractId) + cidMiddleInternalContractIdOpt should not be empty // Simulate blocking pruning by read locking one of the to-be-pruned contracts - val contractLock = - participant1.underlying.value.sync.participantNodePersistentState.value.ledgerApiStore - .readLockContract( - participant1.testing.state_inspection.internalContractIdOf(cidMiddleContractId).value - ) + val contractLock = withConnectionForTest(participant1)( + readLockContract(participant1, cidMiddleInternalContractIdOpt.value) + ) // Pruning fails if contract pruning cannot resolve the optimistic lock after retries loggerFactory.assertThrowsAndLogs[CommandFailure]( @@ -465,31 +468,28 @@ trait LedgerApiParticipantPruningTest val c1InternalContractId = participant1.testing.state_inspection.internalContractIdOf(c1ContractId).value - val (_, c2cid) = createContract(participant1, daId) - val c2ContractId = ContractId.assertFromString(c2cid) - val c2InternalContractId = - participant1.testing.state_inspection.internalContractIdOf(c2ContractId).value - - // this is needed for the locking approach to work later - c1InternalContractId should be < (c2InternalContractId) - // unassign val unassign = participant1.ledger_api.commands.submit_unassign( submitter = participant1.adminParty, - contractIds = Seq(c1ContractId, c2ContractId), + contractIds = Seq(c1ContractId), source = daId, target = acmeId, ) val pruningOffset = participant1.ledger_api.state.end() - // issue write lock on participant1 for C1, this will block the Indexer at ingestion of the following assignation on the first contract - val c1Lock = - participant1.underlying.value.sync.participantNodePersistentState.value.ledgerApiStore - .writeLockContract(c1InternalContractId) + // issue write lock on participant1 for C1, this will block the Indexer at ingestion of the following assignation on the contract + val c1Lock = withConnectionForTest(participant1)( + testFunction = writeLockContract(participant1, c1InternalContractId), + onCommit = conn => { + // simulate pruning by manually remove C1 from the contract store + deleteContract(participant1, c1InternalContractId)(conn) + logger.info("C1 contract removed") + }, + ) logger.info("C1 locked") - // reassign C1 and C2 to acme, this should be blocked on Indexing the assignment because of the lock above + // reassign C1 to acme, this should be blocked on Indexing the assignment because of the lock above val reassignmentF = Future( participant1.ledger_api.commands.submit_assign( submitter = participant1.adminParty, @@ -498,16 +498,11 @@ trait LedgerApiParticipantPruningTest target = acmeId, ) ) - logger.info("Reassignment of C1,C2 started") + logger.info("Reassignment of C1 started") // wait a little to make sure the assignment is already blocked Threading.sleep(5000) - logger.info("Waited 5 second") - - // simulate pruning by manually remove C2 from the contract store - participant1.testing.state_inspection.deleteContract(c2InternalContractId) - participant1.testing.state_inspection.internalContractIdOf(c2ContractId) shouldBe None - logger.info("C2 contract removed") + logger.info("Waited 5 seconds") loggerFactory.assertLogsSeq( SuppressionRule.Level(event.Level.INFO) && @@ -530,10 +525,10 @@ trait LedgerApiParticipantPruningTest }, ) - // C2 is reinserted - val c2NewInternalContractId = - participant1.testing.state_inspection.internalContractIdOf(c2ContractId).value - c2InternalContractId should be < (c2NewInternalContractId) + // C1 is reinserted + val c1NewInternalContractId = + participant1.testing.state_inspection.internalContractIdOf(c1ContractId).value + c1InternalContractId should be < (c1NewInternalContractId) // pruning before assign so that referential integrity is restored waitUntilSafeToPrune(participant1, Some(pruningOffset)) @@ -689,6 +684,60 @@ trait LedgerApiParticipantPruningTest .value offer.id.exerciseAcceptByPainter().commands.loneElement } + + private def withConnectionForTest( + participant: LocalParticipantReference + )(testFunction: Connection => Unit, onCommit: Connection => Unit = _ => ()) = { + val ledgerApiStore = + participant.underlying.value.sync.participantNodePersistentState.value.ledgerApiStore + val conn = + ledgerApiStore.ledgerApiDbSupport.storageBackendFactory.createDataSourceStorageBackend + .createDataSource( + dataSourceConfig = DataSourceConfig(ledgerApiStore.ledgerApiStorage.jdbcUrl), + loggerFactory = loggerFactory, + ) + .getConnection + conn.setAutoCommit(false) + QueryStrategy.withoutNetworkTimeout(testFunction(_))(conn, noTracingLogger) + new Object { + def commitAndClose(): Unit = { + onCommit(conn) + conn.commit() + conn.close() + } + } + } + + private def eventStorageBackend(participant: LocalParticipantReference) = + participant.underlying.value.sync.participantNodePersistentState.value.ledgerApiStore.ledgerApiDbSupport.storageBackendFactory + .createEventStorageBackend( + ledgerEndCache = MutableLedgerEndCache(), + stringInterning = new MockStringInterning, + loggerFactory = loggerFactory, + ) + + private def lockPruning(participant: LocalParticipantReference)(conn: Connection) = + eventStorageBackend(participant).lockExclusivelyPruningProcessingTable(conn) + + private def readLockContract(participant: LocalParticipantReference, internalContractId: Long)( + conn: Connection + ) = eventStorageBackend(participant).readLockInternalContractIds(Set(internalContractId))(conn) + + private def writeLockContract(participant: LocalParticipantReference, internalContractId: Long)( + conn: Connection + ) = + eventStorageBackend(participant).writeLockInternalContractIds(cSQL"= $internalContractId")(conn) + + def deleteContract(participant: LocalParticipantReference, internalContractId: Long)(implicit + conn: Connection + ): Int = { + val removed = SQL"DELETE FROM par_contracts WHERE internal_contract_id=$internalContractId" + .executeUpdate()(conn) + participant.underlying.value.sync.participantNodePersistentState.value.contractStore + .contractsPruned(List(internalContractId)) + removed + } + } class LedgerApiParticipantPruningTestPostgres extends LedgerApiParticipantPruningTest { diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/auth/GetCompletionsAuthIT.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/auth/GetCompletionsAuthIT.scala new file mode 100644 index 0000000000..afdaf642d2 --- /dev/null +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/auth/GetCompletionsAuthIT.scala @@ -0,0 +1,118 @@ +// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.integration.tests.ledgerapi.auth + +import com.daml.grpc.test.StreamConsumer +import com.daml.ledger.api.v2.command_completion_service.{ + CommandCompletionServiceGrpc, + CompletionStreamResponse, + GetCompletionsRequest, +} +import com.daml.test.evidence.scalatest.ScalaTestSupport.Implicits.* +import com.digitalasset.canton.integration.TestConsoleEnvironment +import com.digitalasset.canton.integration.plugins.{UseBftSequencer, UseH2} +import com.digitalasset.canton.integration.tests.ledgerapi.services.SubmitAndWaitDummyCommand +import io.grpc.stub.StreamObserver + +import scala.concurrent.Future + +final class GetCompletionsAuthIT + extends ExpiringStreamServiceCallAuthTests[CompletionStreamResponse] + with SubmitAndWaitDummyCommand { + registerPlugin(new UseH2(loggerFactory)) + registerPlugin(new UseBftSequencer(loggerFactory)) + + override def serviceCallName: String = "CommandCompletionService#GetCompletions" + + override protected def stream( + context: ServiceCallContext, + env: TestConsoleEnvironment, + ): StreamObserver[CompletionStreamResponse] => Unit = + streamFor(context) + + private def mkRequest(parties: List[String]) = + GetCompletionsRequest(parties, 0) + + private def streamFor( + context: ServiceCallContext + ): StreamObserver[CompletionStreamResponse] => Unit = + observer => + stub(CommandCompletionServiceGrpc.stub(channel), context.token) + .getCompletions(mkRequest(List(context.mainActorId)), observer) + + override def serviceCall(context: ServiceCallContext)(implicit + env: TestConsoleEnvironment + ): Future[Any] = { + import env.* + val mainActorId = getMainActorId + submitAndWaitAsMainActor(mainActorId).flatMap(_ => + new StreamConsumer[CompletionStreamResponse]( + streamFor(context.copy(mainActorId = mainActorId)) + ).first() + ) + } + + serviceCallName should { + "allow calls with valid parties" taggedAs securityAsset + .setHappyCase( + "Ledger API client can make a GetCompletions call for its own parties" + ) in { implicit env => + import env.* + expectSuccess(serviceCall(canActAsMainActor)) + } + + "deny calls with empty parties for user without CanReadAsAnyParty" taggedAs securityAsset + .setAttack( + attackPermissionDenied(threat = + "Present a JWT without CanReadAsAnyParty and request completions with empty parties" + ) + ) in { implicit env => + import env.* + val mainActorId = getMainActorId + expectPermissionDenied( + submitAndWaitAsMainActor(mainActorId).flatMap { _ => + new StreamConsumer[CompletionStreamResponse](observer => + stub(CommandCompletionServiceGrpc.stub(channel), canActAsMainActor.token) + .getCompletions(mkRequest(List.empty), observer) + ).first() + } + ) + } + + "allow calls with empty parties for CanReadAsAnyParty user" taggedAs securityAsset + .setHappyCase( + "Ledger API client with CanReadAsAnyParty can call GetCompletions with empty parties" + ) in { implicit env => + import env.* + val mainActorId = getMainActorId + expectSuccess( + submitAndWaitAsMainActor(mainActorId).flatMap { _ => + new StreamConsumer[CompletionStreamResponse](observer => + stub(CommandCompletionServiceGrpc.stub(channel), canReadAsAnyParty.token) + .getCompletions(mkRequest(List.empty), observer) + ).first() + } + ) + } + + "deny calls requesting a party the user is not authorized for" taggedAs securityAsset + .setAttack( + attackPermissionDenied(threat = + "Present a JWT authorized for the main actor and request completions for a foreign party" + ) + ) in { implicit env => + import env.* + val mainActorId = getMainActorId + val foreignParty = getRandomPartyId + expectPermissionDenied( + submitAndWaitAsMainActor(mainActorId).flatMap { _ => + new StreamConsumer[CompletionStreamResponse](observer => + stub(CommandCompletionServiceGrpc.stub(channel), canActAsMainActor.token) + .getCompletions(mkRequest(List(foreignParty)), observer) + ).first() + } + ) + } + } +} diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/auth/IDPBoxingServiceCallOutTests.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/auth/IDPBoxingServiceCallOutTests.scala index 94be66f9d3..667f5d22b0 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/auth/IDPBoxingServiceCallOutTests.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/auth/IDPBoxingServiceCallOutTests.scala @@ -141,6 +141,46 @@ trait IDPBoxingServiceCallOutTests } } + "IDP admin granting rights that transcend IDP boundaries" should { + for ( + (description, kind) <- List[(String, uproto.Right.Kind)]( + ( + "read as any party", + uproto.Right.Kind.CanReadAsAnyParty(uproto.Right.CanReadAsAnyParty()), + ), + ( + "execute as any party", + uproto.Right.Kind.CanExecuteAsAnyParty(uproto.Right.CanExecuteAsAnyParty()), + ), + ( + "participant admin", + uproto.Right.Kind.ParticipantAdmin(uproto.Right.ParticipantAdmin()), + ), + ) + ) { + s"deny granting $description rights" taggedAs adminSecurityAsset + .setAttack( + attackUnknownResource(threat = s"Grant $description rights") + ) in { implicit env => + import env.* + loggerFactory.suppress(AuthServiceJWTSuppressionRule) { + expectPermissionDenied { + val suffix = UUID.randomUUID().toString + for { + (_, idpAdminContext, _) <- createIDPBundle(canBeAnAdmin, suffix) + + _ <- boxedCall( + "user-" + suffix, + idpAdminContext, + Vector(uproto.Right(kind)), + ) + } yield () + } + } + } + } + } + "allow Admin granting permissions to parties which do not exist" taggedAs adminSecurityAsset .setHappyCase( "Grant rights to non existing parties" diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/fixture/CantonFixture.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/fixture/CantonFixture.scala index d31bc18f2a..01a57cacc7 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/fixture/CantonFixture.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/fixture/CantonFixture.scala @@ -8,10 +8,10 @@ import com.digitalasset.canton.config.{AuthServiceConfig, PositiveDurationSecond import com.digitalasset.canton.console.LocalParticipantReference import com.digitalasset.canton.integration.tests.ledgerapi.auth.SandboxRequiringAuthorizationFuns import com.digitalasset.canton.integration.{ + CantonEnvironmentSetup, CommunityIntegrationTest, ConfigTransforms, EnvironmentDefinition, - EnvironmentSetup, IsolatedEnvironments, SharedEnvironment, TestConsoleEnvironment, @@ -47,7 +47,7 @@ trait CantonFixtureIsolated trait CantonFixtureAbstract extends CommunityIntegrationTest with SandboxRequiringAuthorizationFuns { - this: EnvironmentSetup => + this: CantonEnvironmentSetup => protected def darFile = new File(CantonTestsPath) diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/submission/ExternalPartyOnboardingIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/submission/ExternalPartyOnboardingIntegrationTest.scala index 00ac40d346..6344e3fb5a 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/submission/ExternalPartyOnboardingIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/submission/ExternalPartyOnboardingIntegrationTest.scala @@ -9,7 +9,9 @@ import com.digitalasset.canton.config import com.digitalasset.canton.config.RequireTypes.PositiveInt import com.digitalasset.canton.console.CommandFailure import com.digitalasset.canton.console.commands.PartiesAdministration +import com.digitalasset.canton.crypto.KeyPurpose.Signing import com.digitalasset.canton.crypto.{SigningKeyUsage, SigningKeysWithThreshold} +import com.digitalasset.canton.discard.Implicits.DiscardOps import com.digitalasset.canton.error.MediatorError import com.digitalasset.canton.integration.{ CommunityIntegrationTest, @@ -17,16 +19,21 @@ import com.digitalasset.canton.integration.{ EnvironmentDefinition, HasCycleUtils, SharedEnvironment, + TestConsoleEnvironment, } import com.digitalasset.canton.logging.LogEntry import com.digitalasset.canton.participant.topology.ParticipantTopologyManagerError.ExternalPartyAlreadyExists import com.digitalasset.canton.topology.admin.grpc.TopologyStoreId -import com.digitalasset.canton.topology.transaction.DelegationRestriction.CanSignAllMappings +import com.digitalasset.canton.topology.transaction.DelegationRestriction.{ + CanSignAllButNamespaceDelegations, + CanSignAllMappings, +} import com.digitalasset.canton.topology.transaction.ParticipantPermission.{ Confirmation, Observation, } import com.digitalasset.canton.topology.transaction.{ + DelegationRestriction, HostingParticipant, MultiTransactionSignature, NamespaceDelegation, @@ -85,6 +92,179 @@ class ExternalPartyOnboardingIntegrationTest extends ExternalPartyOnboardingInte .party shouldBe patrick.partyId } + "sign the external party transaction for the participant" when { + // This test case checks that a participant signs external party allocations only for itself, even if + // the participant owns a signing key with a delegation from another participant. This restriction + // is in place to avoid a loophole where a participant might not want to host an external party, + // but through a namespace delegation, the external party might try to acquire the participant's signature + // through other means (e.g. by submitting the allocation via a participant with a namespace delegation in place). + "the participant also has a delegated key for another participant" in { implicit env => + import env.* + + // set up the key delegation from participant2 to a key owned by participant1 + val delegatedKey = + participant1.keys.secret.generate_signing_key(usage = Set(SigningKeyUsage.Namespace)) + participant2.topology.namespace_delegations.propose_delegation( + participant2.namespace, + delegatedKey, + DelegationRestriction.CanSignSpecificMappings(PartyToParticipant.code), + store = daId, + ) + + eventually() { + participant1.topology.namespace_delegations + .list( + daId, + filterNamespace = participant2.namespace.filterString, + filterTargetKey = Some(delegatedKey.fingerprint), + ) + .loneElement + .discard + } + // validate that the delegation from participant2 to participant1's key actually works for PTPs + participant1.parties.enable( + "party-on-p2", + namespace = participant2.namespace, + synchronizer = daName, + ) + + val partyId = allocateExternalParty().partyId + + val ptp = eventually() { + participant2.topology.party_to_participant_mappings + .list(daId, proposals = true, filterParty = partyId) + .loneElement + } + // future-proofing the test: ensure that the PTP has both participants as hosting participants (and therefore eligigle for signing it) + ptp.item.participantIds should contain theSameElementsAs Seq( + participant1.id, + participant2.id, + ) + // check that participant1 only signed with the key for participant1, even though it owns a delegated key for participant2 + ptp.context.signedBy.forgetNE.loneElement shouldBe participant1.fingerprint + + // clean up: revoke the delegation again + participant2.topology.namespace_delegations.propose_revocation( + participant2.namespace, + targetKey = delegatedKey, + store = daId, + ) + + eventually() { + participant1.topology.namespace_delegations.list( + daId, + filterNamespace = participant2.namespace.filterString, + filterTargetKey = Some(delegatedKey.fingerprint), + ) shouldBe empty + } + + } + "the participant has an offline root key" in { implicit env => + import env.* + + // download the root key so that we can restore it at the end of the test + val rootNamespaceKey = participant1.keys.secret.download(participant1.fingerprint) + + // delete the root namespace key to make it "offline" + participant1.keys.secret.delete(participant1.fingerprint, force = true) + + // try to allocate the external party without any valid topology signing key + loggerFactory.assertThrowsAndLogs[CommandFailure]( + allocateExternalParty(), + _.errorMessage should include( + "Could not find an appropriate signing key to issue the topology transaction" + ), + ) + + // temporarily restore the root namespace key and issue a namespace delegation + // for an intermediate key + participant1.keys.secret.upload(rootNamespaceKey, name = None) + + val intermediateKey = + participant1.keys.secret.generate_signing_key(usage = Set(SigningKeyUsage.Namespace)) + participant1.topology.namespace_delegations.propose_delegation( + participant1.namespace, + intermediateKey, + CanSignAllButNamespaceDelegations, + store = daId, + ) + + eventually() { + participant1.topology.namespace_delegations + .list(daId, filterTargetKey = Some(intermediateKey.fingerprint)) + .loneElement + } + + // delete the root namespace key again + participant1.keys.secret.delete(participant1.fingerprint, force = true) + + // allocating the external party should work now + val externalPartyId = allocateExternalParty().partyId + + // validate that the mapping for the external party exists, + // and that it was only signed by intermediate key + eventually() { + participant1.topology.party_to_participant_mappings + .list( + daId, + proposals = true, + filterParty = externalPartyId, + ) + .loneElement + .context + .signedBy + .forgetNE should contain theSameElementsAs Seq(intermediateKey.fingerprint) + } + + participant1.keys.secret.upload(rootNamespaceKey, name = None) + } + } + + /** Allocates an external party with a random name in sequencer1's namespace with participant1 + * and participant2 as the hosting participants. + */ + def allocateExternalParty()(implicit env: TestConsoleEnvironment) = { + import env.* + participant1.ledger_api.parties + .allocate_external( + daId, + Seq( + TopologyTransaction( + TopologyChangeOp.Replace, + PositiveInt.one, + PartyToParticipant.tryCreate( + PartyId.tryCreate(UUID.randomUUID().toString, sequencer1.namespace), + PositiveInt.one, + Seq( + HostingParticipant(participant1.id, ParticipantPermission.Confirmation), + HostingParticipant(participant2.id, ParticipantPermission.Confirmation), + ), + partySigningKeysWithThreshold = Some( + SigningKeysWithThreshold( + NonEmpty( + Set, + // pick some key as the party's signing key + sequencer1.keys.public + .list( + filterPurpose = Set(Signing), + filterUsage = Set(SigningKeyUsage.Protocol), + ) + .head + .publicKey + .asSigningKey + .value, + ), + PositiveInt.one, + ) + ), + ), + testedProtocolVersion, + ) -> Seq.empty + ), + Seq.empty, + ) + } + "allocate a party with a PartyToKeyMapping" in { implicit env => import env.* val namespaceKey = global_secret.keys.secret diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/manual/BftOrderingBenchmark.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/manual/BftOrderingBenchmark.scala index c58331d5fb..13918eb5a4 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/manual/BftOrderingBenchmark.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/manual/BftOrderingBenchmark.scala @@ -3,6 +3,7 @@ package com.digitalasset.canton.integration.tests.manual +import com.digitalasset.canton.config.ReplicationConfig import com.digitalasset.canton.config.RequireTypes.{ NonNegativeInt, Port, @@ -66,6 +67,7 @@ import scala.concurrent.duration.{Duration, DurationInt, FiniteDuration} * -Dscala.concurrent.context.numThreads=30 \ * -Dbft-ordering-benchmark.num-db-connections-per-node=5 \ * -Dbft-ordering-benchmark.transaction-sizes-and-weights={payloads=[{size-bytes=2000,weight=1}]} \ + * -Dbft-ordering-benchmark.test-catchup={nodes-to-stop=[2],duration-nodes-are-down=1minutes,duration-node-need-to-startup=10seconds}\ * -Dbft-ordering-benchmark.benchmark-duration=1minute" * * export CI=1 # When this defined, it ensures no dockerized Postgres is being used @@ -158,7 +160,7 @@ class BftOrderingBenchmark PositiveInt.tryCreate( Option(System.getProperty(s"$BFTOrderingBenchmarkPrefix.num-db-connections-per-node")) .map(_.toInt) - .getOrElse(5) + .getOrElse(12) ) /** Tracing options. Disabled by default. */ @@ -228,6 +230,27 @@ class BftOrderingBenchmark Option(System.getProperty(s"$BFTOrderingBenchmarkPrefix.sequencer-db-latency-millis")) .map(_.toLong) + /** Whether DB replication (`DbMultiStorage`) is enabled. Default is [[Some(true)]]. To disable, + * set to [[Some(false)]]. Note that `DbMultiStorage` uses a separate connection pool, reserving + * roughly half of the total DB connections available in the [[num-db-connections-per-node]]. + */ + private val dbReplicationEnabled: Option[Boolean] = + Option(System.getProperty(s"$BFTOrderingBenchmarkPrefix.db-replication-enabled")) + .map(_.toBoolean) + .orElse(Some(true)) + + private val testCatchupConfig: BftBenchmarkConfig.TestCatchup = + Option(System.getProperty(s"$BFTOrderingBenchmarkPrefix.test-catchup")) + .map { s => + val result = + ConfigSource + .string(s) + .load[BftBenchmarkConfig.TestCatchup] + result.left.foreach(errors => logger.error(s"Failed to parse testCatchup config: $errors")) + result.getOrElse(throw new RuntimeException("Invalid test catchup configuration")) + } + .getOrElse(BftBenchmarkConfig.TestCatchup.NoTestCatchup) + registerPlugin( new UsePostgres( loggerFactory, @@ -279,6 +302,15 @@ class BftOrderingBenchmark ) } }) + .addConfigTransforms( + ConfigTransforms.updateAllSequencerConfigs { case (_, config) => + dbReplicationEnabled.fold(config) { replicationEnabled => + config + .focus(_.replication) + .replace(Some(ReplicationConfig(enabled = Some(replicationEnabled)))) + } + } + ) .addConfigTransforms( _.focus(_.monitoring.tracing.tracer).replace( TracingConfig.Tracer( @@ -377,36 +409,49 @@ class BftOrderingBenchmark waitUntilAllBftSequencersAuthenticateDisseminationQuorum(5.minutes) + val nodesToStop = env.sequencers.local.zipWithIndex + .filter(x => testCatchupConfig.nodesToStop.contains(x._2)) + .map(_._1) + + if (nodesToStop.nonEmpty) { + + nodesToStop.foreach(_.stop()) + + env.actorSystem.scheduler.scheduleOnce(testCatchupConfig.durationNodesAreDown) { + nodesToStop.foreach(_.start()) + } + } + val benchmarkTool = new BftBenchmarkTool(new DaBftBindingFactory(loggerFactory), loggerFactory) + val p2pEndpoints = bftSequencerPlugin.p2pEndpoints.getOrElse(fail("No P2P endpoints found")) val benchmarkToolConfig = BftBenchmarkConfig( transactionSizesAndWeights = transactionSizesAndWeights.payloads, + testCatchup = testCatchupConfig, runDuration = runDuration, perNodeWritePeriod = perNodeWritePeriod, reportingInterval = reportingIntervalOpt, - nodes = bftSequencerPlugin.p2pEndpoints - .getOrElse(fail("No P2P endpoints found")) - .values - .zipWithIndex - .map { case (p2pConfig, idx) => - val host = p2pConfig.address - val port = p2pConfig.port.unwrap - val node: BftBenchmarkConfig.Node = - if (idx == 0) { - BftBenchmarkConfig.NetworkedReadWriteNode( - host = host, - writePort = port, - readPort = port, - ) - } else { - BftBenchmarkConfig.NetworkedWriteOnlyNode( - host = host, - writePort = port, - ) - } - node - } - .toSeq, + nodes = env.sequencers.local.zipWithIndex.map { case (sequencer, idx) => + val name = sequencer.name + val p2pConfig = p2pEndpoints(name) + + val host = p2pConfig.address + val port = p2pConfig.port.unwrap + val node: BftBenchmarkConfig.Node = + if (idx == 0) { + BftBenchmarkConfig.NetworkedReadWriteNode( + host = host, + writePort = port, + readPort = port, + ) + } else { + BftBenchmarkConfig.NetworkedWriteOnlyNode( + host = host, + writePort = port, + ) + } + node + }, ) benchmarkTool.run(benchmarkToolConfig).discard } diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/manual/BftSingleNodePerformanceTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/manual/BftSingleNodePerformanceTest.scala index d20bb417e9..d27c6a1d96 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/manual/BftSingleNodePerformanceTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/manual/BftSingleNodePerformanceTest.scala @@ -15,6 +15,7 @@ import com.digitalasset.canton.console.{ LocalSequencerReference, } import com.digitalasset.canton.discard.Implicits.DiscardOps +import com.digitalasset.canton.integration.bootstrap.NetworkTopologyDescription.MediatorSequencersConfiguration import com.digitalasset.canton.integration.bootstrap.{ NetworkBootstrapper, NetworkTopologyDescription, @@ -128,7 +129,11 @@ class BftSingleNodePerformanceTest mediators = allMediators_, overrideMediatorToSequencers = Some( allMediators_.map { mediator => - mediator -> (Seq(sequencer1), PositiveInt.one, NonNegativeInt.zero) + mediator -> MediatorSequencersConfiguration( + Seq(sequencer1), + trustThreshold = PositiveInt.one, + livenessMargin = NonNegativeInt.zero, + ) }.toMap ), mediatorThreshold = PositiveInt.one, diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/manual/DataContinuityTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/manual/DataContinuityTest.scala index 758e54fab8..1bfe5463dd 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/manual/DataContinuityTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/manual/DataContinuityTest.scala @@ -39,9 +39,7 @@ import com.digitalasset.canton.integration.{ SharedEnvironment, TestConsoleEnvironment, } -import com.digitalasset.canton.logging.SuppressingLogger.LogEntryOptionality import com.digitalasset.canton.logging.{LogEntry, TracedLogger} -import com.digitalasset.canton.resource.DatabaseStorageError import com.digitalasset.canton.synchronizer.sequencer.ProgrammableSequencer import com.digitalasset.canton.topology.PartyId import com.digitalasset.canton.tracing.TraceContext @@ -103,34 +101,6 @@ trait DataContinuityTest // Set to true if you want to persist the dumps locally even if a test container is found lazy val forceLocalDumps = false - // Suppress flaky DB task rejection warnings during outer environment transitions and final teardown. - // When nodes are stopped, Slick connection pools terminate immediately ("Terminated"). If background - // processes (like the ACS Commitment Processor running heavy SQL queries) are still executing, - // subsequent pool submissions trigger benign DB_STORAGE_DEGRADATION warnings caused by underlying - // RejectedExecutionException failures. - // - // TODO(#16601): This is a workaround for an uncoordinated shutdown sequence - // A coordinated graceful shutdown that actively cancels ongoing queries would make this suppression obsolete. - protected def assertBenignDbStorageDegradationShutdown(e: LogEntry): Assertion = - e.shouldBeCantonError( - DatabaseStorageError.DatabaseStorageDegradation, - messageAssertion = msg => { - // Check the top-level message text - msg should include("A database task was rejected from the database task queue") - }, - contextAssertion = mdc => { - // Inspect the raw error context dumped by Slick into the MDC map - val slickMsg = mdc.getOrElse("messageFromSlick", "") - slickMsg should include("RejectedExecutionException") - // These strings originate from java.util.concurrent.ThreadPoolExecutor#toString(). - // We match only shutdown phases to suppress benign teardown races, while ensuring - // actual load-based queue rejections ("Running") remain visible. - slickMsg should include regex "Shutting down|Terminated" - }, - loggerAssertion = - loggerName => loggerName should startWith("com.digitalasset.canton.resource.DbStorageMulti"), - ) - override val logsToBeHandledAtStartup: Option[Seq[LogEntry] => Assertion] = Some( LogEntry.assertLogSeq( Seq.empty, @@ -143,8 +113,6 @@ trait DataContinuityTest s"Using a session signing key is not possible with protocol version 34." ) ), - // Flake prevention: Suppress trailing DB task rejections during mid-test dump restorations via loadState - assertBenignDbStorageDegradationShutdown, ), ) ) @@ -154,12 +122,7 @@ trait DataContinuityTest protocolVersion: ProtocolVersion, )(f: TestConsoleEnvironment => Unit): Unit = { - // Flake prevention: Catch benign DB rejections when stopping the old environment. - // Wrapped separately to avoid nesting suppression scopes. - loggerFactory.assertLogsUnorderedOptional( - oldEnv.nodes.local.foreach(_.stop()), - LogEntryOptionality.OptionalMany -> assertBenignDbStorageDegradationShutdown, - ) + oldEnv.nodes.local.foreach(_.stop()) val newEnv = manualCreateEnvironment( initialConfig = oldEnv.environment.config, configTransform = config => @@ -173,12 +136,7 @@ trait DataContinuityTest logger.info(s"About to run with protocol version $protocolVersion") f(newEnv) } finally { - // Flake prevention: Catch benign DB rejections during final environment teardown. - // Wrapped separately so mid-test calls to `handleStartupLogs` do not trigger nested suppression errors. - loggerFactory.assertLogsUnorderedOptional( - destroyEnvironment(newEnv), - LogEntryOptionality.OptionalMany -> assertBenignDbStorageDegradationShutdown, - ) + destroyEnvironment(newEnv) } } @@ -727,16 +685,11 @@ trait SynchronizerChangeDataContinuityTest extends SynchronizerChangeDataContinu val unassignedEvent = incompleteUnassignedEvents.loneElement // act on state clue("starting assignment and paint offer acceptance") { - // Flake prevention: Catch trailing DB task rejections if background processors - // flake concurrently with active test execution. - loggerFactory.assertLogsUnorderedOptional( - assignmentAndPaintOfferAcceptance( - Alice.toPartyId(), - Bank.toPartyId(), - Painter.toPartyId(), - unassignedEvent.entry.getUnassignedEvent, - ), - LogEntryOptionality.OptionalMany -> assertBenignDbStorageDegradationShutdown, + assignmentAndPaintOfferAcceptance( + Alice.toPartyId(), + Bank.toPartyId(), + Painter.toPartyId(), + unassignedEvent.entry.getUnassignedEvent, ) } } diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/manual/S3Synchronization.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/manual/S3Synchronization.scala index eba41b394f..6048be328e 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/manual/S3Synchronization.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/manual/S3Synchronization.scala @@ -203,7 +203,7 @@ object S3Synchronization { final case class ContinuityDumpS3Ref(override val path: String) extends ContinuityDumpRef { lazy val localDownloadPath: File = { val syncCommand = - s"aws s3 sync s3://canton-public-releases/data-continuity-dumps/$path ${baseDbDumpPath.path}/$path --no-sign-request" + s"aws s3 sync s3://canton-public-releases/data-continuity-dumps/$path ${baseDbDumpPath.path}/$path --no-sign-request --quiet" val syncResult = runSynchronized(syncCommand) if (syncResult != 0) { diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/manual/topology/TopologyStateVerification.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/manual/topology/TopologyStateVerification.scala index ae183ad196..bf8a0adfe2 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/manual/topology/TopologyStateVerification.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/manual/topology/TopologyStateVerification.scala @@ -281,6 +281,7 @@ class TopologyStateVerification( ReleaseProtocolVersion(BaseTest.testedProtocolVersion), futureSupervisor, clock, + CommonMockMetrics.cryptoMetrics, executionContext, timeouts, BatchingConfig(), diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/metrics/SequencerConnectionPoolMetricsIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/metrics/SequencerConnectionPoolMetricsIntegrationTest.scala new file mode 100644 index 0000000000..3a75ef82bd --- /dev/null +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/metrics/SequencerConnectionPoolMetricsIntegrationTest.scala @@ -0,0 +1,232 @@ +// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.integration.tests.metrics + +import com.daml.metrics.api.MetricQualification +import com.digitalasset.canton.admin.api.client.data.{ + GrpcSequencerConnection, + SubmissionRequestAmplification, +} +import com.digitalasset.canton.config.RequireTypes.{NonNegativeInt, PositiveInt} +import com.digitalasset.canton.console.{ + InstanceReference, + LocalInstanceReference, + LocalMediatorReference, + LocalParticipantReference, +} +import com.digitalasset.canton.integration.bootstrap.NetworkTopologyDescription.MediatorSequencersConfiguration +import com.digitalasset.canton.integration.bootstrap.{ + NetworkBootstrapper, + NetworkTopologyDescription, +} +import com.digitalasset.canton.integration.plugins.{UseBftSequencer, UsePostgres} +import com.digitalasset.canton.integration.{ + CommunityIntegrationTest, + EnvironmentDefinition, + SharedEnvironment, +} +import com.digitalasset.canton.metrics.{MetricsConfig, MetricsReporterConfig} +import com.digitalasset.canton.{SequencerAlias, UniquePortGenerator, config} +import monocle.macros.syntax.lens.* + +import scala.concurrent.duration.* + +/** This test checks that the sequencer connection pool metrics properly obtain a `psid` label in + * their context. + * + * The environment is as follows: + * - 2 participants + * - 4 sequencers + * - 1 mediator + * + * The participants and the mediator connect to all sequencers with a trust threshold of 4. + * + * The test first restarts all the participants because the `psid` is known only starting at the + * second connect. + * + * The test then pings participant2 from participant1, and proceeds to examine all the connection + * pool metrics for all participant and mediator nodes, validating that they have the `psid` label + * where expected. + */ +final class SequencerConnectionPoolMetricsIntegrationTest + extends CommunityIntegrationTest + with SharedEnvironment { + registerPlugin(new UsePostgres(loggerFactory)) + registerPlugin(new UseBftSequencer(loggerFactory)) + + override def environmentDefinition: EnvironmentDefinition = + EnvironmentDefinition.P2S4M1_Config + .addConfigTransform( + _.focus(_.monitoring.metrics) + .replace( + MetricsConfig( + qualifiers = MetricQualification.All, + reporters = Seq( + MetricsReporterConfig.Prometheus( + port = UniquePortGenerator.next + ) + ), + ) + ) + ) + .withNetworkBootstrap { implicit env => + import env.* + + new NetworkBootstrapper( + NetworkTopologyDescription( + daName, + synchronizerOwners = Seq[InstanceReference](sequencer1, mediator1), + synchronizerThreshold = PositiveInt.one, + sequencers = sequencers.local, + mediators = Seq(mediator1), + overrideMediatorToSequencers = Some( + Map( + mediator1 -> MediatorSequencersConfiguration( + sequencers.local, + trustThreshold = PositiveInt.four, + livenessMargin = NonNegativeInt.zero, + ) + ) + ), + ) + ) + } + .withSetup { implicit env => + import env.* + + val amplification = SubmissionRequestAmplification( + factor = 20, + patience = config.NonNegativeFiniteDuration.tryFromDuration(1.seconds), + ) + + Seq(participant1, participant2).foreach( + _.synchronizers.connect_bft( + sequencers.local.map(s => + GrpcSequencerConnection.fromInternal( + s.config.publicApi.clientConfig + .asSequencerConnection(SequencerAlias.tryCreate(s.name), sequencerId = None) + ) + ), + synchronizerAlias = daName, + sequencerTrustThreshold = PositiveInt.four, + sequencerLivenessMargin = NonNegativeInt.zero, + submissionRequestAmplification = amplification, + ) + ) + } + + "SequencerConnectionPoolMetrics" should { + "have the `psid` label after the first connect" in { implicit env => + import env.* + + val nodes = Seq[LocalInstanceReference](participant1, participant2, mediator1) + + // For participants, the `psid` will be populated starting at the second connect + clue("disconnect and reconnect participants") { + participants.all.foreach(_.synchronizers.disconnect_all()) + participants.all.foreach(_.synchronizers.reconnect_all()) + } + + participant1.health.ping(participant2) + + clue("check metrics") { + val psidKeyName = "psid" + val prefix = "daml.sequencer-client.sequencer-connection-pool" + + forAll(nodes) { node => + val metrics = node.metrics.list(prefix) + // Ensure the test fails if we change the prefix, which would result in an empty `metrics` + metrics.size should be >= 8 + + node match { + case _: LocalMediatorReference => + // Mediators have their `psid` always defined, even at their first connection + forAll(metrics) { case (_name, values) => + values.size should be >= 1 + forAll(values)(_.attributes should contain key psidKeyName) + } + + case _: LocalParticipantReference => + val uniqueMetrics = Seq( + "trust-threshold", + "tracked-connections", + "validated-connections", + "subscription-threshold", + "active-subscriptions", + ).map(m => s"$prefix.$m") + + forAll(metrics) { + case (name, values) if uniqueMetrics.contains(name) => + // These metrics don't have separate instances for different sets of labels, so they will all have the `psid` + values.loneElement.attributes should contain key psidKeyName + + case (name, values) + if name == s"$prefix.connection-health" || name == s"$prefix.subscription-health" => + // The metrics on the first connect will be without `psid`, but they are closed when disconnecting. + // Metrics for the second connect will have the `psid`. + // With 4 sequencers and trust threshold = 4, we will have 4 connections and 4 subscriptions. + values should have size 4 + forAll(values)(_.attributes should contain key psidKeyName) + + case (name, values) if name == s"$prefix.grpc-requests" => + // These metrics are counters and are not closed when the pool closes, so the metrics without `psid` will be around + + val metricsPerEndPoint = values.groupBy(_.attributes("endpoint")) + val endpointsWithPsidOnAllConnections = Seq( + "GetApiInfo", + "GetSynchronizerId", + "GetSynchronizerParameters", + "Handshake", + "Authenticate", + "Challenge", + "Subscribe", + ) + val endpointsWithPsidOnSomeConnections = Seq( + "SendAsync", + "AcknowledgeSigned", + ) + + val endpointsWithoutPsid = Seq( + "DownloadTopologyStateForInit", + "DownloadTopologyStateForInitHash", + ) + + val endpointsNotCalled = Seq( + "Logout", + "GetTime", + "GetTrafficStateForMember", + ) + + forAll(metricsPerEndPoint) { + case (endpoint, metrics) + if endpointsWithPsidOnAllConnections.contains(endpoint) => + // All the connections will have a `psid` because all the connections use these endpoints + forExactly(4, metrics)(_.attributes should contain key psidKeyName) + + case (endpoint, metrics) + if endpointsWithPsidOnSomeConnections.contains(endpoint) => + // Not all connections will necessarily have a `psid` because not all connections may use these endpoints + forAtLeast(1, metrics)(_.attributes should contain key psidKeyName) + + case (endpoint, metrics) if endpointsWithoutPsid.contains(endpoint) => + // There are no metric with `psid` because these endpoints are only used during the first connection + forAll(metrics)(_.attributes should not(contain key psidKeyName)) + + case (endpoint, _) if endpointsNotCalled.contains(endpoint) => + // These endpoints should not be called during this test + fail(s"endpoint should not have been called: $endpoint") + + case (endpoint, _) => fail(s"unknown endpoint: $endpoint") + } + + case (other, _) => fail(s"unexpected metric: $other") + } + + case _ => fail(s"unexpected node type: $node") + } + } + } + } + } +} diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multihostedparties/AutomaticReassignmentDecentralizedPartyIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multihostedparties/AutomaticReassignmentDecentralizedPartyIntegrationTest.scala index ae61cb77ed..5e67e4d947 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multihostedparties/AutomaticReassignmentDecentralizedPartyIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multihostedparties/AutomaticReassignmentDecentralizedPartyIntegrationTest.scala @@ -48,7 +48,7 @@ class AutomaticReassignmentDecentralizedPartyIntegrationTest override lazy val environmentDefinition: EnvironmentDefinition = EnvironmentDefinition.P2_S1M1_S1M1 .addConfigTransforms( - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multihostedparties/DivulgenceIntegrationTestHelpers.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multihostedparties/DivulgenceIntegrationTestHelpers.scala index acc499ffb3..ae46ba543f 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multihostedparties/DivulgenceIntegrationTestHelpers.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multihostedparties/DivulgenceIntegrationTestHelpers.scala @@ -27,7 +27,7 @@ object DivulgenceIntegrationTestHelpers { case object NonConsumed extends EventType implicit class ParticipantSimpleStreamHelper(val participant: LocalParticipantReference)(implicit - val alphaMultiSynchronizerSupport: Boolean = false + val enableAllLedgerApiReassignments: Boolean = false ) { def acs(party: Party): Seq[OffsetCid] = @@ -111,7 +111,7 @@ object DivulgenceIntegrationTestHelpers { parties: Seq[PartyId], beginOffsetExclusive: Long, ): Seq[UpdateService.UpdateWrapper] = { - val reassignmentsFilter = if (alphaMultiSynchronizerSupport) { + val reassignmentsFilter = if (enableAllLedgerApiReassignments) { Some( EventFormat( filtersByParty = parties.map(party => party.toLf -> Filters(Nil)).toMap, diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multihostedparties/OnlinePartyReplicationParticipantProtocolTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multihostedparties/OnlinePartyReplicationParticipantProtocolTest.scala index e2b761a7dc..08c1bbf38d 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multihostedparties/OnlinePartyReplicationParticipantProtocolTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multihostedparties/OnlinePartyReplicationParticipantProtocolTest.scala @@ -89,7 +89,7 @@ sealed trait OnlinePartyReplicationParticipantProtocolTest ConfigTransforms .enableAlphaOnlinePartyReplicationSupport(enableUnsafeSequencerChannelSupport = true)* ) - .addConfigTransform(ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag) + .addConfigTransform(ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag) .withSetup { implicit env => import env.* // More frequent ACS commitments by configuring a smaller reconciliation interval. diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multihostedparties/offpr/DivulgenceIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multihostedparties/offpr/DivulgenceIntegrationTest.scala index 56d5a760c4..c40b4795d2 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multihostedparties/offpr/DivulgenceIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multihostedparties/offpr/DivulgenceIntegrationTest.scala @@ -16,10 +16,10 @@ trait DivulgenceIntegrationTest extends OfflinePartyReplicationIntegrationTestBa import com.digitalasset.canton.integration.tests.multihostedparties.DivulgenceIntegrationTestHelpers.* // Whether to use Assign/Unassign (multi-synchronizer) or Create/Archive for the ACS import - def alphaMultiSynchronizerSupport: Boolean + def enableAllLedgerApiReassignments: Boolean // Inject this setting into the implicit scope for the helper class - implicit def alphaSupportImplicit: Boolean = alphaMultiSynchronizerSupport + implicit def alphaSupportImplicit: Boolean = enableAllLedgerApiReassignments // Make sure deduplication duration does not block pruning private val maxDedupDuration = java.time.Duration.ofSeconds(2) @@ -29,7 +29,8 @@ trait DivulgenceIntegrationTest extends OfflinePartyReplicationIntegrationTestBa super.environmentDefinition .addConfigTransforms( ConfigTransforms.updateAllParticipantConfigs_( - _.focus(_.parameters.alphaMultiSynchronizerSupport).replace(alphaMultiSynchronizerSupport) + _.focus(_.parameters.enableAllLedgerApiReassignments) + .replace(enableAllLedgerApiReassignments) ), ConfigTransforms.updateMaxDeduplicationDurations(maxDedupDuration), ) @@ -100,7 +101,7 @@ trait DivulgenceIntegrationTest extends OfflinePartyReplicationIntegrationTestBa participant: LocalParticipantReference, contractId: String, party: Party, - ) = if (alphaMultiSynchronizerSupport) { + ) = if (enableAllLedgerApiReassignments) { assertEventNotFound(participant, contractId, party) } else { checkCreatedEventFor(participant, contractId, party) @@ -525,13 +526,13 @@ trait DivulgenceIntegrationTest extends OfflinePartyReplicationIntegrationTestBa immediateDivulged2P2Import, ) // event query - if (alphaMultiSynchronizerSupport) + if (enableAllLedgerApiReassignments) assertEventNotFound(participant2, aliceStakeholderCreatedP1.contractId, alice) else checkCreatedEventFor(participant2, aliceStakeholderCreatedP1.contractId, alice) checkCreatedEventFor(participant2, aliceBobStakeholderCreatedP1.contractId, alice) checkCreatedEventFor(participant2, divulgeIouByExerciseP1.contractId, alice) assertEventNotFound(participant2, immediateDivulged1P1.contractId, alice) - if (alphaMultiSynchronizerSupport) + if (enableAllLedgerApiReassignments) assertEventNotFound(participant2, immediateDivulged2P1.contractId, alice) else checkCreatedEventFor(participant2, immediateDivulged2P1.contractId, alice) assertEventNotFound(participant2, immediateDivulged1ArchiveP1.contractId, alice) @@ -596,18 +597,18 @@ trait DivulgenceIntegrationTestWithoutCache extends DivulgenceIntegrationTest { } class DivulgenceIntegrationTestReassignmentWithCache extends DivulgenceIntegrationTest { - override def alphaMultiSynchronizerSupport: Boolean = true + override def enableAllLedgerApiReassignments: Boolean = true } class DivulgenceIntegrationTestLegacyWithCache extends DivulgenceIntegrationTest { - override def alphaMultiSynchronizerSupport: Boolean = false + override def enableAllLedgerApiReassignments: Boolean = false } class DivulgenceIntegrationTestReassignmentWithoutCache extends DivulgenceIntegrationTestWithoutCache { - override def alphaMultiSynchronizerSupport: Boolean = true + override def enableAllLedgerApiReassignments: Boolean = true } class DivulgenceIntegrationTestLegacyWithoutCache extends DivulgenceIntegrationTestWithoutCache { - override def alphaMultiSynchronizerSupport: Boolean = false + override def enableAllLedgerApiReassignments: Boolean = false } diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multihostedparties/offpr/WorkflowIdsIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multihostedparties/offpr/WorkflowIdsIntegrationTest.scala index b5a16bff8e..9b35bfdc89 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multihostedparties/offpr/WorkflowIdsIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multihostedparties/offpr/WorkflowIdsIntegrationTest.scala @@ -24,15 +24,15 @@ import java.util.Collections * custom-configured. */ abstract class WorkflowIdsIntegrationTestBase( - alphaMultiSynchronizerSupport: Boolean = false + enableAllLedgerApiReassignments: Boolean = false ) extends OfflinePartyReplicationIntegrationTestBase { override lazy val environmentDefinition: EnvironmentDefinition = super.environmentDefinition .addConfigTransforms( ConfigTransforms.updateAllParticipantConfigs_( - _.focus(_.parameters.alphaMultiSynchronizerSupport) - .replace(alphaMultiSynchronizerSupport) + _.focus(_.parameters.enableAllLedgerApiReassignments) + .replace(enableAllLedgerApiReassignments) ) ) @@ -113,7 +113,7 @@ abstract class WorkflowIdsIntegrationTestBase( party: Party, expectedCount: Int, ): Seq[NormalizedEvent] = - if (alphaMultiSynchronizerSupport) { + if (enableAllLedgerApiReassignments) { val reassignments = target.ledger_api.updates .reassignments(Set(party), completeAfter = PositiveInt.tryCreate(expectedCount)) reassignments.map { r => @@ -158,4 +158,4 @@ abstract class WorkflowIdsIntegrationTestBase( final class WorkflowIdsIntegrationTest extends WorkflowIdsIntegrationTestBase final class WorkflowIdsReassignmentIntegrationTest - extends WorkflowIdsIntegrationTestBase(alphaMultiSynchronizerSupport = true) + extends WorkflowIdsIntegrationTestBase(enableAllLedgerApiReassignments = true) diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/AssignmentBeforeUnassignmentIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/AssignmentBeforeUnassignmentIntegrationTest.scala index 4c5c10b24f..472cba7f35 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/AssignmentBeforeUnassignmentIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/AssignmentBeforeUnassignmentIntegrationTest.scala @@ -47,7 +47,7 @@ sealed trait AssignmentBeforeUnassignmentIntegrationTest override def environmentDefinition: EnvironmentDefinition = EnvironmentDefinition.P2_S1M1_S1M1 .addConfigTransforms( - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/AsynchronousReassignmentProtocolIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/AsynchronousReassignmentProtocolIntegrationTest.scala index e9947701a9..ae20129a5b 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/AsynchronousReassignmentProtocolIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/AsynchronousReassignmentProtocolIntegrationTest.scala @@ -70,7 +70,7 @@ final class AsynchronousReassignmentProtocolIntegrationTest override lazy val environmentDefinition: EnvironmentDefinition = EnvironmentDefinition.P1_S1M1_S1M1 .addConfigTransforms( - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/AutomaticReassignmentBatchingIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/AutomaticReassignmentBatchingIntegrationTest.scala index e547fa6096..15ca2767f9 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/AutomaticReassignmentBatchingIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/AutomaticReassignmentBatchingIntegrationTest.scala @@ -36,7 +36,7 @@ class AutomaticReassignmentBatchingIntegrationTest override def environmentDefinition: EnvironmentDefinition = EnvironmentDefinition.P1_S1M1_S1M1 .addConfigTransforms( ProgrammableSequencer.configOverride(this.getClass.toString, loggerFactory), - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentConfirmationAdminPartyIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentConfirmationAdminPartyIntegrationTest.scala index df6de065ee..5c2f4cc189 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentConfirmationAdminPartyIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentConfirmationAdminPartyIntegrationTest.scala @@ -69,7 +69,7 @@ sealed trait ReassignmentConfirmationAdminPartyIntegrationTest // Because we play with the simClock, ensure we have enough forward tolerance // on the target timestamp to not impact up unassigments. ConfigTransforms.updateTargetTimestampForwardTolerance(1.hours), - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentConfirmationPoliciesIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentConfirmationPoliciesIntegrationTest.scala index 20ac9e3605..dee97c89a8 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentConfirmationPoliciesIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentConfirmationPoliciesIntegrationTest.scala @@ -44,7 +44,7 @@ sealed trait ReassignmentConfirmationPoliciesPartyIntegrationTest override def environmentDefinition: EnvironmentDefinition = EnvironmentDefinition.P3_S1M1_S1M1 .addConfigTransforms( - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentNoReassignmentDataIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentNoReassignmentDataIntegrationTest.scala index 22bc701f7c..bbf047740f 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentNoReassignmentDataIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentNoReassignmentDataIntegrationTest.scala @@ -63,7 +63,7 @@ sealed trait ReassignmentNoReassignmentDataIntegrationTest EnvironmentDefinition.P3_S1M1_S1M1 .addConfigTransforms( ConfigTransforms.useStaticTime, - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentServiceConcurrentReassignmentsIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentServiceConcurrentReassignmentsIntegrationTest.scala index 29ef10f11b..eb98e0ec9d 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentServiceConcurrentReassignmentsIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentServiceConcurrentReassignmentsIntegrationTest.scala @@ -56,7 +56,7 @@ trait ReassignmentServiceConcurrentReassignmentsIntegrationTest override def environmentDefinition: EnvironmentDefinition = EnvironmentDefinition.P3_S1M1_S1M1 .addConfigTransforms( - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentServiceIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentServiceIntegrationTest.scala index e1bc0b2f47..d0e8f1ad50 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentServiceIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentServiceIntegrationTest.scala @@ -92,7 +92,7 @@ abstract class ReassignmentServiceIntegrationTest .addConfigTransforms( // Ensure reassignments are not tripped up by some participants being a little behind. ConfigTransforms.updateTargetTimestampForwardTolerance(30.seconds), - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentServiceTimeoutCommandRejectedIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentServiceTimeoutCommandRejectedIntegrationTest.scala index 22880f7a07..5b0a766f16 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentServiceTimeoutCommandRejectedIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentServiceTimeoutCommandRejectedIntegrationTest.scala @@ -77,7 +77,7 @@ sealed trait ReassignmentServiceTimeoutCommandRejectedIntegrationTest .addConfigTransforms( ConfigTransforms.useStaticTime, ConfigTransforms.updateTargetTimestampForwardTolerance(60.seconds), - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentSubmissionIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentSubmissionIntegrationTest.scala index 1743a5e421..c408e6b71d 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentSubmissionIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentSubmissionIntegrationTest.scala @@ -52,7 +52,7 @@ sealed trait ReassignmentSubmissionIntegrationTest // We want to trigger time out .addConfigTransforms( ConfigTransforms.useStaticTime, - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentTargetTimestampIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentTargetTimestampIntegrationTest.scala index f9b74820ca..2086552209 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentTargetTimestampIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentTargetTimestampIntegrationTest.scala @@ -45,7 +45,7 @@ class ReassignmentTargetTimestampIntegrationTest override def environmentDefinition: EnvironmentDefinition = EnvironmentDefinition.P2_S1M1_S1M1 .addConfigTransforms( - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentsConfirmationObserversIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentsConfirmationObserversIntegrationTest.scala index e0de9be45f..f48da31091 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentsConfirmationObserversIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentsConfirmationObserversIntegrationTest.scala @@ -85,7 +85,7 @@ sealed trait ReassignmentsConfirmationObserversIntegrationTest // We want to trigger time out .addConfigTransforms( ConfigTransforms.useStaticTime, - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentsConfirmationThresholdIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentsConfirmationThresholdIntegrationTest.scala index ab4d6ab16b..f56a3509ac 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentsConfirmationThresholdIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/ReassignmentsConfirmationThresholdIntegrationTest.scala @@ -91,7 +91,7 @@ sealed trait ReassignmentsConfirmationThresholdIntegrationTest .addConfigTransforms( ConfigTransforms.useStaticTime, ConfigTransforms.updateTargetTimestampForwardTolerance(10.minutes), - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/RepairServiceIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/RepairServiceIntegrationTest.scala index 42b589d7fb..4960f5679d 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/RepairServiceIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/RepairServiceIntegrationTest.scala @@ -50,7 +50,7 @@ abstract class RepairServiceIntegrationTest override lazy val environmentDefinition: EnvironmentDefinition = EnvironmentDefinition.P1_S2M1_S2M1 .addConfigTransforms( - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/UpdateServiceIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/UpdateServiceIntegrationTest.scala index 7a68b905c3..d6c5f53838 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/UpdateServiceIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/multisynchronizer/UpdateServiceIntegrationTest.scala @@ -47,7 +47,7 @@ abstract class UpdateServiceIntegrationTest override def environmentDefinition: EnvironmentDefinition = EnvironmentDefinition.P1_S2M1_S2M1 .addConfigTransforms( - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/nightly/kms/KmsCryptoNoPreDefinedKeysIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/nightly/kms/KmsCryptoNoPreDefinedKeysIntegrationTest.scala index 84742c2630..3995e2ac87 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/nightly/kms/KmsCryptoNoPreDefinedKeysIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/nightly/kms/KmsCryptoNoPreDefinedKeysIntegrationTest.scala @@ -15,8 +15,8 @@ import com.digitalasset.canton.crypto.store.{CryptoPrivateStore, KmsCryptoPrivat import com.digitalasset.canton.integration.plugins.{UseBftSequencer, UseKms} import com.digitalasset.canton.integration.tests.security.kms.KmsCryptoIntegrationTestBase import com.digitalasset.canton.integration.{ + CantonEnvironmentSetup, CommunityIntegrationTest, - EnvironmentSetup, EnvironmentSetupPlugin, } import com.digitalasset.canton.lifecycle.FutureUnlessShutdown @@ -28,7 +28,7 @@ import scala.concurrent.Future * keys (i.e. keys are generated on-the-fly using a KMS and nodes are automatically initialized). */ trait KmsCryptoNoPreDefinedKeysIntegrationTest extends KmsCryptoIntegrationTestBase { - self: CommunityIntegrationTest with EnvironmentSetup => + self: CommunityIntegrationTest with CantonEnvironmentSetup => protected def kmsConfig: KmsConfig diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/nightly/sequencer/SequencerCatchUpPerformanceIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/nightly/sequencer/SequencerCatchUpPerformanceIntegrationTest.scala index 22b926d6ce..e0aaaebce8 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/nightly/sequencer/SequencerCatchUpPerformanceIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/nightly/sequencer/SequencerCatchUpPerformanceIntegrationTest.scala @@ -24,6 +24,7 @@ import com.digitalasset.canton.console.{ LocalSequencerReference, } import com.digitalasset.canton.discard.Implicits.DiscardOps +import com.digitalasset.canton.integration.bootstrap.NetworkTopologyDescription.MediatorSequencersConfiguration import com.digitalasset.canton.integration.bootstrap.{ NetworkBootstrapper, NetworkTopologyDescription, @@ -72,7 +73,7 @@ import scala.concurrent.duration.* * Check the events/s in the `log` folder after the run: * * {{{ - * cat canton_test.log | grep 'sequencer2 events/s' + * cat canton_test.log | grep 'sequencer4 events/s' * }}} * * More options: @@ -137,7 +138,7 @@ class SequencerCatchUpPerformanceIntegrationTest // Enable to see metrics in Prometheus or Grafana. For some reason, doesn't work with the 'countBlockEvents' flag private val exposeHttpMetrics = false - // Enable to automatically count events/s, grep 'sequencer2 events/s' after the test run + // Enable to automatically count events/s, grep 'sequencer4 events/s' after the test run // Set to true by default, so we can print the sequencer speed on CircleCI private val countBlockEvents = true @@ -150,13 +151,13 @@ class SequencerCatchUpPerformanceIntegrationTest // The duration of load producing before the restart. The bigger the number, the more events will accumulate to catch up with private val beforeRestartDurationMillis = 5 * 60000L - // The max time for the sequencer2 to catch up + // The max time for the sequencer4 to catch up private val afterRestartDurationMillis = beforeRestartDurationMillis / 2 - // Useful to isolate the sequencer2 performance + // Useful to isolate the sequencer4 performance private val stopOtherNodesDuringCatchUp = true - // Keep producing load after catch-up. May result in sequencer2 never catching up, depending on the other settings. + // Keep producing load after catch-up. May result in sequencer4 never catching up, depending on the other settings. private val produceLoadAfterRestart = false private val enableTrafficManagement = true @@ -215,7 +216,7 @@ class SequencerCatchUpPerformanceIntegrationTest Option.when(useExternalSequencerProcess)( new UseExternalProcess( loggerFactory, - externalSequencers = Set("sequencer2"), + externalSequencers = Set("sequencer4"), fileNameHint = this.getClass.getSimpleName, configTransforms = if (exposeHttpMetrics) Seq(metricsConfigTransform, streamInstrumentationConfigTransform) @@ -223,11 +224,11 @@ class SequencerCatchUpPerformanceIntegrationTest ) ) - private val sequencer2Proxy = "sequencer2-to-postgres" + private val sequencer4Proxy = "sequencer4-to-postgres" private val toxiproxyPluginOpt: Option[UseToxiproxy] = Option.when(useToxiProxy)( new UseToxiproxy( - ToxiproxyConfig(proxies = Seq(SequencerToPostgres(sequencer2Proxy, "sequencer2"))) + ToxiproxyConfig(proxies = Seq(SequencerToPostgres(sequencer4Proxy, "sequencer4"))) ) ) @@ -237,7 +238,7 @@ class SequencerCatchUpPerformanceIntegrationTest new UsePostgres( loggerFactory, customMaxConnectionsByNode = Some { - case "sequencer2" => PositiveInt.tryCreate(10).some + case "sequencer4" => PositiveInt.tryCreate(10).some case _ => PositiveInt.tryCreate(5).some }, ) @@ -256,7 +257,7 @@ class SequencerCatchUpPerformanceIntegrationTest EnvironmentDefinition .buildBaseEnvironmentDefinition( numParticipants = 2, - numSequencers = 2, + numSequencers = 4, numMediators = 5, ) .withManualStart @@ -273,6 +274,8 @@ class SequencerCatchUpPerformanceIntegrationTest allMediators().foreach(_.start()) sequencer1.start() + sequencer2.start() + sequencer3.start() startSequencer() participant1.start() @@ -285,27 +288,33 @@ class SequencerCatchUpPerformanceIntegrationTest ) sequencer1.health.wait_for_ready_for_initialization() + sequencer2.health.wait_for_ready_for_initialization() + sequencer3.health.wait_for_ready_for_initialization() - val seq2 = if (useExternalSequencerProcess) remoteSequencer2 else sequencer2 - seq2.health.wait_for_ready_for_initialization() + val seq4 = if (useExternalSequencerProcess) remoteSequencer4 else sequencer4 + seq4.health.wait_for_ready_for_initialization() } .withNetworkBootstrap { implicit env => import env.* - val seq2 = if (useExternalSequencerProcess) remoteSequencer2 else sequencer2 + val seq4 = if (useExternalSequencerProcess) remoteSequencer4 else sequencer4 val allMediators_ = allMediators() new NetworkBootstrapper( NetworkTopologyDescription( daName, - synchronizerOwners = Seq(sequencer1, seq2), + synchronizerOwners = Seq(sequencer1, sequencer2, sequencer3, seq4), synchronizerThreshold = PositiveInt.one, - sequencers = Seq(sequencer1, seq2), + sequencers = Seq(sequencer1, sequencer2, sequencer3, seq4), mediators = allMediators_, overrideMediatorToSequencers = Some( allMediators_.map { mediator => // Make sure both mediators are connected the first sequencer to avoid the SEQUENCER_SUBSCRIPTION_LOST warning // And flaky results (load produced super slowly from time to time) - mediator -> (Seq(sequencer1), PositiveInt.one, NonNegativeInt.zero) + mediator -> MediatorSequencersConfiguration( + Seq(sequencer1), + trustThreshold = PositiveInt.one, + livenessMargin = NonNegativeInt.zero, + ) }.toMap ), mediatorThreshold = allMediators_.length - 1, @@ -413,7 +422,7 @@ class SequencerCatchUpPerformanceIntegrationTest participant2.health.ping(participant1.id) participant1.health.ping(participant2.id) - testLogger.info("sequencer2 STOPPING") + testLogger.info("sequencer4 STOPPING") stopSequencer() runners.foreach(env.environment.addUserCloseable(_)) @@ -435,11 +444,11 @@ class SequencerCatchUpPerformanceIntegrationTest loggerFactory.assertLoggedWarningsAndErrorsSeq( { - testLogger.info("sequencer2 RESTARTING") + testLogger.info("sequencer4 RESTARTING") // enable db toxiproxy toxiproxyPluginOpt.foreach { toxiproxyPlugin => - val proxy = toxiproxyPlugin.runningToxiproxy.getProxy(sequencer2Proxy) + val proxy = toxiproxyPlugin.runningToxiproxy.getProxy(sequencer4Proxy) val client = proxy .valueOrFail("must be here") .underlying @@ -452,7 +461,7 @@ class SequencerCatchUpPerformanceIntegrationTest val before = Instant.now() val seq2EventCountBeforeOpt: Option[Long] = - if (exposeHttpMetrics || !countBlockEvents) None else Some(blockEventCount(sequencer2)) + if (exposeHttpMetrics || !countBlockEvents) None else Some(blockEventCount(sequencer4)) def instantToSeconds(instant: Instant): Double = instant.getEpochSecond.toDouble + (instant.getNano.toDouble / 1000_000_000) @@ -464,26 +473,26 @@ class SequencerCatchUpPerformanceIntegrationTest maxPollInterval = 50 milliseconds, ) { val seq1EventCount = blockEventCount(sequencer1) - val seq2EventCount = blockEventCount(sequencer2) + val seq4EventCount = blockEventCount(sequencer4) testLogger.info( - s"Checking if sequencer2 has caught up. sequencer1 events: $seq1EventCount, sequencer2 events: $seq2EventCount" + s"Checking if sequencer4 has caught up. sequencer1 events: $seq1EventCount, sequencer4 events: $seq4EventCount" ) // seq2 has caught up if it has seen all events. // it may see more in case of shutdown during async processing - val seq2CaughtUp = seq2EventCount >= seq1EventCount + val seq2CaughtUp = seq4EventCount >= seq1EventCount assert(seq2CaughtUp) if (seq2CaughtUp) { val now = Instant.now() - val processedEventCount = seq2EventCount - seq2EventCountBefore + val processedEventCount = seq4EventCount - seq2EventCountBefore val eventsPerSec = processedEventCount.toDouble / (instantToSeconds(now) - instantToSeconds(before)) val logMessage = - f"sequencer2 events/s: $eventsPerSec%.2f, processed events: $processedEventCount" + f"sequencer4 events/s: $eventsPerSec%.2f, processed events: $processedEventCount" testLogger.info(logMessage) // Ugly solution to print the sequencer speed on CircleCI @@ -494,7 +503,7 @@ class SequencerCatchUpPerformanceIntegrationTest case None => Threading.sleep(afterRestartDurationMillis) } - testLogger.info("sequencer2 COMPLETED") + testLogger.info("sequencer4 COMPLETED") if (turnOffDebugLogging) NodeLoggingUtil.setLevel(level = "DEBUG") }, @@ -533,11 +542,11 @@ class SequencerCatchUpPerformanceIntegrationTest externalProcessOpt match { case Some(externalProcess) => - externalProcess.start("sequencer2") - assert(externalProcess.isRunning("sequencer2")) + externalProcess.start("sequencer4") + assert(externalProcess.isRunning("sequencer4")) case None => - sequencer2.start() - assert(sequencer2.is_running) + sequencer4.start() + assert(sequencer4.is_running) } } @@ -546,11 +555,11 @@ class SequencerCatchUpPerformanceIntegrationTest externalProcessOpt match { case Some(externalProcess) => - externalProcess.kill("sequencer2") - assert(!externalProcess.isRunning("sequencer2")) + externalProcess.kill("sequencer4") + assert(!externalProcess.isRunning("sequencer4")) case None => - sequencer2.stop() - assert(!sequencer2.is_running) + sequencer4.stop() + assert(!sequencer4.is_running) } } } diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/operations/ViewConsistencyTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/operations/ViewConsistencyTest.scala index 0cd3009160..5e6109e379 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/operations/ViewConsistencyTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/operations/ViewConsistencyTest.scala @@ -71,7 +71,9 @@ sealed trait ViewConsistencyTest -- no views for flyway or blocks tables table_name not in ('flyway_schema_history', 'blocks') and -- DEV version adds a column, but it doesn't have any significance, so let's ignore it - not (table_name = 'common_node_id' and column_name = 'test_column') + not (table_name = 'common_node_id' and column_name = 'test_column') and + -- exclude partition tables + table_name !~ '_p[0-9]+$$' group by table_name, column_name having count(column_name) != 2""".as[(String, String, String)], "select", diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/pkgdars/PackageUsableMixin.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/pkgdars/PackageUsableMixin.scala index 49e73cd7f1..b60bb33482 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/pkgdars/PackageUsableMixin.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/pkgdars/PackageUsableMixin.scala @@ -7,14 +7,14 @@ import com.daml.ledger.javaapi.data.Command import com.digitalasset.canton.config import com.digitalasset.canton.console.ParticipantReference import com.digitalasset.canton.damltests.java.conflicttest.Many -import com.digitalasset.canton.integration.BaseIntegrationTest +import com.digitalasset.canton.integration.CantonBaseIntegrationTest import com.digitalasset.canton.topology.{PartyId, SynchronizerId} import org.scalatest.Assertion import scala.jdk.CollectionConverters.* trait PackageUsableMixin { - this: BaseIntegrationTest => + this: CantonBaseIntegrationTest => protected def submitCommand( submittingParticipant: ParticipantReference, diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/pruning/LedgerPruningIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/pruning/LedgerPruningIntegrationTest.scala index 09fc47aa71..8fd8c69b87 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/pruning/LedgerPruningIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/pruning/LedgerPruningIntegrationTest.scala @@ -3,9 +3,12 @@ package com.digitalasset.canton.integration.tests.pruning +import anorm.SqlParser.int +import anorm.SqlStringInterpolation import com.daml.ledger.api.v2.event_query_service.GetEventsByContractIdResponse import com.daml.ledger.api.v2.transaction.Transaction import com.daml.ledger.api.v2.transaction_filter.TransactionShape.TRANSACTION_SHAPE_LEDGER_EFFECTS +import com.daml.metrics.DatabaseMetrics import com.digitalasset.canton.BigDecimalImplicits.* import com.digitalasset.canton.config.DbConfig import com.digitalasset.canton.config.RequireTypes.PositiveInt @@ -22,6 +25,7 @@ import com.digitalasset.canton.integration.plugins.{ import com.digitalasset.canton.integration.tests.examples.IouSyntax import com.digitalasset.canton.integration.tests.multihostedparties.DivulgenceIntegrationTestHelpers.ParticipantSimpleStreamHelper import com.digitalasset.canton.ledger.error.groups.RequestValidationErrors.OffsetOutOfRange +import com.digitalasset.canton.logging.LoggingContextWithTrace import com.digitalasset.canton.participant.admin.data.{ ContractImportMode, RepairContract, @@ -776,11 +780,19 @@ abstract class LedgerPruningIntegrationTest contract.contractId } val cid1 = pushContract("0.01").coid - val _ = pushContract("0.02") + val cid2 = pushContract("0.02").coid - // eventually the first should be available in the contract store + // eventually the two contracts should be available in the contract store and in the event store too eventually() { contractFor(participant1, daId, cid1).isDefined shouldBe true + contractFor(participant1, daId, cid2).isDefined shouldBe true + participant1.underlying.value.sync.ledgerApiIndexer.asEval.value.ledgerApiStore.value.ledgerApiDbSupport.dbDispatcher + .executeSql(DatabaseMetrics.ForTesting("getting-all-activations-with-workflow-id"))( + SQL"""select count(*) c + from lapi_events_activate_contract + where workflow_id like 'failedAddContractOperation%'""".as(int("c").single)(_) + )(LoggingContextWithTrace.ForTesting) + .futureValue shouldBe 2 } loggerFactory.assertLoggedWarningsAndErrorsSeq( diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/pruning/ReassignmentPruningIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/pruning/ReassignmentPruningIntegrationTest.scala index 79f73cdd44..5967c97d96 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/pruning/ReassignmentPruningIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/pruning/ReassignmentPruningIntegrationTest.scala @@ -62,7 +62,7 @@ sealed trait ReassignmentPruningIntegrationTest .addConfigTransforms( ConfigTransforms.useStaticTime, ConfigTransforms.updateMaxDeduplicationDurations(maxDedupDuration), - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/repair/AcsImportReassignmentCounterIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/repair/AcsImportReassignmentCounterIntegrationTest.scala index 8af1bde8ca..346d844216 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/repair/AcsImportReassignmentCounterIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/repair/AcsImportReassignmentCounterIntegrationTest.scala @@ -31,10 +31,10 @@ sealed trait AcsImportReassignmentCounterIntegrationTest EnvironmentDefinition.P2_S1M1_S1M1 .addConfigTransforms( ConfigTransforms.updateAllParticipantConfigs_( - _.focus(_.parameters.alphaMultiSynchronizerSupport) + _.focus(_.parameters.enableAllLedgerApiReassignments) .replace(true) ), - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/repair/ImportContractsIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/repair/ImportContractsIntegrationTest.scala index 8cda7b22fb..a1a19458ac 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/repair/ImportContractsIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/repair/ImportContractsIntegrationTest.scala @@ -29,7 +29,7 @@ trait ImportContractsIntegrationTestBase with SharedEnvironment with EntitySyntax { - protected def enableAlphaMultiSynchronizerSupport: Boolean + protected def enableAllLedgerApiReassignments: Boolean private var alice: PartyId = _ private var bob: PartyId = _ @@ -39,10 +39,10 @@ trait ImportContractsIntegrationTestBase EnvironmentDefinition.P3_S1M1_S1M1 .addConfigTransforms( ConfigTransforms.updateAllParticipantConfigs_( - _.focus(_.parameters.alphaMultiSynchronizerSupport) - .replace(enableAlphaMultiSynchronizerSupport) + _.focus(_.parameters.enableAllLedgerApiReassignments) + .replace(enableAllLedgerApiReassignments) ), - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .withSetup { implicit env => import env.* @@ -91,7 +91,7 @@ trait ImportContractsIntegrationTestBase "Importing an ACS" should { - s"handle contracts with non-zero reassignment counter (multi-synchronizer-support=$enableAlphaMultiSynchronizerSupport)" in { + s"handle contracts with non-zero reassignment counter (multi-synchronizer-support=$enableAllLedgerApiReassignments)" in { implicit env => import env.* @@ -129,7 +129,7 @@ trait ImportContractsIntegrationTestBase participant3.synchronizers.disconnect_all() - if (enableAlphaMultiSynchronizerSupport) { + if (enableAllLedgerApiReassignments) { participant3.repair.import_acs(daId, file.canonicalPath) val reassignedContract = participant3.ledger_api.state.acs @@ -157,11 +157,11 @@ trait ImportContractsIntegrationTestBase * The ACS import will simply drop contracts that are associated to a different synchronizer; * and logs that fact once. */ - s"import a multi-synchronizer ACS snapshot (multi-synchronizer-support=$enableAlphaMultiSynchronizerSupport)" in { + s"import a multi-synchronizer ACS snapshot (multi-synchronizer-support=$enableAllLedgerApiReassignments)" in { implicit env => import env.* - // Create several contracts for both synchronizers having reassignment counter zero (thus independent of enableAlphaMultiSynchronizerSupport) + // Create several contracts for both synchronizers having reassignment counter zero (thus independent of enableAllLedgerApiReassignments) val contractsDa = (1 to 2).map { _ => IouSyntax.createIou(participant1, synchronizerId = Some(daId))(charlie, bob) } @@ -208,7 +208,7 @@ trait ImportContractsIntegrationTestBase // 1. Non-zero reassignment counter in recovered contract // 2. Imports contract using Assign/Unassign events // 3. Recovery retains pre-existing active contracts - s"recover successfully ACS import mid-crash for a reassigned contract preserving pre-existing state (multi-synchronizer-support=$enableAlphaMultiSynchronizerSupport)" in { + s"recover successfully ACS import mid-crash for a reassigned contract preserving pre-existing state (multi-synchronizer-support=$enableAllLedgerApiReassignments)" in { implicit env => import env.* @@ -244,7 +244,7 @@ trait ImportContractsIntegrationTestBase participant3.synchronizers.disconnect_all() - if (enableAlphaMultiSynchronizerSupport) { + if (enableAllLedgerApiReassignments) { val contractInstance = participant1.underlying.value.sync.participantNodePersistentState.value.contractStore .lookup(cid) @@ -339,10 +339,10 @@ trait ImportContractsIntegrationTestBase } final class ImportContractsIntegrationTest extends ImportContractsIntegrationTestBase { - override protected def enableAlphaMultiSynchronizerSupport: Boolean = false + override protected def enableAllLedgerApiReassignments: Boolean = false } final class ImportContractsWithReassignmentIntegrationTest extends ImportContractsIntegrationTestBase { - override protected def enableAlphaMultiSynchronizerSupport: Boolean = true + override protected def enableAllLedgerApiReassignments: Boolean = true } diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/repair/RepairServiceIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/repair/RepairServiceIntegrationTest.scala index bc8bb9e5b1..7bc838391e 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/repair/RepairServiceIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/repair/RepairServiceIntegrationTest.scala @@ -83,7 +83,7 @@ trait RepairServiceIntegrationTest EnvironmentDefinition.P2_S1M1_S1M1 .addConfigTransforms( ConfigTransforms.enableAdvancedCommands(FeatureFlag.Repair), - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) override val defaultParticipant: String = "participant1" @@ -858,7 +858,7 @@ sealed trait RepairServiceIntegrationTestStableLf extends RepairServiceIntegrati import env.* // If multi-synchronizer support is enabled, the purge will be represented as an Unassigned event, otherwise as a synthetic Archive event - val multiSynchronizerSupport = participant1.config.parameters.alphaMultiSynchronizerSupport + val multiSynchronizerSupport = participant1.config.parameters.enableAllLedgerApiReassignments val eventFormat = EventFormat( filtersByParty = @@ -1098,24 +1098,24 @@ sealed trait WithMultiSynchronizerSupport extends RepairServiceIntegrationTest { .addConfigTransforms( ConfigTransforms.enableAdvancedCommands(FeatureFlag.Repair), ConfigTransforms.updateAllParticipantConfigs_( - _.focus(_.parameters.alphaMultiSynchronizerSupport).replace(true) + _.focus(_.parameters.enableAllLedgerApiReassignments).replace(true) ), - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) } -/** Contains tests that ONLY work when alphaMultiSynchronizerSupport = true */ +/** Contains tests that ONLY work when enableAllLedgerApiReassignments = true */ sealed trait RepairServiceMultiSynchronizerTests extends RepairServiceIntegrationTest { "RepairServiceMultiSynchronizerTests" must { - "run test only with enabled `alphaMultiSynchronizerSupport`" in { implicit env => + "run test only with enabled `enableAllLedgerApiReassignments`" in { implicit env => import env.* participants.local.foreach { participant => assert( - participant.config.parameters.alphaMultiSynchronizerSupport, - s"alphaMultiSynchronizerSupport must be true for ${participant.name} in this test suite", + participant.config.parameters.enableAllLedgerApiReassignments, + s"enableAllLedgerApiReassignments must be true for ${participant.name} in this test suite", ) } } diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/repair/RollbackUnassignmentIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/repair/RollbackUnassignmentIntegrationTest.scala index 110a4d0787..70eb37d37a 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/repair/RollbackUnassignmentIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/repair/RollbackUnassignmentIntegrationTest.scala @@ -32,7 +32,7 @@ sealed trait RollbackUnassignmentIntegrationTest EnvironmentDefinition.P2_S1M1_S1M1 .addConfigTransforms( ConfigTransforms.enableAdvancedCommands(FeatureFlag.Repair), - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/CryptoMetricsIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/CryptoMetricsIntegrationTest.scala new file mode 100644 index 0000000000..373b761946 --- /dev/null +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/CryptoMetricsIntegrationTest.scala @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.integration.tests.security + +import com.daml.metrics.api.testing.MetricValues.* +import com.digitalasset.canton.crypto.provider.jce.JcePrivateCrypto +import com.digitalasset.canton.crypto.provider.kms.KmsPrivateCrypto +import com.digitalasset.canton.integration.plugins.{UseBftSequencer, UsePostgres} +import com.digitalasset.canton.integration.tests.security.kms.KmsCryptoWithPreDefinedKeysIntegrationTest +import com.digitalasset.canton.integration.tests.security.kms.gcp.GcpKmsCryptoIntegrationTestBase +import com.digitalasset.canton.integration.{ + CantonEnvironmentSetup, + CommunityIntegrationTest, + SharedEnvironment, +} + +/** Integration tests verifying that cryptographic operations using KMS providers are correctly + * instrumented to correctly record crypto-related metrics. + * + * In particular, this suite ensures that signing and decryption latencies are captured, as well as + * KMS-related metrics. + */ +trait CryptoMetricsIntegrationTest extends KmsCryptoWithPreDefinedKeysIntegrationTest { + self: CommunityIntegrationTest & CantonEnvironmentSetup => + + override lazy val protectedNodes: Set[String] = Set("participant1") + + "signing, decryption latencies, and KMS metrics are recorded" in { implicit env => + import env.* + + participant1.crypto.privateCrypto.isInstanceOf[KmsPrivateCrypto] shouldBe true + participant2.crypto.privateCrypto.isInstanceOf[JcePrivateCrypto] shouldBe true + + assertPingSucceeds(participant1, participant1) + + participant1.underlying.value.metrics.cryptoMetrics.kmsMetricsO shouldBe defined + + // Even when KMS is not used, signing and decryption operations always record latency metrics. + forAll(Seq(participant1, participant2)) { p => + p.underlying.value.metrics.cryptoMetrics.signingMetrics.signingLatency.valuesWithContext should not be empty + p.underlying.value.metrics.cryptoMetrics.decryptionMetrics.decryptLatency.valuesWithContext should not be empty + } + } + +} + +class GcpKmsCryptoMetricsIntegrationTestPostgres + extends CommunityIntegrationTest + with SharedEnvironment + with GcpKmsCryptoIntegrationTestBase + with KmsCryptoWithPreDefinedKeysIntegrationTest { + setupPlugins( + withAutoInit = false, + storagePlugin = Some(new UsePostgres(loggerFactory)), + sequencerPlugin = new UseBftSequencer(loggerFactory), + ) +} diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/InvalidAssignmentRequestIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/InvalidAssignmentRequestIntegrationTest.scala index 65255c156b..3fa1ad0fb4 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/InvalidAssignmentRequestIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/InvalidAssignmentRequestIntegrationTest.scala @@ -64,7 +64,7 @@ final class InvalidAssignmentRequestIntegrationTest override def environmentDefinition: EnvironmentDefinition = EnvironmentDefinition.P3_S1M1_S1M1 - .addConfigTransforms(ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag) + .addConfigTransforms(ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/InvalidReassignmentIdIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/InvalidReassignmentIdIntegrationTest.scala index 4ce90ff29d..5613504f67 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/InvalidReassignmentIdIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/InvalidReassignmentIdIntegrationTest.scala @@ -78,7 +78,7 @@ sealed trait InvalidReassignmentIdIntegrationTest override def environmentDefinition: EnvironmentDefinition = EnvironmentDefinition.P3_S1M1_S1M1 - .addConfigTransforms(ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag) + .addConfigTransforms(ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/InvalidUnassignmentRequestIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/InvalidUnassignmentRequestIntegrationTest.scala index e73bdec3fb..2bd66734d3 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/InvalidUnassignmentRequestIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/InvalidUnassignmentRequestIntegrationTest.scala @@ -58,7 +58,7 @@ class InvalidUnassignmentRequestIntegrationTest EnvironmentDefinition.P3_S1M1_S1M1 .addConfigTransforms( ProgrammableSequencer.configOverride(getClass.toString, loggerFactory), - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/KeyManagementIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/KeyManagementIntegrationTest.scala index a8f1bd7c75..c5b0bfe60e 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/KeyManagementIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/KeyManagementIntegrationTest.scala @@ -22,7 +22,7 @@ import com.digitalasset.canton.util.OptionUtil import org.scalatest.Assertion trait KeyManagementIntegrationTestHelper extends KeyManagementTestHelper { - self: BaseIntegrationTest => + self: CantonBaseIntegrationTest => protected def waitForKeyTopologyUpdate( nodes: Seq[InstanceReference], diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/KmsCryptoIntegrationTestBase.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/KmsCryptoIntegrationTestBase.scala index 0cdb6691f0..6ee91993a5 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/KmsCryptoIntegrationTestBase.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/KmsCryptoIntegrationTestBase.scala @@ -19,7 +19,7 @@ import com.digitalasset.canton.time.{RemoteClock, SimClock} * Check contributing/kms.md on how to run the tests */ trait KmsCryptoIntegrationTestBase extends TopologyManagementHelper { - self: CommunityIntegrationTest with EnvironmentSetup => + self: CommunityIntegrationTest with CantonEnvironmentSetup => // Defines which nodes will run an external KMS. protected lazy val protectedNodes: Set[String] = Set("participant1") diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/KmsCryptoWithPreDefinedKeysIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/KmsCryptoWithPreDefinedKeysIntegrationTest.scala index bce32988f0..9888b01cb4 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/KmsCryptoWithPreDefinedKeysIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/KmsCryptoWithPreDefinedKeysIntegrationTest.scala @@ -5,14 +5,14 @@ package com.digitalasset.canton.integration.tests.security.kms import com.digitalasset.canton.config.CryptoProvider import com.digitalasset.canton.crypto.store.KmsCryptoPrivateStore -import com.digitalasset.canton.integration.{CommunityIntegrationTest, EnvironmentSetup} +import com.digitalasset.canton.integration.{CantonEnvironmentSetup, CommunityIntegrationTest} /** Runs a crypto integration tests with one participant using a KMS provider with pre-generated * keys. Runs with persistence so we also check that it is able to recover from an unexpected * shutdown. */ trait KmsCryptoWithPreDefinedKeysIntegrationTest extends KmsCryptoIntegrationTestBase { - self: CommunityIntegrationTest & EnvironmentSetup => + self: CommunityIntegrationTest & CantonEnvironmentSetup => "be able to restart from a persisted state" in { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/SessionSigningKeysIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/SessionSigningKeysIntegrationTest.scala index 3c4943d665..cbaf4b7052 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/SessionSigningKeysIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/SessionSigningKeysIntegrationTest.scala @@ -38,7 +38,7 @@ trait SessionSigningKeysIntegrationTest override protected def otherConfigTransforms: Seq[ConfigTransform] = Seq( ConfigTransforms.setSigningKeysIfPV35OrHigher( - SessionSigningKeysConfig.default, + SessionSigningKeysConfig.enabled, nodeFilter = name => !nodesWithSessionSigningKeysDisabled.contains(name), ) ) @@ -48,13 +48,14 @@ trait SessionSigningKeysIntegrationTest ): Map[MetricsContext, Long] = { val kmsMetrics = node match { case p: LocalParticipantReference => - p.underlying.value.metrics.kmsMetrics + p.underlying.value.metrics.cryptoMetrics.kmsMetricsO.valueOrFail("no KMS metrics") case m: LocalMediatorReference => - m.underlying.value.replicaManager.mediatorRuntime.value.mediator.metrics.kmsMetrics + m.underlying.value.replicaManager.mediatorRuntime.value.mediator.metrics.cryptoMetrics.kmsMetricsO + .valueOrFail("no KMS metrics") case s: LocalSequencerReference => - s.underlying.value.sequencer.metrics.kmsMetrics + s.underlying.value.sequencer.metrics.cryptoMetrics.kmsMetricsO.valueOrFail("no KMS metrics") case _ => fail("unexpected node") } @@ -69,7 +70,7 @@ trait SessionSigningKeysIntegrationTest if (nodesWithSessionSigningKeysDisabled.contains(node.name)) node.config.crypto.sessionSigningKeys shouldBe SessionSigningKeysConfig.disabled else - node.config.crypto.sessionSigningKeys shouldBe SessionSigningKeysConfig.default + node.config.crypto.sessionSigningKeys shouldBe SessionSigningKeysConfig.enabled } assertPingSucceeds(participant1, participant2) diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/SessionSigningKeysLifecycleIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/SessionSigningKeysLifecycleIntegrationTest.scala index 65f9d3a139..cb652bc9b9 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/SessionSigningKeysLifecycleIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/SessionSigningKeysLifecycleIntegrationTest.scala @@ -78,7 +78,10 @@ trait SessionSigningKeysLifecycleIntegrationTest // record initial metrics to establish a baseline, as a fallback to the long-term key // may have occurred during bootstrap. val participantKmsMetrics = Seq(participant1, participant2).map( - _.underlying.value.metrics.kmsMetrics.sessionSigningKeysFallback.valuesWithContext + _.underlying.value.metrics.cryptoMetrics.kmsMetricsO + .valueOrFail("no KMS metrics") + .sessionSigningKeysFallback + .valuesWithContext ) env.nodes.local.foreach { node => @@ -100,7 +103,10 @@ trait SessionSigningKeysLifecycleIntegrationTest // we expect that no fallback has been triggered for the ping requests and that // session signing keys have been used and rotated successfully. Seq(participant1, participant2).map( - _.underlying.value.metrics.kmsMetrics.sessionSigningKeysFallback.valuesWithContext + _.underlying.value.metrics.cryptoMetrics.kmsMetricsO + .valueOrFail("no KMS metrics") + .sessionSigningKeysFallback + .valuesWithContext ) shouldBe participantKmsMetrics } } diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/aws/AwsEncryptedCryptoPrivateStoreTestBase.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/aws/AwsEncryptedCryptoPrivateStoreTestBase.scala index da4569679d..64ec2190f4 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/aws/AwsEncryptedCryptoPrivateStoreTestBase.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/aws/AwsEncryptedCryptoPrivateStoreTestBase.scala @@ -5,13 +5,13 @@ package com.digitalasset.canton.integration.tests.security.kms.aws import com.digitalasset.canton.integration.plugins.UseAwsKms import com.digitalasset.canton.integration.{ + CantonEnvironmentSetup, CommunityIntegrationTest, - EnvironmentSetup, EnvironmentSetupPlugin, } trait AwsEncryptedCryptoPrivateStoreTestBase { - self: CommunityIntegrationTest with EnvironmentSetup => + self: CommunityIntegrationTest with CantonEnvironmentSetup => protected def setupPlugins( protectedNodes: Set[String], diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/aws/AwsKmsCryptoIntegrationTestBase.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/aws/AwsKmsCryptoIntegrationTestBase.scala index 12c653a22c..1e154dc6da 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/aws/AwsKmsCryptoIntegrationTestBase.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/aws/AwsKmsCryptoIntegrationTestBase.scala @@ -16,14 +16,14 @@ import com.digitalasset.canton.integration.plugins.{ } import com.digitalasset.canton.integration.tests.security.kms.KmsCryptoIntegrationTestBase import com.digitalasset.canton.integration.{ + CantonEnvironmentSetup, CommunityIntegrationTest, ConfigTransforms, - EnvironmentSetup, EnvironmentSetupPlugin, } trait AwsKmsCryptoIntegrationTestBase extends KmsCryptoIntegrationTestBase { - self: CommunityIntegrationTest with EnvironmentSetup => + self: CommunityIntegrationTest with CantonEnvironmentSetup => protected val kmsConfig: KmsConfig = KmsConfig.Aws.defaultTestConfig diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/gcp/GcpEncryptedCryptoPrivateStoreTestBase.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/gcp/GcpEncryptedCryptoPrivateStoreTestBase.scala index d959f24c6a..a88b23e07d 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/gcp/GcpEncryptedCryptoPrivateStoreTestBase.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/gcp/GcpEncryptedCryptoPrivateStoreTestBase.scala @@ -6,13 +6,13 @@ package com.digitalasset.canton.integration.tests.security.kms.gcp import com.digitalasset.canton.config.KmsConfig import com.digitalasset.canton.integration.plugins.UseGcpKms import com.digitalasset.canton.integration.{ + CantonEnvironmentSetup, CommunityIntegrationTest, - EnvironmentSetup, EnvironmentSetupPlugin, } trait GcpEncryptedCryptoPrivateStoreTestBase { - self: CommunityIntegrationTest with EnvironmentSetup => + self: CommunityIntegrationTest with CantonEnvironmentSetup => protected def setupPlugins( protectedNodes: Set[String], diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/gcp/GcpKmsCryptoIntegrationTestBase.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/gcp/GcpKmsCryptoIntegrationTestBase.scala index fe03234d72..1f1d77e1e4 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/gcp/GcpKmsCryptoIntegrationTestBase.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/gcp/GcpKmsCryptoIntegrationTestBase.scala @@ -16,14 +16,14 @@ import com.digitalasset.canton.integration.plugins.{ } import com.digitalasset.canton.integration.tests.security.kms.KmsCryptoIntegrationTestBase import com.digitalasset.canton.integration.{ + CantonEnvironmentSetup, CommunityIntegrationTest, ConfigTransforms, - EnvironmentSetup, EnvironmentSetupPlugin, } trait GcpKmsCryptoIntegrationTestBase extends KmsCryptoIntegrationTestBase { - self: CommunityIntegrationTest with EnvironmentSetup => + self: CommunityIntegrationTest with CantonEnvironmentSetup => protected val kmsConfig: KmsConfig = KmsConfig.Gcp.defaultTestConfig diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/mock/MockEncryptedCryptoPrivateStoreTestBase.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/mock/MockEncryptedCryptoPrivateStoreTestBase.scala index 6ce7858a09..495a93cf6d 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/mock/MockEncryptedCryptoPrivateStoreTestBase.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/mock/MockEncryptedCryptoPrivateStoreTestBase.scala @@ -6,13 +6,13 @@ package com.digitalasset.canton.integration.tests.security.kms.mock import com.digitalasset.canton.crypto.kms.mock.v1.MockKmsDriverFactory.mockKmsDriverName import com.digitalasset.canton.integration.plugins.UseKmsDriver import com.digitalasset.canton.integration.{ + CantonEnvironmentSetup, CommunityIntegrationTest, - EnvironmentSetup, EnvironmentSetupPlugin, } trait MockEncryptedCryptoPrivateStoreTestBase { - self: CommunityIntegrationTest with EnvironmentSetup => + self: CommunityIntegrationTest with CantonEnvironmentSetup => protected def setupPlugins( protectedNodes: Set[String], diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/mock/MockKmsDriverCryptoIntegrationTestBase.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/mock/MockKmsDriverCryptoIntegrationTestBase.scala index 122cb1a701..fcdc56bfca 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/mock/MockKmsDriverCryptoIntegrationTestBase.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/kms/mock/MockKmsDriverCryptoIntegrationTestBase.scala @@ -14,8 +14,8 @@ import com.digitalasset.canton.integration.plugins.{EncryptedPrivateStoreStatus, import com.digitalasset.canton.integration.tests.security.kms.KmsCryptoIntegrationTestBase import com.digitalasset.canton.integration.tests.security.kms.mock.MockKmsDriverCryptoIntegrationTestBase.mockKmsDriverConfig import com.digitalasset.canton.integration.{ + CantonEnvironmentSetup, CommunityIntegrationTest, - EnvironmentSetup, EnvironmentSetupPlugin, } import com.typesafe.config.ConfigValueFactory @@ -23,7 +23,7 @@ import com.typesafe.config.ConfigValueFactory import scala.jdk.CollectionConverters.* trait MockKmsDriverCryptoIntegrationTestBase extends KmsCryptoIntegrationTestBase { - self: CommunityIntegrationTest with EnvironmentSetup => + self: CommunityIntegrationTest with CantonEnvironmentSetup => protected val kmsConfig: KmsConfig = mockKmsDriverConfig diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/pkgdars/PackageRemovalIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/pkgdars/PackageRemovalIntegrationTest.scala index 66f0a6b6d3..873d29794e 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/pkgdars/PackageRemovalIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/pkgdars/PackageRemovalIntegrationTest.scala @@ -73,7 +73,7 @@ sealed trait PackageRemovalIntegrationTest override def environmentDefinition: EnvironmentDefinition = EnvironmentDefinition.P2_S1M1_S1M1 - .addConfigTransforms(ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag) + .addConfigTransforms(ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag) // Note that CantonTests depends on CantonExamples private val cantonTestsPkg = PackageId.assertFromString(Many.PACKAGE_ID) diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/pkgdars/PackageVettingIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/pkgdars/PackageVettingIntegrationTest.scala index af2b8cccc9..6096935e97 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/pkgdars/PackageVettingIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/security/pkgdars/PackageVettingIntegrationTest.scala @@ -47,6 +47,7 @@ import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.util.MaliciousParticipantNode import com.digitalasset.canton.util.ReassignmentTag.{Source, Target} import com.digitalasset.canton.util.ShowUtil.* +import com.digitalasset.canton.version.ProtocolVersion import com.digitalasset.daml.lf.archive.{DamlLf, DarParser, DarReader} import com.digitalasset.daml.lf.data.Ref.PackageId import monocle.macros.syntax.lens.* @@ -69,6 +70,8 @@ sealed trait PackageVettingIntegrationTest val ledgerIntegrity: SecurityTest = SecurityTest(property = Integrity, asset = "virtual shared ledger") + private val pvSupportsUnvettedDependencies: Boolean = testedProtocolVersion > ProtocolVersion.v34 + private lazy val pureCryptoRef: AtomicReference[CryptoPureApi] = new AtomicReference() def pureCrypto: CryptoPureApi = pureCryptoRef.get() @@ -84,7 +87,7 @@ sealed trait PackageVettingIntegrationTest _.focus(_.parameters.reassignmentsConfig.targetTimestampForwardTolerance) .replace(config.NonNegativeFiniteDuration.ofMinutes(10)) ), - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .withSetup { implicit env => import env.* @@ -591,7 +594,7 @@ sealed trait PackageVettingIntegrationTest } "all packages are stored, but a dependent package has not been vetted" must { - "refuse to vet the package" taggedAs_ { mit => + s"refuse to vet the package on PV=${ProtocolVersion.v34}" taggedAs_ { mit => ledgerIntegrity.setAttack( Attack( actor = "participant operator", @@ -599,7 +602,7 @@ sealed trait PackageVettingIntegrationTest mitigation = mit, ) ) - } in { implicit env => + } onlyRunWhen (!pvSupportsUnvettedDependencies) in { implicit env => import env.* participant3.packages.list().filter(_.packageId == packId) should not be empty @@ -620,9 +623,16 @@ sealed trait PackageVettingIntegrationTest ) in { implicit env => import env.* - // can vet dependencies one by one using force archive.dependencies.foreach { dep => - vettingCmd(adds = List(dep), force = ForceFlags(ForceFlag.AllowUnvettedDependencies)) + vettingCmd( + adds = List(dep), + force = + if (pvSupportsUnvettedDependencies) ForceFlags.none + else { + // can vet dependencies one by one using force + ForceFlags(ForceFlag.AllowUnvettedDependencies) + }, + ) eventually() { participant3.topology.vetted_packages .list(daId, filterParticipant = participant3.filterString) @@ -861,17 +871,27 @@ sealed trait PackageVettingIntegrationTest val vettingDepDar = tryReadDar(VettingDepPath) val vettingMainDar = tryReadDar(VettingMainPath) - "refuse to unvet if the package is used as a dependency" in { implicit env => - import env.* + s"refuse to unvet if the package is used as a dependency on PV=${ProtocolVersion.v34} or before" onlyRunWhen (!pvSupportsUnvettedDependencies) in { + implicit env => + import env.* - // upload and vet the main dar and its dependencies - participant3.dars.upload(VettingMainPath, vetAllPackages = true) + // upload and vet the main dar and its dependencies + participant3.dars.upload(VettingMainPath, vetAllPackages = true) - // unvetting the dep package should fail - assertThrowsAndLogsCommandFailures( - vettingCmd(removes = Seq(vettingDepDar.main)), - _.shouldBeCantonErrorCode(ParticipantTopologyManagerError.DependenciesNotVetted), - ) + assertThrowsAndLogsCommandFailures( + vettingCmd(removes = Seq(vettingDepDar.main)), + _.shouldBeCantonErrorCode(ParticipantTopologyManagerError.DependenciesNotVetted), + ) + } + + s"allow to unvet if the package is used as a dependency on PV=${ProtocolVersion.v35} or after" onlyRunWhen (pvSupportsUnvettedDependencies) in { + implicit env => + import env.* + + // upload and vet the main dar and its dependencies + participant3.dars.upload(VettingMainPath, vetAllPackages = true) + + vettingCmd(removes = Seq(vettingDepDar.main)) } "allow to unvet if the package is used as a dependency and AllowUnvettedDependencies is used" in { @@ -882,20 +902,35 @@ sealed trait PackageVettingIntegrationTest ) } - "refuse to unvet while vetting a dependent package" in { implicit env => - // vet the dep package and unvet the main package - vettingCmd( - adds = Seq(vettingDepDar.main), - removes = Seq(vettingMainDar.main), - ) + s"refuse to unvet while vetting a dependent package (PV=${ProtocolVersion.v34})" onlyRunWhen (!pvSupportsUnvettedDependencies) in { + implicit env => + // vet the dep package and unvet the main package + vettingCmd( + adds = Seq(vettingDepDar.main), + removes = Seq(vettingMainDar.main), + ) + + assertThrowsAndLogsCommandFailures( + vettingCmd( + adds = Seq(vettingMainDar.main), + removes = Seq(vettingDepDar.main), + ), + _.shouldBeCantonErrorCode(ParticipantTopologyManagerError.DependenciesNotVetted), + ) + } + + s"allow to unvet while vetting a dependent package (PV=${ProtocolVersion.v35}+)" onlyRunWhen pvSupportsUnvettedDependencies in { + implicit env => + // vet the dep package and unvet the main package + vettingCmd( + adds = Seq(vettingDepDar.main), + removes = Seq(vettingMainDar.main), + ) - assertThrowsAndLogsCommandFailures( vettingCmd( adds = Seq(vettingMainDar.main), removes = Seq(vettingDepDar.main), - ), - _.shouldBeCantonErrorCode(ParticipantTopologyManagerError.DependenciesNotVetted), - ) + ) } } diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/DynamicOnboardingIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/DynamicOnboardingIntegrationTest.scala index a51722d849..bd393b5e28 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/DynamicOnboardingIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/DynamicOnboardingIntegrationTest.scala @@ -348,6 +348,17 @@ abstract class DynamicOnboardingIntegrationTest(val name: String) ) ) => message should include(s"was previously delivered at $aggregationSequenced2") + case SendResult.Error( + DeliverError( + _, + _, + _, + _, + SequencerErrors.AggregateSubmissionAlreadySentV2(message), + _, + ) + ) => + message should include(s"was previously delivered at $aggregationSequenced2") } } } diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/ToxiproxyIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/ToxiproxyIntegrationTest.scala index 27da1ddbe1..4ba7fb8e8a 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/ToxiproxyIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/ToxiproxyIntegrationTest.scala @@ -8,6 +8,7 @@ import com.digitalasset.canton.concurrent.Threading import com.digitalasset.canton.config import com.digitalasset.canton.config.RequireTypes.{NonNegativeInt, PositiveInt} import com.digitalasset.canton.console.SequencerReference +import com.digitalasset.canton.integration.bootstrap.NetworkTopologyDescription.MediatorSequencersConfiguration import com.digitalasset.canton.integration.bootstrap.{ NetworkBootstrapper, NetworkTopologyDescription, @@ -83,7 +84,15 @@ abstract class ToxiproxyIntegrationTest // Use a threshold of two to ensure that the mediator connects to all sequencers. // TODO(#19911) Reduce to one again once this can be configured independently. Some( - testMediators.map(_ -> (testSequencers, PositiveInt.two, NonNegativeInt.zero)).toMap + testMediators + .map( + _ -> MediatorSequencersConfiguration( + testSequencers, + trustThreshold = PositiveInt.two, + livenessMargin = NonNegativeInt.zero, + ) + ) + .toMap ), ) NetworkBootstrapper(Seq(description)) diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/bftordering/BftOrderingSequencerWithTrafficControlApiTestPostgres.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/bftordering/BftOrderingSequencerWithTrafficControlApiTestPostgres.scala index 7b231f8657..25ba787e20 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/bftordering/BftOrderingSequencerWithTrafficControlApiTestPostgres.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/bftordering/BftOrderingSequencerWithTrafficControlApiTestPostgres.scala @@ -17,6 +17,7 @@ import com.digitalasset.canton.synchronizer.sequencer.block.BlockSequencerFactor import com.digitalasset.canton.synchronizer.sequencer.block.bftordering.bindings.canton.sequencing.BftSequencerFactory import com.digitalasset.canton.synchronizer.sequencer.block.bftordering.core.BftBlockOrdererConfig import com.digitalasset.canton.synchronizer.sequencer.config.SequencerNodeParameters +import com.digitalasset.canton.synchronizer.sequencer.time.LsuSequencingBounds import com.digitalasset.canton.synchronizer.sequencer.traffic.{ SequencerRateLimitManager, SequencerTrafficConfig, @@ -81,6 +82,7 @@ class BftOrderingSequencerWithTrafficControlApiTestPostgres synchronizerSyncCryptoApi: SynchronizerCryptoClient, protocolVersion: ProtocolVersion, trafficConfig: SequencerTrafficConfig, + lsuSequencingBounds: Option[LsuSequencingBounds], ): SequencerRateLimitManager = rateLimitManager } } diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/bftordering/BftSequencerApiTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/bftordering/BftSequencerApiTest.scala index 7792bde4ca..68d6f51cae 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/bftordering/BftSequencerApiTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/bftordering/BftSequencerApiTest.scala @@ -56,7 +56,9 @@ class BftSequencerApiTest extends SequencerApiTest with RateLimitManagerTesting asyncWriter = AsyncWriterParameters(), timeAdvancingTopology = TimeAdvancingTopologyConfig(), delayRequestsBeforeLsuTrafficInit = false, + enableRejectDeliveredAggregationsOnPv35 = Seq("MED", "PAR"), lsuConfig = SequencerLsuConfig(), + enablePrevalidation = true, ) override final def createSequencer(crypto: SynchronizerCryptoClient)(implicit diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/reference/ReferenceSequencerApiTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/reference/ReferenceSequencerApiTest.scala index df6a325c54..c6cf3453a6 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/reference/ReferenceSequencerApiTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/reference/ReferenceSequencerApiTest.scala @@ -91,7 +91,9 @@ class ReferenceSequencerApiTest extends SequencerApiTest with RateLimitManagerTe asyncWriter = AsyncWriterParameters(), timeAdvancingTopology = TimeAdvancingTopologyConfig(), delayRequestsBeforeLsuTrafficInit = false, + enableRejectDeliveredAggregationsOnPv35 = Seq("MED", "PAR"), lsuConfig = SequencerLsuConfig(), + enablePrevalidation = true, ) "Reference sequencer" when runSequencerApiTests() diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/reference/ReferenceSequencerWithTrafficControlApiTestBase.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/reference/ReferenceSequencerWithTrafficControlApiTestBase.scala index 20c26d4f34..f3327bd1dc 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/reference/ReferenceSequencerWithTrafficControlApiTestBase.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/reference/ReferenceSequencerWithTrafficControlApiTestBase.scala @@ -313,8 +313,10 @@ abstract class ReferenceSequencerWithTrafficControlApiTestBase asyncWriter = AsyncWriterParameters(), timeAdvancingTopology = TimeAdvancingTopologyConfig(), delayRequestsBeforeLsuTrafficInit = false, + enableRejectDeliveredAggregationsOnPv35 = Seq("MED"), disableSubmissionChecksForTesting = disableSubmissionChecksForTesting, lsuConfig = SequencerLsuConfig(), + enablePrevalidation = true, ) // Important to create the histograms before the factory, because creating the factory will // register them once and for all and we can't add more afterwards @@ -1320,6 +1322,7 @@ object ReferenceSequencerWithTrafficControlApiTestBase { trafficConfig, sequencerMemberRateLimiterFactory, eventCostCalculator, + lsuSequencingBounds = None, ) { private val isWriteSideEnforcementDisabled = new AtomicBoolean(false) private val readValidationResponse = new AtomicReference[ diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/reference/ReferenceSequencerWithTrafficControlApiTestPostgres.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/reference/ReferenceSequencerWithTrafficControlApiTestPostgres.scala index 7496aef193..fd36c5f241 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/reference/ReferenceSequencerWithTrafficControlApiTestPostgres.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/sequencer/reference/ReferenceSequencerWithTrafficControlApiTestPostgres.scala @@ -12,6 +12,7 @@ import com.digitalasset.canton.synchronizer.metrics.SequencerMetrics import com.digitalasset.canton.synchronizer.sequencer.BlockSequencerConfig import com.digitalasset.canton.synchronizer.sequencer.block.DriverBlockSequencerFactory import com.digitalasset.canton.synchronizer.sequencer.config.SequencerNodeParameters +import com.digitalasset.canton.synchronizer.sequencer.time.LsuSequencingBounds import com.digitalasset.canton.synchronizer.sequencer.traffic.{ SequencerRateLimitManager, SequencerTrafficConfig, @@ -58,6 +59,7 @@ class ReferenceSequencerWithTrafficControlApiTestPostgres synchronizerSyncCryptoApi: SynchronizerCryptoClient, protocolVersion: ProtocolVersion, trafficConfig: SequencerTrafficConfig, + lsuSequencingBounds: Option[LsuSequencingBounds], ): SequencerRateLimitManager = rateLimitManager } } diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/topology/PartyToParticipantAuthIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/topology/PartyToParticipantAuthIntegrationTest.scala index 6e48579537..4ac0db9363 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/topology/PartyToParticipantAuthIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/topology/PartyToParticipantAuthIntegrationTest.scala @@ -35,6 +35,7 @@ import org.scalatest.Assertion import org.slf4j.event.Level import java.util.UUID +import scala.concurrent.ExecutionContext trait PartyToParticipantAuthIntegrationTest extends CommunityIntegrationTest @@ -1096,7 +1097,10 @@ trait PartyToParticipantAuthIntegrationTest fingerprints: Seq[Fingerprint], serial: PositiveInt = PositiveInt.one, topologyChangeOp: TopologyChangeOp = TopologyChangeOp.Replace, - )(implicit env: FixtureParam): SignedTopologyTransaction[TopologyChangeOp, TopologyMapping] = { + )(implicit + env: FixtureParam, + ec: ExecutionContext, + ): SignedTopologyTransaction[TopologyChangeOp, TopologyMapping] = { val tx = topologyTransaction(topologyMapping, serial = serial, topologyChangeOp = topologyChangeOp) @@ -1128,13 +1132,13 @@ trait PartyToParticipantAuthIntegrationTest private def sign[Op <: TopologyChangeOp, M <: TopologyMapping]( topologyTransaction: TopologyTransaction[Op, M], fingerprint: Fingerprint, - )(implicit env: FixtureParam): Signature = + )(implicit env: FixtureParam, ec: ExecutionContext): Signature = signBytes(topologyTransaction.hash.hash.getCryptographicEvidence, fingerprint) private def signBytes( bytes: ByteString, fingerprint: Fingerprint, - )(implicit env: FixtureParam): Signature = + )(implicit env: FixtureParam, ec: ExecutionContext): Signature = env.tryGlobalCrypto.privateCrypto .signBytes( bytes, @@ -1148,7 +1152,7 @@ trait PartyToParticipantAuthIntegrationTest participant: ParticipantReference, partyId: PartyId, signingKey: SigningPublicKey, - )(implicit env: FixtureParam): ExecuteSubmissionAndWaitResponse = { + )(implicit env: FixtureParam, ec: ExecutionContext): ExecuteSubmissionAndWaitResponse = { val prepared: PrepareSubmissionResponse = participant.ledger_api.javaapi.interactive_submission.prepare( Seq(partyId), diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/topology/TopologyValidationMultiSynchronizerIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/topology/TopologyValidationMultiSynchronizerIntegrationTest.scala index d90f37df5a..fe80f4bd7a 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/topology/TopologyValidationMultiSynchronizerIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/topology/TopologyValidationMultiSynchronizerIntegrationTest.scala @@ -36,7 +36,7 @@ class TopologyValidationMultiSynchronizerIntegrationTest override lazy val environmentDefinition: EnvironmentDefinition = EnvironmentDefinition.P2_S1M1_S1M1 .addConfigTransforms( - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag ) .withSetup { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/toxiproxy/slow/ToxiproxyBftSequencerConnectionsIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/toxiproxy/slow/ToxiproxyBftSequencerConnectionsIntegrationTest.scala index 10ceb0a2a3..d73949b73e 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/toxiproxy/slow/ToxiproxyBftSequencerConnectionsIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/toxiproxy/slow/ToxiproxyBftSequencerConnectionsIntegrationTest.scala @@ -12,6 +12,7 @@ import com.digitalasset.canton.config import com.digitalasset.canton.config.RequireTypes.{NonNegativeInt, PositiveInt} import com.digitalasset.canton.console.{CommandFailure, InstanceReference} import com.digitalasset.canton.error.TransactionRoutingError.TopologyErrors.UnknownContractSynchronizers +import com.digitalasset.canton.integration.bootstrap.NetworkTopologyDescription.MediatorSequencersConfiguration import com.digitalasset.canton.integration.bootstrap.{ NetworkBootstrapper, NetworkTopologyDescription, @@ -102,8 +103,11 @@ sealed trait ToxiproxyBftSequencerConnectionsIntegrationTest mediators = Seq(mediator1), overrideMediatorToSequencers = Some( Map( - mediator1 -> (sequencers.local, - /* trust threshold */ PositiveInt.two, /* liveness margin */ NonNegativeInt.zero) + mediator1 -> MediatorSequencersConfiguration( + sequencers.local, + trustThreshold = PositiveInt.two, + livenessMargin = NonNegativeInt.zero, + ) ) ), ) diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/traffic/ParticipantTrafficEnforcementTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/traffic/ParticipantTrafficEnforcementTest.scala new file mode 100644 index 0000000000..99e19a1a6b --- /dev/null +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/traffic/ParticipantTrafficEnforcementTest.scala @@ -0,0 +1,213 @@ +// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.integration.tests.traffic + +import com.digitalasset.canton.config +import com.digitalasset.canton.config.RequireTypes.PositiveInt +import com.digitalasset.canton.console.CommandFailure +import com.digitalasset.canton.integration.* +import com.digitalasset.canton.integration.util.{TestUtils, TrafficControlUtils} +import com.digitalasset.canton.ledger.error.CommonErrors.ServiceNotRunning +import com.digitalasset.canton.logging.LogEntry +import com.digitalasset.canton.platform.config.{ + TrafficEnforcementConfig, + TrafficEnforcementServerConfig, +} +import com.digitalasset.canton.topology.ExternalParty +import io.grpc.Status +import monocle.macros.syntax.lens.* +import org.scalatest.Assertion + +import java.time.Duration +import java.util.UUID +import scala.concurrent.duration.DurationInt + +sealed trait ParticipantTrafficEnforcementTest + extends CommunityIntegrationTest + with SharedEnvironment + with HasCycleUtils { + + protected var aliceE: ExternalParty = _ + + /** Participant config transforms to enable or disable participant-local traffic enforcement. + */ + protected def participantConfigTransforms: Seq[ConfigTransform] + + override def environmentDefinition: EnvironmentDefinition = + EnvironmentDefinition.P2_S1M1 + .addConfigTransforms(participantConfigTransforms*) + .addConfigTransform(ConfigTransforms.enableInteractiveSubmissionTransforms) + .addConfigTransforms(ConfigTransforms.useStaticTime) + .addConfigTransform( + ConfigTransforms.updateAllSequencerConfigs_( + _.focus(_.trafficConfig.pruningRetentionWindow) + .replace(config.NonNegativeFiniteDuration.ofSeconds(5)) + .focus(_.trafficConfig.trafficPurchasedCacheSizePerMember) + .replace(PositiveInt.one) + ) + ) + .withSetup { implicit env => + import env.* + participants.local.foreach { participant => + participant.synchronizers.connect_local(sequencer1, alias = daName) + participant.dars.upload(CantonExamplesPath, synchronizerId = daId) + } + + aliceE = participant1.parties.testing.external.enable("Alice") + } + .withTrafficControl( + TestUtils.waitForTargetTimeOnSynchronizerNode(wallClock.now, logger), + trafficControlParameters = TrafficControlUtils.predictableTraffic, + topUpAllMembers = true, + disableCommitments = true, + ) + + protected def assertUnimplemented(entry: LogEntry): Assertion = + entry.message should include(Status.Code.UNIMPLEMENTED.toString) +} + +final class ParticipantTrafficEnforcementDisabledTest extends ParticipantTrafficEnforcementTest { + override protected def participantConfigTransforms: Seq[ConfigTransform] = Seq( + ConfigTransforms.updateParticipantConfig("participant1")( + _.focus(_.trafficEnforcement).replace(TrafficEnforcementConfig(enabled = false)) + ) + ) + + "Participant" when { + "traffic enforcement is disabled" should { + "not expose the traffic service endpoints on the Ledger API" in { implicit env => + import env.* + + assertThrowsAndLogsCommandFailures( + participant1.ledger_api.traffic.get_account(aliceE.partyId.toProtoPrimitive), + assertUnimplemented, + ) + + assertThrowsAndLogsCommandFailures( + participant1.ledger_api.traffic.update_account( + aliceE.partyId.toProtoPrimitive, + None, + ), + assertUnimplemented, + ) + } + } + + "support interactive submissions" in { implicit env => + import env.* + + // Pass some time to allow traffic re-fill the submission below + environment.simClock.value.advance(Duration.ofSeconds(5L)) + + // Prepare and execute should work seamlessly + val prepared = participant1.ledger_api.interactive_submission.prepare( + actAs = Seq(aliceE), + commands = Seq(createCycleCommand(aliceE.partyId, "traffic")), + hashingSchemeVersion = testedApiHashingSchemeVersion, + ) + + participant1.ledger_api.interactive_submission.execute_and_wait( + prepared.getPreparedTransaction, + Map(aliceE.partyId -> global_secret.sign(prepared.preparedTransactionHash, aliceE)), + UUID.randomUUID().toString, + prepared.hashingSchemeVersion, + ) + } + } +} + +final class ParticipantTrafficEnforcementEnabledTest extends ParticipantTrafficEnforcementTest { + private val nonExistentTeaServerName = s"non-existent-tea-server" + + override protected def participantConfigTransforms: Seq[ConfigTransform] = Seq( + ConfigTransforms.updateParticipantConfig("participant1")( + _.focus(_.trafficEnforcement) + .replace( + TrafficEnforcementConfig( + enabled = true, + trafficEnforcementServer = + TrafficEnforcementServerConfig.Internal(nonExistentTeaServerName), + ) + ) + ), + // Shorten network timeout so retries to the non-existent traffic service give up quickly + _.focus(_.parameters.timeouts.processing.network) + .replace(config.NonNegativeDuration.tryFromDuration(5.seconds)), + ) + + "Participant" when { + "traffic enforcement is enabled but traffic enforcement server is not available" should { + "return graceful errors on traffic and interactive submission service endpoints" in { + implicit env => + import env.* + + def assertEntriesTeaUnavailable(entries: Seq[LogEntry]): Assertion = + entries.foldLeft(succeed) { case (_, entry) => + entry.message should ((include(ServiceNotRunning.id) and include( + "User traffic service is not running" + )) or + (include(Status.Code.UNAVAILABLE.toString) and include( + s"Could not find server: $nonExistentTeaServerName" + ) or + include("Retry timeout has elapsed, giving up."))) + } + + // GetAccount on P1 fails due to TEA not enabled + loggerFactory.assertThrowsAndLogsSeq[CommandFailure]( + participant1.ledger_api.traffic.get_account(aliceE.partyId.toProtoPrimitive), + assertEntriesTeaUnavailable, + ) + + // UpdateAccount on P1 fails due to TEA not enabled + loggerFactory.assertThrowsAndLogsSeq[CommandFailure]( + participant1.ledger_api.traffic.update_account( + aliceE.partyId.toProtoPrimitive, + None, + ), + assertEntriesTeaUnavailable, + ) + + // Preparing on P1 fails due to TEA not enabled + loggerFactory.assertThrowsAndLogsSeq[CommandFailure]( + participant1.ledger_api.interactive_submission.prepare( + actAs = Seq(aliceE), + commands = Seq(createCycleCommand(aliceE.partyId, "traffic")), + hashingSchemeVersion = testedApiHashingSchemeVersion, + ), + assertEntriesTeaUnavailable, + ) + + // Prepare a transaction on P2 for Alice (P2 does not have traffic enabled so we can prepare) + val prepared = participant2.ledger_api.interactive_submission.prepare( + actAs = Seq(aliceE), + commands = Seq(createCycleCommand(aliceE.partyId, "traffic")), + hashingSchemeVersion = testedApiHashingSchemeVersion, + ) + + // Executing on P1 fails due to TEA not enabled + loggerFactory.assertThrowsAndLogsSeq[CommandFailure]( + participant1.ledger_api.interactive_submission.execute_and_wait( + prepared.getPreparedTransaction, + Map(aliceE.partyId -> global_secret.sign(prepared.preparedTransactionHash, aliceE)), + UUID.randomUUID().toString, + prepared.hashingSchemeVersion, + // Short timeout to reduce test time + optTimeout = Some(5.seconds), + ), + _.foldLeft(succeed) { case (_, entry) => + entry.message should ((include(ServiceNotRunning.id) and include( + "User traffic service is not running" + )) or + (include(Status.Code.UNAVAILABLE.toString) and include( + s"Could not find server: $nonExistentTeaServerName" + )) or + include("Retry timeout has elapsed, giving up.") or + include("Failed to submit submission") or + include("DEADLINE_EXCEEDED")) + }, + ) + } + } + } +} diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/traffic/TrafficControlTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/traffic/TrafficControlTest.scala index e78ba7250c..113b1abb73 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/traffic/TrafficControlTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/traffic/TrafficControlTest.scala @@ -11,6 +11,7 @@ import com.digitalasset.canton.admin.api.client.data.{ ComponentHealthState, TrafficControlParameters, } +import com.digitalasset.canton.annotations.UnstableTest import com.digitalasset.canton.config.CantonRequireTypes.InstanceName import com.digitalasset.canton.config.RequireTypes.{ NonNegativeLong, @@ -1006,6 +1007,7 @@ trait TrafficControlTest ) } +@UnstableTest // TOOD(#31976) class TrafficControlTestBftOrderingPostgres extends TrafficControlTest { private val useBftSequencer = new UseBftSequencer( loggerFactory, @@ -1018,6 +1020,7 @@ class TrafficControlTestBftOrderingPostgres extends TrafficControlTest { registerPlugin(new UseProgrammableSequencer(this.getClass.toString, loggerFactory)) } +@UnstableTest // TOOD(#32073) class TrafficControlTestBftOrderingH2 extends TrafficControlTest { private val useBftSequencer = new UseBftSequencer( loggerFactory, diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/CantonNetworkTopologyStateIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/CantonNetworkTopologyStateIntegrationTest.scala index 49190e2964..9de116b926 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/CantonNetworkTopologyStateIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/CantonNetworkTopologyStateIntegrationTest.scala @@ -12,9 +12,9 @@ import com.digitalasset.canton.integration.plugins.toxiproxy.UseToxiproxy.Toxipr import com.digitalasset.canton.integration.plugins.toxiproxy.{ParticipantToPostgres, UseToxiproxy} import com.digitalasset.canton.integration.tests.toxiproxy.ToxiproxyHelpers import com.digitalasset.canton.integration.{ + CantonEnvironmentSetup, CommunityIntegrationTest, EnvironmentDefinition, - EnvironmentSetup, SharedEnvironment, TestConsoleEnvironment, } @@ -43,7 +43,7 @@ import scala.annotation.nowarn import scala.concurrent.duration.* trait CantonNetworkTopologyIntegrationTestBase extends CommunityIntegrationTest { - this: EnvironmentSetup => + this: CantonEnvironmentSetup => protected def runValidation( topoStoreIdx: Int, txs: GenericStoredTopologyTransactions, diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuCommandIdIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuCommandIdIntegrationTest.scala index ed12c111e3..fdf2b997c6 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuCommandIdIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuCommandIdIntegrationTest.scala @@ -6,6 +6,7 @@ package com.digitalasset.canton.integration.tests.upgrade.lsu import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.discard.Implicits.DiscardOps import com.digitalasset.canton.error.MediatorError +import com.digitalasset.canton.error.TransactionRoutingError.ConfigurationErrors import com.digitalasset.canton.integration.bootstrap.NetworkBootstrapper import com.digitalasset.canton.integration.plugins.UseReferenceBlockSequencer.MultiSynchronizer import com.digitalasset.canton.integration.plugins.{UseBftSequencer, UseProgrammableSequencer} @@ -13,15 +14,11 @@ import com.digitalasset.canton.integration.tests.examples.IouSyntax import com.digitalasset.canton.integration.util.TestUtils.waitForTargetTimeOnSequencer import com.digitalasset.canton.integration.{EnvironmentDefinition, TestEnvironment} import com.digitalasset.canton.ledger.error.groups.ConsistencyErrors.DuplicateCommand -import com.digitalasset.canton.logging.SuppressingLogger.LogEntryOptionality.{ - Optional, - OptionalMany, -} import com.digitalasset.canton.participant.protocol.TransactionProcessor.SubmissionErrors import com.digitalasset.canton.participant.protocol.TransactionProcessor.SubmissionErrors.SequencerRequest +import com.digitalasset.canton.participant.sync.SyncServiceInjectionError import com.digitalasset.canton.protocol.LocalRejectError.TimeRejects -import com.digitalasset.canton.sequencing.protocol.SubmissionRequest -import com.digitalasset.canton.synchronizer.sequencer.errors.SequencerError +import com.digitalasset.canton.sequencing.protocol.{SequencerErrors, SubmissionRequest} import com.digitalasset.canton.synchronizer.sequencer.{HasProgrammableSequencer, SendDecision} import com.digitalasset.canton.topology.PartyId import com.google.rpc.Code @@ -145,48 +142,35 @@ final class LsuCommandIdIntegrationTest extends LsuBase with HasProgrammableSequ val cmdIdAtUpgradeTime = "cmd-id-at-upgrade-time" withClue("test command at upgrade time") { - loggerFactory.assertLogsUnorderedOptional( - { - // Move to upgrade time so that command submissions will fail due to overlap with LSU - environment.simClock.value.advanceTo(upgradeTime) - - val offsetBeforeSubmit = participant1.ledger_api.state.end() - participant1.ledger_api.javaapi.commands - .submit_async(Seq(bank), createIouCmd, commandId = cmdIdAtUpgradeTime) - - // Move forward until the new psid is up - environment.simClock.value.advanceTo(upgradeTime.immediateSuccessor) - transferTraffic(suppressLogs = false) - eventually() { - environment.simClock.value.advance(Duration.ofSeconds(1)) - participants.all.forall(_.synchronizers.is_connected(fixture.newPsid)) shouldBe true - } - waitForTargetTimeOnSequencer(sequencer2, environment.clock.now, logger) + // Move to upgrade time so that command submissions will fail due to overlap with LSU + environment.simClock.value.advanceTo(upgradeTime) + // wait for the mediator to observe the upgrade time on the old synchronizer. + mediator1.testing.await_synchronizer_time(upgradeTime, commandTimeouts.ledgerCommand) - oldSynchronizerNodes.all.stop() + assertThrowsAndLogsCommandFailures( + participant1.ledger_api.javaapi.commands + .submit(Seq(bank), createIouCmd, commandId = cmdIdAtUpgradeTime), + _.message should ( + include(SyncServiceInjectionError.NotConnectedToAnySynchronizer.id) or + include(ConfigurationErrors.SubmissionSynchronizerNotReady.id) or + include(SubmissionErrors.TimeoutError.id) or + include(SequencerErrors.PassedUpgradeTime.id) or + // the command gets rejected with this error, when the synthetic LSU tombstone gets sequenced and processed + // before the command gets submitted, and therefore it gets rejected directly during the synchronous processing of sendAsync + include(SequencerRequest.id) + ), + ) - // Move further forward until this decision timeout has expired, and expect to see our submission timeout on the completion stream - environment.simClock.value.advance(decisionTimeout.plusSeconds(1).asJava) - participant1.health.ping(participant1) // To notify the sequencer that time has passed + // Move forward until the new psid is up + environment.simClock.value.advanceTo(upgradeTime.immediateSuccessor) + transferTraffic(suppressLogs = false) + eventually() { + environment.simClock.value.advance(Duration.ofSeconds(1)) + participants.all.forall(_.synchronizers.is_connected(fixture.newPsid)) shouldBe true + } + waitForTargetTimeOnSequencer(sequencer2, environment.clock.now, logger) - assertCommandEventuallyFailed( - cmdIdAtUpgradeTime, - offsetBeforeSubmit, - bank, - _ should ( - include(TimeRejects.LocalTimeout.id) or - include(SubmissionErrors.TimeoutError.id) or - // the command gets rejected with this error, when the synthetic LSU tombstone gets sequenced and processed - // before the command gets submitted, and therefore it gets rejected right at the start - include(SequencerRequest.id) - ), - ) - }, - Optional -> (_.warningMessage should (include regex "Response message for request .* timed out at")), - Optional -> (_.warningMessage should include("Submission timed out at")), - Optional -> (_.warningMessage should include("Time validation has failed")), - OptionalMany -> (_.shouldBeCantonErrorCode(SequencerError.NotAtUpgradeTimeOrBeyond)), - ) + oldSynchronizerNodes.all.stop() } withClue( @@ -211,7 +195,7 @@ final class LsuCommandIdIntegrationTest extends LsuBase with HasProgrammableSequ sinceOffset: Long, party: PartyId, assertErrorMessage: String => Unit, - )(implicit env: TestEnvironment): Unit = { + )(implicit env: TestEnvironment[?]): Unit = { val status = env.participant1.ledger_api.completions .list( party, diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuEarlyHandshakeIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuEarlyHandshakeIntegrationTest.scala index ab0cc39b2c..036920bb83 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuEarlyHandshakeIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuEarlyHandshakeIntegrationTest.scala @@ -186,7 +186,7 @@ final class LsuEarlyHandshakeIntegrationTest extends LsuBase { forAll(participants.local)( _.topology.lsu.sequencer_successors .list() - .filter(_.item.sequencerId == sequencer1.id) + .filter(_.item.sequencerId == sequencer2.id) .loneElement ) } diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuEndToEndIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuEndToEndIntegrationTest.scala index 309ef2387a..bb9bd05869 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuEndToEndIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuEndToEndIntegrationTest.scala @@ -210,6 +210,52 @@ private object LsuEndToEndIntegrationTest { } } +private object LsuEndToEndIntegrationTest { + import org.scalatest.OptionValues.* + + final case class ExpectedRegisteredSynchronizers( + initialSynchronizerConnectionConfig: SynchronizerConnectionConfig, + fixture: Fixture, + )(implicit env: TestConsoleEnvironment) { + import env.* + + val currentRegisteredSynchronizerBeforeLsu = RegisteredSynchronizer( + config = initialSynchronizerConnectionConfig, + status = Status.Active, + psid = KnownPhysicalSynchronizerId(fixture.currentPsid), + predecessor = None, + isConnected = true, + ) + + val newSequencerConnections: NonEmpty[Map[SequencerAlias, SequencerConnection]] = NonEmpty + .from( + Map( + sequencer1.sequencerAlias -> sequencer2.sequencerConnection + .copy(sequencerAlias = sequencer1.sequencerAlias, sequencerId = Some(sequencer1.id)) + ) + ) + .value + + val newRegisteredSynchronizerBeforeLsu = RegisteredSynchronizer( + config = initialSynchronizerConnectionConfig + .focus(_.sequencerConnections.aliasToConnection) + .replace(newSequencerConnections) + .focus(_.synchronizerId) + .replace(Some(fixture.newPsid)), + status = Status.LsuTarget, + psid = KnownPhysicalSynchronizerId(fixture.newPsid), + predecessor = Some( + SynchronizerPredecessor( + psid = fixture.currentPsid, + upgradeTime = fixture.upgradeTime, + isLateUpgrade = false, + ) + ), + isConnected = false, + ) + } +} + final class LsuEndToEndSimClockIntegrationTest extends LsuEndToEndIntegrationTest final class LsuEndToEndWallClockIntegrationTest extends LsuEndToEndIntegrationTest { diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuExhaustiveHandshakeIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuExhaustiveHandshakeIntegrationTest.scala new file mode 100644 index 0000000000..4b611224d4 --- /dev/null +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuExhaustiveHandshakeIntegrationTest.scala @@ -0,0 +1,206 @@ +// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.integration.tests.upgrade.lsu + +import com.daml.metrics.api.MetricQualification +import com.digitalasset.canton.data.CantonTimestamp +import com.digitalasset.canton.integration.* +import com.digitalasset.canton.integration.EnvironmentDefinition.S2M2 +import com.digitalasset.canton.integration.bootstrap.NetworkBootstrapper +import com.digitalasset.canton.integration.plugins.UseReferenceBlockSequencer.MultiSynchronizer +import com.digitalasset.canton.integration.plugins.{UseBftSequencer, UsePostgres} +import com.digitalasset.canton.integration.tests.upgrade.lsu.LsuBase.{ + getLsuStatusMetricValues, + getParticipantHandshakesMetricValues, +} +import com.digitalasset.canton.logging.SuppressionRule +import com.digitalasset.canton.metrics.{MetricsConfig, MetricsReporterConfig} +import com.digitalasset.canton.participant.metrics.ParticipantMetrics +import com.digitalasset.canton.participant.synchronizer.grpc.GrpcSynchronizerRegistry +import com.digitalasset.canton.{UniquePortGenerator, config} +import monocle.macros.syntax.lens.* +import org.slf4j.event.Level + +/** The goal of this test is to ensure that participants do the handshake with as many sequencers as + * possible and that this "waiting" is interrupted in case of shutdown or when minimumDuration has + * elapsed. + * + * Topology: + * - P1, P2, P3 connected to S1 and S2 with threshold=1 + * - The handshake is done as soon as S1 successor is known + * - Announcement of S2 successor triggers another handshake on which we do checks/assertions + * + * - Two sequencers: + * - S1 has S3 as successor + * - S2 has S4 as successor + * + * Tests: + * - P1 waits until all sequencers are up + * - P2 stops waiting because of shutdown + * - P3 stops waiting because of minimumDuration has elapsed + */ +final class LsuExhaustiveHandshakeIntegrationTest extends LsuBase { + + override protected def testName: String = "lsu-exhaustive-handshake" + + registerPlugin( + new UseBftSequencer( + loggerFactory, + MultiSynchronizer.tryCreate(Set("sequencer1", "sequencer2"), Set("sequencer3", "sequencer4")), + ) + ) + registerPlugin(new UsePostgres(loggerFactory)) + + override protected lazy val newOldSequencers: Map[String, String] = + Map("sequencer3" -> "sequencer1", "sequencer4" -> "sequencer2") + override protected lazy val newOldMediators: Map[String, String] = + Map("mediator3" -> "mediator1", "mediator4" -> "mediator2") + override protected lazy val upgradeTime: CantonTimestamp = CantonTimestamp.Epoch.plusSeconds(30) + + override lazy val environmentDefinition: EnvironmentDefinition = + EnvironmentDefinition.P3S4M4_Config + .withNetworkBootstrap { implicit env => + new NetworkBootstrapper(S2M2) + } + .addConfigTransforms( + _.focus(_.monitoring.metrics) + .replace( + MetricsConfig( + qualifiers = Seq[MetricQualification](MetricQualification.Debug), + reporters = Seq( + MetricsReporterConfig.Prometheus( + port = UniquePortGenerator.next + ) + ), + ) + ) + ) + .addConfigTransform( + ConfigTransforms.updateAllParticipantConfigs { + + case ("participant1" | "participant2", pConfig) => + pConfig + .focus(_.parameters.lsu.handshake.minimumDuration) + // big value to ensure that the minimumDuration does not interrupt the wait + .replace(Some(config.NonNegativeFiniteDuration.ofDays(1))) + + case ("participant3", pConfig) => + pConfig + .focus(_.parameters.lsu.handshake.minimumDuration) + // small value to ensure that the minimumDuration interrupts the wait + .replace(Some(config.NonNegativeFiniteDuration.ofSeconds(3))) + + case (_other, pConfig) => pConfig + } + ) + .addConfigTransforms(configTransforms*) + .withSetup { implicit env => + import env.* + + defaultEnvironmentSetup(connectParticipants = false) + + participants.local.foreach( + _.synchronizers.connect_by_config( + synchronizerConnectionConfig(Seq(sequencer1, sequencer2), threshold = 1) + ) + ) + } + + "Participants" should { + "perform the handshake with as many sequencers as possible" in { implicit env => + import env.* + + val fixture = fixtureWithDefaults() + val p2Id = participant2.id + + fixture.oldSynchronizerOwners.foreach( + _.topology.lsu.announcement.propose(fixture.newPsid, fixture.upgradeTime) + ) + + // Ensure all nodes see the announcement + eventually() { + forAll(fixture.oldSynchronizerNodes.all ++ participants.local)( + _.topology.lsu.announcement + .list(store = Some(fixture.currentPsid)) + .filter(_.item.successorSynchronizerId == fixture.newPsid) + .loneElement + ) + } + + migrateSynchronizerNodes(fixture) + + /* + Stopping S4 so that handshake with S4 fails. + Stopping the mediators first to avoid connectivity warnings in the logs. + */ + + // Ensure that handshake with S4 fails + fixture.newSynchronizerNodes.mediators.stop() + sequencer4.stop() + + sequencer1.topology.lsu.sequencer_successors.propose_successor( + sequencerId = sequencer1.id, + endpoints = sequencer3.sequencerConnection.endpoints.map(_.toURI(useTls = false)), + successorSynchronizerId = fixture.newPsid, + ) + + // Initial handshake succeed because threshold=1 + eventually() { + forAll(participants.local) { p => + getLsuStatusMetricValues(p) + .get(fixture.newPsid) + .value should be >= ParticipantMetrics.LsuStatus.LocalCopyDone + } + } + + loggerFactory.assertEventuallyLogsSeq( + SuppressionRule.Level(Level.DEBUG) && SuppressionRule.forLogger[GrpcSynchronizerRegistry] + )( + sequencer2.topology.lsu.sequencer_successors.propose_successor( + sequencerId = sequencer2.id, + endpoints = sequencer4.sequencerConnection.endpoints.map(_.toURI(useTls = false)), + successorSynchronizerId = fixture.newPsid, + ), + entries => { + // All participants start the waiting + forExactly(3, entries)( + _.debugMessage should include("Handshake was successful. Starting to wait until") + ) + // P3 eventually stops waiting + forExactly(1, entries) { entry => + entry.debugMessage should include( + "Stopping the wait because max waiting time is reached." + ) + entry.loggerName should include("participant3") + } + }, + ) + + loggerFactory.assertLogsSeq( + SuppressionRule.Level(Level.DEBUG) && SuppressionRule.forLogger[GrpcSynchronizerRegistry] + )( + participant2.stop(), + forExactly(1, _) { entry => + entry.debugMessage should include("Stopping the wait because of shutdown.") + entry.loggerName should include("participant2") + }, + ) + + // Starting S4 should eventually allow P1 to handshake with S4 + sequencer4.start() + + eventually() { + getParticipantHandshakesMetricValues(sequencer4) + .get((participant1.id, "success")) + .value should be >= 1L + } + + getParticipantHandshakesMetricValues(sequencer4).get((p2Id, "success")) shouldBe empty + + getParticipantHandshakesMetricValues(sequencer4).get( + (participant3.id, "success") + ) shouldBe empty + } + } +} diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuIncorrectSequencerIdentityIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuIncorrectSequencerIdentityIntegrationTest.scala index 7b9f6d89ef..86038a4033 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuIncorrectSequencerIdentityIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuIncorrectSequencerIdentityIntegrationTest.scala @@ -135,6 +135,20 @@ final class LsuIncorrectSequencerIdentityIntegrationTest extends LsuBase { LogEntryOptionality.OptionalMany, _.shouldBeCantonErrorCode(SequencerError.NotAtUpgradeTimeOrBeyond), ), + // sequencer1 fails to contact/handshake its successor + ( + LogEntryOptionality.Required, + _.warningMessage should include( + s"Error when contacting successor: expecting sequencer id to be ${sequencer1.id} but found ${sequencer2.id}" + ), + ), + // sequencer2 fails to contact/handshake its successor + ( + LogEntryOptionality.Required, + _.warningMessage should include( + s"Error when contacting successor: expecting sequencer id to be ${sequencer2.id} but found ${sequencer1.id}" + ), + ), ) } } @@ -288,6 +302,13 @@ final class LsuSuccessorSequencerIsPredecessorIntegrationTest extends LsuBase { LogEntryOptionality.OptionalMany, _.shouldBeCantonErrorCode(SequencerError.NotAtUpgradeTimeOrBeyond), ), + // failed contact between sequencers and their successor + ( + LogEntryOptionality.OptionalMany, + _.warningMessage should include( + s"Error when contacting successor: expecting psid to be ${fixture.newPsid} but found ${fixture.currentPsid}" + ), + ), ) } } diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuMetricsIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuMetricsIntegrationTest.scala index 0f1a42ada0..6ebaff3b89 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuMetricsIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuMetricsIntegrationTest.scala @@ -85,7 +85,7 @@ final class LsuMetricsIntegrationTest extends LsuBase { .addConfigTransform( // We want to retry more aggressively in the test ConfigTransforms.updateAllParticipantConfigs_( - _.focus(_.parameters.lsu.handshakeRetry) + _.focus(_.parameters.lsu.handshake.retry) .replace( ExponentialBackoffConfig( initialDelay = config.NonNegativeFiniteDuration.ofMillis(100), diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuOfflinePartyReplicationIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuOfflinePartyReplicationIntegrationTest.scala index 46a58c9120..dc6a7f8b4b 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuOfflinePartyReplicationIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuOfflinePartyReplicationIntegrationTest.scala @@ -100,7 +100,7 @@ abstract class LsuOfflinePartyReplicationIntegrationTest extends LsuBase with Ha protected val acsSnapshotFile: TempFile = tempDirectory.toTempFile("offpr_test_acs_snapshot.gz") - protected def makeFixture1(implicit env: TestEnvironment): Fixture = Fixture( + protected def makeFixture1(implicit env: TestEnvironment[?]): Fixture = Fixture( currentPsid = env.daId, upgradeTime = upgradeTime1, oldSynchronizerNodes = SynchronizerNodes(Seq(env.sequencer1), Seq(env.mediator1)), @@ -111,16 +111,17 @@ abstract class LsuOfflinePartyReplicationIntegrationTest extends LsuBase with Ha newSerial = env.daId.serial.increment.toNonNegative, ) - protected def makeFixture2(fixture1: Fixture)(implicit env: TestEnvironment): Fixture = Fixture( - currentPsid = fixture1.newPsid, - upgradeTime = upgradeTime2, - oldSynchronizerNodes = fixture1.newSynchronizerNodes, - newSynchronizerNodes = SynchronizerNodes(Seq(env.sequencer3), Seq(env.mediator3)), - newOldNodesResolution = Map("sequencer3" -> "sequencer2", "mediator3" -> "mediator2"), - oldSynchronizerOwners = Set[InstanceReference](env.sequencer2, env.mediator2), - newPV = testedProtocolVersion, - newSerial = fixture1.newSerial.increment.toNonNegative, - ) + protected def makeFixture2(fixture1: Fixture)(implicit env: TestEnvironment[?]): Fixture = + Fixture( + currentPsid = fixture1.newPsid, + upgradeTime = upgradeTime2, + oldSynchronizerNodes = fixture1.newSynchronizerNodes, + newSynchronizerNodes = SynchronizerNodes(Seq(env.sequencer3), Seq(env.mediator3)), + newOldNodesResolution = Map("sequencer3" -> "sequencer2", "mediator3" -> "mediator2"), + oldSynchronizerOwners = Set[InstanceReference](env.sequencer2, env.mediator2), + newPV = testedProtocolVersion, + newSerial = fixture1.newSerial.increment.toNonNegative, + ) protected def assertParticipantHostsParty( participant: ParticipantReference, diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuPurgeStoresAfterLsuIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuPurgeStoresAfterLsuIntegrationTest.scala index 8310dece36..208cbbe94c 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuPurgeStoresAfterLsuIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuPurgeStoresAfterLsuIntegrationTest.scala @@ -15,6 +15,7 @@ import com.digitalasset.canton.integration.tests.examples.IouSyntax import com.digitalasset.canton.integration.util.TestUtils.waitForTargetTimeOnSequencer import com.digitalasset.canton.integration.{ConfigTransforms, EnvironmentDefinition} import com.digitalasset.canton.participant.config.PurgeConfig +import com.digitalasset.canton.topology.transaction.TopologyMapping import monocle.macros.syntax.lens.* import java.time.Duration @@ -62,6 +63,7 @@ final class LsuPurgeStoresAfterLsuIntegrationTest extends LsuBase { PurgeConfig().copy( chunkSize = PositiveInt.tryCreate(2), cron = "/5 * * * * ?", + purgeableStoresListValidity = config.NonNegativeFiniteDuration.ofSeconds(1), ) ) ) @@ -85,8 +87,9 @@ final class LsuPurgeStoresAfterLsuIntegrationTest extends LsuBase { performSynchronizerNodesLsu(fixture) - clue("old stores are not empty") { - oldTopologyStore.dumpStoreContent().futureValueUS.result should not be empty + val oldTopology = clue("old stores are not empty") { + val oldTopology = oldTopologyStore.dumpStoreContent().futureValueUS.result.toSet + oldTopology should not be empty participant1.underlying.value.sync.syncPersistentStateManager .get(fixture.currentPsid) @@ -94,6 +97,8 @@ final class LsuPurgeStoresAfterLsuIntegrationTest extends LsuBase { .submissionTrackerStore .size .futureValueUS shouldBe 1 + + oldTopology } environment.simClock.value.advanceTo(upgradeTime.immediateSuccessor) @@ -126,8 +131,16 @@ final class LsuPurgeStoresAfterLsuIntegrationTest extends LsuBase { .value .topologyStore - clue("new stores are not empty") { - newTopologyStore.dumpStoreContent().futureValueUS.result should not be empty + clue("new stores are not purged") { + newTopologyStore + .dumpStoreContent() + .futureValueUS + .result + .toSet shouldBe oldTopology + // LsuSequencerConnectionSuccessor is filtered out when doing the local copy + .filterNot( + _.mapping.code == TopologyMapping.Code.LsuSequencerConnectionSuccessor + ) participant1.underlying.value.sync.syncPersistentStateManager .get(fixture.newPsid) diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuReassignmentsIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuReassignmentsIntegrationTest.scala index a5e34e150a..7b970120e8 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuReassignmentsIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuReassignmentsIntegrationTest.scala @@ -52,7 +52,7 @@ final class LsuReassignmentsIntegrationTest extends LsuBase { NetworkBootstrapper(S1M1_S1M1) } .addConfigTransforms(configTransforms*) - .addConfigTransform(ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag) + .addConfigTransform(ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag) .withSetup { implicit env => defaultEnvironmentSetup() } diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuSanityCheckSuccessorSynchronizerIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuSanityCheckSuccessorSynchronizerIntegrationTest.scala index 6ee7f6a414..751f21c4a7 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuSanityCheckSuccessorSynchronizerIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuSanityCheckSuccessorSynchronizerIntegrationTest.scala @@ -19,6 +19,7 @@ import com.digitalasset.canton.integration.plugins.{ UseReferenceBlockSequencer, } import com.digitalasset.canton.integration.tests.upgrade.lsu.LsuBase.getLsuSequencingTestMetricValues +import com.digitalasset.canton.logging.SuppressingLogger.LogEntryOptionality import com.digitalasset.canton.logging.SuppressionRule import com.digitalasset.canton.metrics.{MetricsConfig, MetricsReporterConfig} import com.digitalasset.canton.synchronizer.sequencer.block.bftordering.core.modules.output.OutputModule @@ -101,21 +102,45 @@ sealed abstract class LsuSanityCheckSuccessorSynchronizerIntegrationTest extends m.get(sequencer3.id).value should be > 0L } - sequencer4.setup.test_lsu_sequencing(NonNegativeInt.zero) - sequencer4.setup.test_lsu_sequencing(NonNegativeInt.zero) - eventually() { - getLsuSequencingTestMetricValues(mediator3).get(sequencer4.id).value shouldBe 2 - getLsuSequencingTestMetricValues(mediator4).get(sequencer4.id).value shouldBe 2 - } - - // Check whether the command behaves well with a restart - sequencer4.stop() - sequencer4.start() - sequencer4.setup.test_lsu_sequencing(NonNegativeInt.zero) - eventually() { - getLsuSequencingTestMetricValues(mediator3).get(sequencer4.id).value shouldBe 3 - getLsuSequencingTestMetricValues(mediator4).get(sequencer4.id).value shouldBe 3 - } + mediator3.stop() + mediator3.start() + + loggerFactory.assertLogsUnorderedOptional( + { + sequencer4.setup.test_lsu_sequencing(NonNegativeInt.zero) + sequencer4.setup.test_lsu_sequencing(NonNegativeInt.zero) + eventually() { + getLsuSequencingTestMetricValues(mediator3).get(sequencer4.id).value shouldBe 2 + getLsuSequencingTestMetricValues(mediator4).get(sequencer4.id).value shouldBe 2 + } + + // Check whether the command behaves well with a restart + sequencer4.stop() + sequencer4.start() + sequencer4.setup.test_lsu_sequencing(NonNegativeInt.zero) + eventually() { + getLsuSequencingTestMetricValues(mediator3).get(sequencer4.id).value shouldBe 3 + getLsuSequencingTestMetricValues(mediator4).get(sequencer4.id).value shouldBe 3 + } + }, + // can happen if one mediator tries to ack when sequencer4 is stopped + ( + LogEntryOptionality.OptionalMany, + _.warningMessage should include("Failed to acknowledge clean timestamp"), + ), + ( + LogEntryOptionality.OptionalMany, + _.warningMessage should include( + "Is the server running? Did you configure the server address" + ), + ), + ( + LogEntryOptionality.Optional, + _.warningMessage should include( + "shutdown did not complete gracefully in allotted 3 seconds" + ), + ), + ) environment.simClock.value.advanceTo(upgradeTime.immediateSuccessor) diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuSequencerContactSuccessorIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuSequencerContactSuccessorIntegrationTest.scala index 8426e7fbe5..04a5e2805d 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuSequencerContactSuccessorIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuSequencerContactSuccessorIntegrationTest.scala @@ -7,7 +7,7 @@ import com.daml.metrics.api.MetricQualification import com.digitalasset.canton.UniquePortGenerator import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.integration.* -import com.digitalasset.canton.integration.EnvironmentDefinition.S1M1 +import com.digitalasset.canton.integration.EnvironmentDefinition.S2M2 import com.digitalasset.canton.integration.bootstrap.NetworkBootstrapper import com.digitalasset.canton.integration.plugins.UseReferenceBlockSequencer.MultiSynchronizer import com.digitalasset.canton.integration.plugins.{UseBftSequencer, UsePostgres} @@ -16,11 +16,16 @@ import com.digitalasset.canton.integration.tests.upgrade.lsu.LsuBase.{ getLsuSuccessorContactStatusMetricValues, } import com.digitalasset.canton.integration.util.TestUtils.waitForTargetTimeOnSequencer +import com.digitalasset.canton.logging.{LogEntry, SuppressionRule} import com.digitalasset.canton.metrics.{MetricsConfig, MetricsReporterConfig} import monocle.macros.syntax.lens.* +import org.slf4j.event.Level import java.time.Duration +/** Each sequencer attempts to contact its successor when it processes its own successor + * announcement. Upon successful contact, a metric should be changed. + */ final class LsuSequencerContactSuccessorIntegrationTest extends LsuBase { override protected def testName: String = "lsu_sequencer_contact_successor" @@ -28,23 +33,27 @@ final class LsuSequencerContactSuccessorIntegrationTest extends LsuBase { registerPlugin( new UseBftSequencer( loggerFactory, - MultiSynchronizer.tryCreate(Set("sequencer1"), Set("sequencer2")), + MultiSynchronizer.tryCreate(Set("sequencer1", "sequencer2"), Set("sequencer3", "sequencer4")), ) ) registerPlugin(new UsePostgres(loggerFactory)) override protected lazy val newOldSequencers: Map[String, String] = Map( - "sequencer2" -> "sequencer1" + "sequencer3" -> "sequencer1", + "sequencer4" -> "sequencer2", + ) + override protected lazy val newOldMediators: Map[String, String] = Map( + "mediator3" -> "mediator1", + "mediator4" -> "mediator2", ) - override protected lazy val newOldMediators: Map[String, String] = Map("mediator2" -> "mediator1") override protected lazy val upgradeTime: CantonTimestamp = CantonTimestamp.Epoch.plusSeconds(30) override lazy val environmentDefinition: EnvironmentDefinition = - EnvironmentDefinition.P2S2M2_Config + EnvironmentDefinition.P2S4M4_Config .withNetworkBootstrap { implicit env => - new NetworkBootstrapper(S1M1) + new NetworkBootstrapper(S2M2) } .addConfigTransforms(configTransforms*) .addConfigTransforms( @@ -66,8 +75,11 @@ final class LsuSequencerContactSuccessorIntegrationTest extends LsuBase { private var fixture: Fixture = _ + private lazy val handshakeFailureWarn = + s"Unable to perform handshake with ${fixture.newPsid}" + "Sequencers" should { - "contact their successor and update metrics to reflect status" in { implicit env => + "have contact metrics set to 0 initially" in { implicit env => import env.* fixture = fixtureWithDefaults() @@ -84,11 +96,108 @@ final class LsuSequencerContactSuccessorIntegrationTest extends LsuBase { eventually() { getLsuSuccessorContactStatusMetricValues(sequencer1) shouldBe Map(fixture.newPsid -> 0) } + } + + "not update the metric if psid is incorrect" in { implicit env => + import env.* + + // Incorrect announcement of the successor: wrong psid + loggerFactory.assertEventuallyLogsSeq(SuppressionRule.Level(Level.WARN))( + sequencer1.topology.lsu.sequencer_successors.propose_successor( + sequencerId = sequencer1.id, + // announced successor is itself -> wrong psid + endpoints = sequencer1.sequencerConnection.endpoints.map(_.toURI(useTls = false)), + successorSynchronizerId = fixture.newPsid, + ), + LogEntry.assertLogSeq( + Seq( + ( + _.warningMessage should include( + s"Error when contacting successor: expecting psid to be ${fixture.newPsid} but found ${fixture.currentPsid}" + ), + "warning on sequencer", + ), + ( + _.warningMessage should include( + "Connection internal-sequencer-connection-sequencer1-0: Invalid synchronizer" + ), + "connection pool warn on p1", + ), + ( + _.warningMessage should include( + "Connection internal-sequencer-connection-sequencer1-0: Invalid synchronizer" + ), + "connection pool warn on p2", + ), + ( + _.warningMessage should include(handshakeFailureWarn), + "handshake failure on p1", + ), + ( + _.warningMessage should include(handshakeFailureWarn), + "handshake failure on p2", + ), + ) + ), + ) + + // metric is not updated + getLsuSuccessorContactStatusMetricValues(sequencer1) shouldBe Map(fixture.newPsid -> 0) + } + + "not update the metric if sequencer id is incorrect" in { implicit env => + import env.* + + // Incorrect announcement of the successor: wrong sequencer id + loggerFactory.assertEventuallyLogsSeq(SuppressionRule.Level(Level.WARN))( + sequencer1.topology.lsu.sequencer_successors.propose_successor( + sequencerId = sequencer1.id, + // announced successor is another sequencer -> wrong sequencer id + endpoints = sequencer4.sequencerConnection.endpoints.map(_.toURI(useTls = false)), + successorSynchronizerId = fixture.newPsid, + ), + LogEntry.assertLogSeq( + Seq( + ( + _.warningMessage should include( + s"Error when contacting successor: expecting sequencer id to be ${sequencer1.id} but found ${sequencer2.id}" + ), + "warning on sequencer", + ), + ( + _.warningMessage should include( + "Validation failure: Connection is not on expected sequencer" + ), + "connection pool warn on p1", + ), + ( + _.warningMessage should include( + "Validation failure: Connection is not on expected sequencer" + ), + "connection pool warn on p2", + ), + ( + _.warningMessage should include(handshakeFailureWarn), + "handshake failure on p1", + ), + ( + _.warningMessage should include(handshakeFailureWarn), + "handshake failure on p2", + ), + ) + ), + ) + + // metric is not updated + getLsuSuccessorContactStatusMetricValues(sequencer1) shouldBe Map(fixture.newPsid -> 0) + } + + "update the metric when the successor is correct" in { implicit env => + import env.* - // Announcement of the successor sequencer1.topology.lsu.sequencer_successors.propose_successor( sequencerId = sequencer1.id, - endpoints = sequencer2.sequencerConnection.endpoints.map(_.toURI(useTls = false)), + endpoints = sequencer3.sequencerConnection.endpoints.map(_.toURI(useTls = false)), successorSynchronizerId = fixture.newPsid, ) @@ -103,7 +212,7 @@ final class LsuSequencerContactSuccessorIntegrationTest extends LsuBase { participants.all.forall(_.synchronizers.is_connected(fixture.newPsid)) shouldBe true } - waitForTargetTimeOnSequencer(sequencer2, environment.clock.now, logger) + waitForTargetTimeOnSequencer(sequencer3, environment.clock.now, logger) oldSynchronizerNodes.all.stop() participant1.health.ping(participant2) @@ -116,22 +225,22 @@ final class LsuSequencerContactSuccessorIntegrationTest extends LsuBase { val upgradeTime2 = fixture.upgradeTime.plusSeconds(30) val upgradeTime3 = upgradeTime2.plusSeconds(30) - sequencer2.topology.lsu.announcement.propose(psid2, upgradeTime2) + sequencer3.topology.lsu.announcement.propose(psid2, upgradeTime2) eventually() { - getLsuSuccessorContactStatusMetricValues(sequencer2) shouldBe Map(psid2 -> 0) + getLsuSuccessorContactStatusMetricValues(sequencer3) shouldBe Map(psid2 -> 0) } - sequencer2.topology.lsu.announcement.propose(psid3, upgradeTime3) + sequencer3.topology.lsu.announcement.propose(psid3, upgradeTime3) eventually() { - getLsuSuccessorContactStatusMetricValues(sequencer2) shouldBe Map(psid2 -> 0, psid3 -> 0) + getLsuSuccessorContactStatusMetricValues(sequencer3) shouldBe Map(psid2 -> 0, psid3 -> 0) } - sequencer2.topology.lsu.announcement.revoke(psid3, upgradeTime3) + sequencer3.topology.lsu.announcement.revoke(psid3, upgradeTime3) eventually() { - getLsuSuccessorContactStatusMetricValues(sequencer2) shouldBe Map(psid2 -> 0, psid3 -> -1) + getLsuSuccessorContactStatusMetricValues(sequencer3) shouldBe Map(psid2 -> 0, psid3 -> -1) } } } diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuSessionSigningKeysIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuSessionSigningKeysIntegrationTest.scala index e1f4cb1bf5..f2aa2ef39c 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuSessionSigningKeysIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuSessionSigningKeysIntegrationTest.scala @@ -53,7 +53,7 @@ final class LsuSessionSigningKeysIntegrationTest override protected def configTransforms: Seq[ConfigTransform] = super.configTransforms :+ ConfigTransforms.setSigningKeysIfPV35OrHigher( - SessionSigningKeysConfig.default + SessionSigningKeysConfig.enabled ) override lazy val environmentDefinition: EnvironmentDefinition = EnvironmentDefinition.P1S2M2_Config diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuTrafficTransferRestartIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuTrafficTransferRestartIntegrationTest.scala index 023278f24f..bc76f6a23d 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuTrafficTransferRestartIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/upgrade/lsu/LsuTrafficTransferRestartIntegrationTest.scala @@ -25,7 +25,8 @@ import java.time.Duration * * Topology: * - P1 connected to S1 - * - P2 connected to S2 + * - P2 connected to S1 and S2 with threshold=2 + * - P3 connected to S2, offboarded before the LSU * * This test: * - Generates purchased/consumed traffic by performing some activity on the predecessor @@ -74,7 +75,9 @@ final class LsuTrafficTransferRestartIntegrationTest extends LsuBase with Traffi changeDynamicSynchronizerParameters = false, ) participant1.synchronizers.connect_by_config(synchronizerConnectionConfig(sequencer1)) - participant2.synchronizers.connect_by_config(synchronizerConnectionConfig(sequencer2)) + participant2.synchronizers.connect_by_config( + synchronizerConnectionConfig(Seq(sequencer1, sequencer2), 2) + ) participant3.synchronizers.connect_by_config(synchronizerConnectionConfig(sequencer2)) participants.all.dars.upload(CantonExamplesPath) @@ -143,6 +146,11 @@ final class LsuTrafficTransferRestartIntegrationTest extends LsuBase with Traffi } } + /* + Regression test. + This ensures that checks about the number of traffic entries are consistent + between export and import. + */ "offboard participant3" in { implicit env => import env.* diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/version/MultipleProtocolVersionReassignmentIntegrationTest.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/version/MultipleProtocolVersionReassignmentIntegrationTest.scala index ac03f18445..d3c27a9299 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/version/MultipleProtocolVersionReassignmentIntegrationTest.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/tests/version/MultipleProtocolVersionReassignmentIntegrationTest.scala @@ -51,7 +51,7 @@ sealed trait MultipleProtocolVersionReassignmentIntegrationTest _.focus(_.parameters.minimumProtocolVersion) .replace(Some(ParticipantProtocolVersion(beforeLastStable))) }, - ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag, + ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag, ) .addConfigTransforms(ConfigTransforms.dontWarnOnDeprecatedPV*) .withSetup { implicit env => diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/util/PartyToParticipantDeclarative.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/util/PartyToParticipantDeclarative.scala index 43ab712b3f..a54dd65974 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/util/PartyToParticipantDeclarative.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/util/PartyToParticipantDeclarative.scala @@ -171,7 +171,7 @@ class PartyToParticipantDeclarative( Map[PhysicalSynchronizerId, PartyHostingState], ], onboarding: Boolean, -)(implicit executionContext: ExecutionContext, env: TestEnvironment) +)(implicit executionContext: ExecutionContext, env: TestEnvironment[?]) extends PartyToParticipantDeclarativeCommon[Party] { override protected def partyReference: (PartyToParticipant, HashingSchemeVersion) => Party = @@ -417,7 +417,7 @@ object PartyToParticipantDeclarative { ], forceFlags: ForceFlags = ForceFlags.none, onboarding: Boolean = false, // participants added in target topology are marked as onboarding - )(implicit executionContext: ExecutionContext, env: TestEnvironment): Unit = { + )(implicit executionContext: ExecutionContext, env: CantonTestEnvironment): Unit = { val participantReference = participants.headOption.getOrElse( fail("No participant set in PartyToParticipantDeclarative") ) @@ -466,7 +466,7 @@ object PartyToParticipantDeclarative { threshold: PositiveInt, hosting: Set[(ParticipantId, ParticipantPermission)], forceFlags: ForceFlags = ForceFlags.none, - )(implicit executionContext: ExecutionContext, env: TestEnvironment): Unit = + )(implicit executionContext: ExecutionContext, env: CantonTestEnvironment): Unit = apply(participants, Set(synchronizerId))( Map(party.partyId -> owningParticipant), Map(party -> Map(synchronizerId -> (threshold, hosting))), @@ -496,7 +496,7 @@ class PartiesAllocator( )( newParties: Seq[(String, ParticipantId)], val targetTopology: Map[String, Map[PhysicalSynchronizerId, PartyHostingState]], -)(implicit executionContext: ExecutionContext, env: TestEnvironment) +)(implicit executionContext: ExecutionContext, env: TestEnvironment[?]) extends PartyToParticipantDeclarativeCommon[String] { override def externalParties: Set[ExternalParty] = Set.empty @@ -631,7 +631,7 @@ object PartiesAllocator { ], )(implicit executionContext: ExecutionContext, - env: TestEnvironment, + env: TestEnvironment[?], partyKind: PartyKind, ): Seq[Party] = new PartiesAllocator(participants)( diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/util/TestSubmissionService.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/util/TestSubmissionService.scala index 9bed31e4e3..015dd54c3c 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/integration/util/TestSubmissionService.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/integration/util/TestSubmissionService.scala @@ -355,7 +355,7 @@ class TestSubmissionService( response: Response = contractOpt match { case Some(contract) => - Response.ContractFound(contract, Hash.HashingMethod.UpgradeFriendly, _ => true) + Response.ContractFound(contract, Hash.HashingMethod.TypedNormalForm, _ => true) case None => Response.ContractNotFound } @@ -378,7 +378,22 @@ class TestSubmissionService( for { cidO <- keyResolver.resolveKey(key)(traceContext) contracts <- cidO.toList.parTraverse(contractResolver(_)(traceContext)) - r <- resolve(resume(contracts.flatten.toVector, NeedKeyProgression.Finished)) + r <- resolve( + resume( + ResultNeedKey.Response( + contracts.flatten + .map(fci => + ResultNeedKey.Response.AuthenticableFatContractInstance( + fci, + Hash.HashingMethod.TypedNormalForm, + _ => true, + ) + ) + .toVector, + NeedKeyProgression.Finished, + ) + ) + ) } yield r case ResultInterruption(continue, _) => diff --git a/canton/community/app/src/test/scala/com/digitalasset/canton/util/ReleaseUtils.scala b/canton/community/app/src/test/scala/com/digitalasset/canton/util/ReleaseUtils.scala index 2eda2caf52..3bfce40700 100644 --- a/canton/community/app/src/test/scala/com/digitalasset/canton/util/ReleaseUtils.scala +++ b/canton/community/app/src/test/scala/com/digitalasset/canton/util/ReleaseUtils.scala @@ -15,9 +15,11 @@ import com.digitalasset.canton.version.{ ProtocolVersionCompatibility, ReleaseVersion, } +import org.scalatest.time.SpanSugar.convertIntToGrainOfTime import java.nio.file.{Files, Paths} import scala.collection.concurrent.TrieMap +import scala.concurrent.duration.FiniteDuration import scala.concurrent.{ExecutionContext, Future} /** A collection of small utilities for tests that have no obvious home */ @@ -119,6 +121,8 @@ object ReleaseUtils { private val releasesRetrieval: TrieMap[ReleaseVersion, Future[String]] = TrieMap.empty private val lock = new Mutex() + val DefaultReleaseDownloadTimeout: FiniteDuration = 5.minutes + /** If the .tar.gz corresponding to release is not found locally, attempts to download it from * artifactory. Then, extract the .tar.gz file. * @param release diff --git a/canton/community/base/src/main/protobuf/com/digitalasset/canton/protocol/v30/topology.proto b/canton/community/base/src/main/protobuf/com/digitalasset/canton/protocol/v30/topology.proto index ffe2febce4..3a9cfeda96 100644 --- a/canton/community/base/src/main/protobuf/com/digitalasset/canton/protocol/v30/topology.proto +++ b/canton/community/base/src/main/protobuf/com/digitalasset/canton/protocol/v30/topology.proto @@ -67,7 +67,7 @@ message Enums { // in model conformance in PV 33 PARTICIPANT_FEATURE_FLAG_PV33_EXTERNAL_SIGNING_LOCAL_CONTRACT_IN_SUBVIEW = 1; // This flag indicates that the participant supports reassignments between synchronizers. - PARTICIPANT_FEATURE_FLAG_ENABLE_ALPHA_MULTI_SYNCHRONIZER = 2; + PARTICIPANT_FEATURE_FLAG_ENABLE_MULTI_SYNCHRONIZER = 2; } } diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/config/AuthServiceConfig.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/config/AuthServiceConfig.scala index eeaedaf311..eeed16b3af 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/config/AuthServiceConfig.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/config/AuthServiceConfig.scala @@ -153,6 +153,7 @@ object AuthServiceConfig { override val privileged: Boolean = false, accessLevel: AccessLevel = AccessLevel.Wildcard, override val users: Seq[AuthorizedUser] = Seq.empty, + override val maxTokenLife: config.NonNegativeDuration = NonNegativeDuration(Duration.Inf), ) extends AuthServiceConfig { @SuppressWarnings(Array("org.wartremover.warts.Null")) private def verifier( @@ -198,6 +199,7 @@ object AuthServiceConfig { override val privileged: Boolean = false, accessLevel: AccessLevel = AccessLevel.Wildcard, override val users: Seq[AuthorizedUser] = Seq.empty, + override val maxTokenLife: config.NonNegativeDuration = NonNegativeDuration(Duration.Inf), ) extends AuthServiceConfig { @SuppressWarnings(Array("org.wartremover.warts.Null")) private def verifier( @@ -243,6 +245,7 @@ object AuthServiceConfig { override val privileged: Boolean = false, accessLevel: AccessLevel = AccessLevel.Wildcard, override val users: Seq[AuthorizedUser] = Seq.empty, + override val maxTokenLife: config.NonNegativeDuration = NonNegativeDuration(Duration.Inf), ) extends AuthServiceConfig { private def verifier( jwksCacheConfig: JwksCacheConfig, diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/config/BaseCantonConfig.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/config/BaseCantonConfig.scala index 1f9cfbfb0d..f57273748a 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/config/BaseCantonConfig.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/config/BaseCantonConfig.scala @@ -31,6 +31,9 @@ object BaseCantonConfig { lazy implicit final val connectionAllocationReader: ConfigReader[ConnectionAllocation] = deriveReader[ConnectionAllocation] + lazy implicit final val partitionConfigReader: ConfigReader[PartitionConfig] = + deriveReader[PartitionConfig] + lazy implicit final val dbParamsReader: ConfigReader[DbParametersConfig] = deriveReader[DbParametersConfig] @@ -57,6 +60,9 @@ object BaseCantonConfig { lazy implicit final val connectionAllocationWriter: ConfigWriter[ConnectionAllocation] = deriveWriter[ConnectionAllocation] + lazy implicit final val partitionConfigWriter: ConfigWriter[PartitionConfig] = + deriveWriter[PartitionConfig] + lazy implicit final val dbParamsWriter: ConfigWriter[DbParametersConfig] = deriveWriter[DbParametersConfig] diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/config/SessionSigningKeysConfig.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/config/SessionSigningKeysConfig.scala index db71c66ba7..68fd51ac1f 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/config/SessionSigningKeysConfig.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/config/SessionSigningKeysConfig.scala @@ -97,7 +97,7 @@ final case class SessionSigningKeysConfig( object SessionSigningKeysConfig { val disabled: SessionSigningKeysConfig = SessionSigningKeysConfig(enabled = false) - val default: SessionSigningKeysConfig = SessionSigningKeysConfig(enabled = true) + val enabled: SessionSigningKeysConfig = SessionSigningKeysConfig(enabled = true) /** Short test-only configuration: durations are small enough to trigger key rotation and validity * edge cases within a test. diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/config/StorageConfig.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/config/StorageConfig.scala index e263efee74..8b602ff0e7 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/config/StorageConfig.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/config/StorageConfig.scala @@ -73,6 +73,7 @@ final case class DbParametersConfig( // Make the default settings a part of repeatable migrations repeatableMigrationsPaths: Seq[String] = Seq.empty, + partitions: PartitionConfig = PartitionConfig(), ) extends PrettyPrinting { override protected def pretty: Pretty[DbParametersConfig] = prettyOfClass( @@ -203,6 +204,13 @@ final case class ConnectionAllocation( ) } +/** @param initialBftOrdererTablesPartitionSize + * Initial partition size for bft-orderer tables. Note that this config is only read once, during + * the initial database setup and later changes to this value won't have any effect. This is also + * only used in Postgres setups. + */ +final case class PartitionConfig(initialBftOrdererTablesPartitionSize: Int = 1500) + object DbParametersConfig { private val defaultWarnOnSlowQueryInterval: PositiveFiniteDuration = diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/config/TestingConfigInternal.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/config/TestingConfigInternal.scala index 84a27d9162..f7df89d3c9 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/config/TestingConfigInternal.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/config/TestingConfigInternal.scala @@ -58,9 +58,10 @@ import com.digitalasset.canton.metrics.MetricsFactoryType.External * @param useLegacyContractIdVersionV11 * Uses contract id version V11 for testing purposes. * @param warnOnJwtScopeUsage - * When true, we log a warning on the first time a JWT token with scope is sent to the Ledger API - * This pattern is currently discouraged for security reasons but will be re-enabled in future - * versions. + * When true, we log a warning on the first time a JWT with a scope but no audience is sent to + * the Ledger API, provided that no explicit targetAudience or targetScope is configured. (In + * other cases, other appropriate warnings are raised on startup if needed). Such tokens are + * currently discouraged for security reasons and will be removed from use in future versions. */ final case class TestingConfigInternal( testSequencerClientFor: Set[TestSequencerClientFor] = Set.empty, diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/CryptoApi.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/CryptoApi.scala index 5cce8ef7ac..3b1d6377eb 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/CryptoApi.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/CryptoApi.scala @@ -44,6 +44,7 @@ import com.digitalasset.canton.health.{ import com.digitalasset.canton.lifecycle.{FutureUnlessShutdown, LifeCycle} import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} +import com.digitalasset.canton.metrics.CryptoMetrics import com.digitalasset.canton.protocol.StaticSynchronizerParameters import com.digitalasset.canton.replica.ReplicaManager import com.digitalasset.canton.resource.Storage @@ -76,6 +77,7 @@ sealed trait BaseCrypto extends NamedLogging { def privateCrypto: CryptoPrivateApi def cryptoPrivateStore: CryptoPrivateStore def cryptoPublicStore: CryptoPublicStore + def cryptoMetrics: CryptoMetrics /** Helper method to generate a new signing key pair and store the public key in the public store * as well. @@ -116,6 +118,7 @@ class Crypto private[crypto] ( override val privateCrypto: CryptoPrivateApi, override val cryptoPrivateStore: CryptoPrivateStore, override val cryptoPublicStore: CryptoPublicStore, + override val cryptoMetrics: CryptoMetrics, override val timeouts: ProcessingTimeout, override val loggerFactory: NamedLoggerFactory, )(override implicit val ec: ExecutionContext) @@ -155,12 +158,15 @@ final case class SynchronizerCrypto( new SynchronizerCryptoPrivateApi( staticSynchronizerParameters, crypto.privateCrypto, + crypto.cryptoMetrics.signingMetrics, + crypto.cryptoMetrics.decryptionMetrics, crypto.timeouts, crypto.loggerFactory, ) override val cryptoPrivateStore: CryptoPrivateStore = crypto.cryptoPrivateStore override val cryptoPublicStore: CryptoPublicStore = crypto.cryptoPublicStore + override val cryptoMetrics: CryptoMetrics = crypto.cryptoMetrics override protected val loggerFactory: NamedLoggerFactory = crypto.loggerFactory } @@ -385,6 +391,7 @@ object Crypto { releaseProtocolVersion: ReleaseProtocolVersion, futureSupervisor: FutureSupervisor, clock: Clock, + cryptoMetrics: CryptoMetrics, executionContext: ExecutionContext, timeouts: ProcessingTimeout, batchingConfig: BatchingConfig, @@ -431,6 +438,7 @@ object Crypto { publicKeyConversionCacheConfig, cryptoPrivateStore, cryptoPublicStore, + cryptoMetrics, timeouts, loggerFactory, ) @@ -478,6 +486,7 @@ object Crypto { kmsSchemes.encryptionSchemes, cryptoPublicStore, kmsCryptoPrivateStore, + cryptoMetrics, timeouts, loggerFactory, ) @@ -488,6 +497,7 @@ object Crypto { sessionEncryptionKeyCacheConfig, publicKeyConversionCacheConfig, cryptoSchemes, + cryptoMetrics, loggerFactory, ) .toEitherT[FutureUnlessShutdown] @@ -496,6 +506,7 @@ object Crypto { kmsPrivateCrypto, kmsCryptoPrivateStore, cryptoPublicStore, + cryptoMetrics, timeouts, loggerFactory, ) diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/Encryption.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/Encryption.scala index c496c6e69e..b0ffe0ec01 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/Encryption.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/Encryption.scala @@ -16,6 +16,7 @@ import com.digitalasset.canton.crypto.store.{CryptoPrivateStoreError, CryptoPriv import com.digitalasset.canton.error.{CantonBaseError, CantonErrorGroups} import com.digitalasset.canton.lifecycle.FutureUnlessShutdown import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} +import com.digitalasset.canton.metrics.DecryptionMetrics import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult import com.digitalasset.canton.serialization.{ CryptoParseAndValidationError, @@ -36,14 +37,7 @@ import scala.concurrent.ExecutionContext /** Encryption operations that do not require access to a private key store but operates with * provided keys. */ -trait EncryptionOps { - - private[crypto] def decryptWithInternal[M]( - encrypted: AsymmetricEncrypted[M], - privateKey: EncryptionPrivateKey, - )( - deserialize: ByteString => Either[DeserializationError, M] - ): Either[DecryptionError, M] +trait EncryptionOps extends DecryptionMetricsSupport { def defaultSymmetricKeyScheme: SymmetricKeyScheme @@ -76,11 +70,6 @@ trait EncryptionOps { encryptionAlgorithmSpec: EncryptionAlgorithmSpec = encryptionAlgorithmSpecs.default, )(implicit traceContext: TraceContext): Either[EncryptionError, AsymmetricEncrypted[M]] - /** Decrypts a message encrypted using `encryptWith` */ - def decryptWith[M](encrypted: AsymmetricEncrypted[M], privateKey: EncryptionPrivateKey)( - deserialize: ByteString => Either[DeserializationError, M] - ): Either[DecryptionError, M] = decryptWithInternal(encrypted, privateKey)(deserialize) - /** Encrypts the bytes of the serialized message using the given symmetric key. Where the message * embedded protocol version determines the message serialization. */ @@ -97,6 +86,24 @@ trait EncryptionOps { symmetricKey: SymmetricKey, ): Either[EncryptionError, ByteString] + /** Decrypts a message encrypted using `encryptWith`. Records latency for the decryption + * operation. + */ + def decryptWith[M](encrypted: AsymmetricEncrypted[M], privateKey: EncryptionPrivateKey)( + deserialize: ByteString => Either[DeserializationError, M] + ): Either[DecryptionError, M] = + decryptionMetrics.decryptLatency.time(decryptWithInternal(encrypted, privateKey)(deserialize)) + + /** Internal decryption primitive implemented by concrete backends. This bypasses higher-level + * wrappers (e.g. metrics and validation) and should only be used by internal decryption logic. + */ + private[crypto] def decryptWithInternal[M]( + encrypted: AsymmetricEncrypted[M], + privateKey: EncryptionPrivateKey, + )( + deserialize: ByteString => Either[DeserializationError, M] + ): Either[DecryptionError, M] + /** Decrypts a message encrypted using `encryptWith` */ def decryptWith[M](encrypted: Encrypted[M], symmetricKey: SymmetricKey)( deserialize: ByteString => Either[DeserializationError, M] @@ -105,17 +112,10 @@ trait EncryptionOps { } /** Encryption operations that require access to stored private keys. */ -trait EncryptionPrivateOps { +trait EncryptionPrivateOps extends DecryptionMetricsSupport { def encryptionSchemes: EncryptionCryptoSchemes - /** Decrypts an encrypted message using the referenced private encryption key */ - def decrypt[M](encrypted: AsymmetricEncrypted[M])( - deserialize: ByteString => Either[DeserializationError, M] - )(implicit - traceContext: TraceContext - ): EitherT[FutureUnlessShutdown, DecryptionError, M] - /** Generates a new encryption key pair with the given scheme and optional name, stores the * private key and returns the public key. */ @@ -125,6 +125,29 @@ trait EncryptionPrivateOps { )(implicit traceContext: TraceContext ): EitherT[FutureUnlessShutdown, EncryptionKeyGenerationError, EncryptionPublicKey] + + /** Decrypts an encrypted message using the referenced private encryption key. Records latency for + * the decryption operation. + */ + def decrypt[M](encrypted: AsymmetricEncrypted[M])( + deserialize: ByteString => Either[DeserializationError, M] + )(implicit + executionContext: ExecutionContext, + traceContext: TraceContext, + ): EitherT[FutureUnlessShutdown, DecryptionError, M] = + EitherTUtil.timed(decryptionMetrics.decryptLatency)( + decryptInternal(encrypted)(deserialize) + ) + + /** Internal decryption primitive implemented by concrete backends. This bypasses higher-level + * wrappers (e.g. metrics and validation) and should only be used by internal decryption logic. + */ + private[crypto] def decryptInternal[M](encrypted: AsymmetricEncrypted[M])( + deserialize: ByteString => Either[DeserializationError, M] + )(implicit + traceContext: TraceContext + ): EitherT[FutureUnlessShutdown, DecryptionError, M] + } /** A default implementation with a private key store */ @@ -137,7 +160,7 @@ trait EncryptionPrivateStoreOps extends EncryptionPrivateOps { protected val encryptionOps: EncryptionOps /** Decrypts an encrypted message using the referenced private encryption key */ - override def decrypt[M](encryptedMessage: AsymmetricEncrypted[M])( + override private[crypto] def decryptInternal[M](encryptedMessage: AsymmetricEncrypted[M])( deserialize: ByteString => Either[DeserializationError, M] )(implicit tc: TraceContext): EitherT[FutureUnlessShutdown, DecryptionError, M] = store @@ -170,6 +193,11 @@ trait EncryptionPrivateStoreOps extends EncryptionPrivateOps { } +/** Provides decryption-related metrics. */ +trait DecryptionMetricsSupport { + def decryptionMetrics: DecryptionMetrics +} + /** A tag to denote encrypted data. */ final case class Encrypted[+M] private[crypto] (ciphertext: ByteString) diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/Signing.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/Signing.scala index bb612bd011..4ddacd2401 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/Signing.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/Signing.scala @@ -23,6 +23,7 @@ import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.error.{CantonBaseError, CantonErrorGroups} import com.digitalasset.canton.lifecycle.FutureUnlessShutdown import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} +import com.digitalasset.canton.metrics.SigningMetrics import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult import com.digitalasset.canton.serialization.{ CryptoParseAndValidationError, @@ -33,7 +34,7 @@ import com.digitalasset.canton.serialization.{ import com.digitalasset.canton.store.db.DbDeserializationException import com.digitalasset.canton.topology.{Member, SynchronizerId} import com.digitalasset.canton.tracing.TraceContext -import com.digitalasset.canton.util.EitherUtil +import com.digitalasset.canton.util.{EitherTUtil, EitherUtil} import com.digitalasset.canton.version.* import com.google.common.annotations.VisibleForTesting import com.google.protobuf.ByteString @@ -50,7 +51,7 @@ import scala.concurrent.ExecutionContext /** Signing operations that do not require access to a private key store but operates with provided * keys. */ -trait SigningOps { +trait SigningOps extends SigningMetricsSupport { def signatureVerificationParallelism: PositiveInt @@ -69,14 +70,32 @@ trait SigningOps { usage: NonEmpty[Set[SigningKeyUsage]], signingAlgorithmSpec: SigningAlgorithmSpec = signingAlgorithmSpecs.default, )(implicit traceContext: TraceContext): Either[SigningError, Signature] = - signBytes(hash.getCryptographicEvidence, signingKey, usage, signingAlgorithmSpec) + signingMetrics.signingLatency.time( + signBytesInternal(hash.getCryptographicEvidence, signingKey, usage, signingAlgorithmSpec) + ) - /** Preferably, we sign a hash; however, we also allow signing arbitrary bytes when necessary. */ + /** Signs raw bytes using the private signing key. Convenience wrapper used when signing + * non-hashed data. + */ protected[crypto] def signBytes( bytes: ByteString, signingKey: SigningPrivateKey, usage: NonEmpty[Set[SigningKeyUsage]], signingAlgorithmSpec: SigningAlgorithmSpec = signingAlgorithmSpecs.default, + )(implicit traceContext: TraceContext): Either[SigningError, Signature] = + signingMetrics.signingLatency.time( + signBytesInternal(bytes, signingKey, usage, signingAlgorithmSpec) + ) + + /** Internal signing primitive implemented by concrete backends. Performs the actual cryptographic + * signing of raw bytes. This bypasses higher-level wrappers (e.g. metrics and validation) and + * should only be used by internal signing logic. + */ + private[crypto] def signBytesInternal( + bytes: ByteString, + signingKey: SigningPrivateKey, + usage: NonEmpty[Set[SigningKeyUsage]], + signingAlgorithmSpec: SigningAlgorithmSpec = signingAlgorithmSpecs.default, )(implicit traceContext: TraceContext): Either[SigningError, Signature] /** Confirms if the provided signature is a valid signature of the payload using the public key */ @@ -97,27 +116,51 @@ trait SigningOps { } /** Signing operations that require access to stored private keys. */ -trait SigningPrivateOps { +trait SigningPrivateOps extends SigningMetricsSupport { def signingSchemes: SigningCryptoSchemes - /** Signs the given hash using the referenced private signing key. */ + /** Signs the given hash using the referenced private signing key. Latency of the signing + * operation is recorded for all outcomes (successful signatures and signing failures). + */ def sign( hash: Hash, signingKeyId: Fingerprint, usage: NonEmpty[Set[SigningKeyUsage]], signingAlgorithmSpec: SigningAlgorithmSpec = signingSchemes.algorithmSpecs.default, )(implicit - tc: TraceContext + ec: ExecutionContext, + tc: TraceContext, ): EitherT[FutureUnlessShutdown, SigningError, Signature] = - signBytes(hash.getCryptographicEvidence, signingKeyId, usage, signingAlgorithmSpec) + EitherTUtil.timed(signingMetrics.signingLatency)( + signBytesInternal(hash.getCryptographicEvidence, signingKeyId, usage, signingAlgorithmSpec) + ) - /** Signs the byte string directly, however it is encouraged to sign a hash. */ + /** Signs the byte string directly, however it is encouraged to sign a hash. Latency of the + * signing operation is recorded for all outcomes (successful signatures and signing failures). + */ def signBytes( bytes: ByteString, signingKeyId: Fingerprint, usage: NonEmpty[Set[SigningKeyUsage]], signingAlgorithmSpec: SigningAlgorithmSpec = signingSchemes.algorithmSpecs.default, + )(implicit + ec: ExecutionContext, + tc: TraceContext, + ): EitherT[FutureUnlessShutdown, SigningError, Signature] = + EitherTUtil.timed(signingMetrics.signingLatency)( + signBytesInternal(bytes, signingKeyId, usage, signingAlgorithmSpec) + ) + + /** Internal signing primitive that produces a signature for the given bytes. This bypasses + * higher-level wrappers (e.g. metrics and validation) and should only be used by internal + * signing logic. + */ + private[crypto] def signBytesInternal( + bytes: ByteString, + signingKeyId: Fingerprint, + usage: NonEmpty[Set[SigningKeyUsage]], + signingAlgorithmSpec: SigningAlgorithmSpec = signingSchemes.algorithmSpecs.default, )(implicit tc: TraceContext): EitherT[FutureUnlessShutdown, SigningError, Signature] /** Generates a new signing key pair with the given scheme and optional name, stores the private @@ -133,6 +176,11 @@ trait SigningPrivateOps { } +/** Provides signing-related metrics. */ +trait SigningMetricsSupport { + def signingMetrics: SigningMetrics +} + /** A default implementation with a private key store */ trait SigningPrivateStoreOps extends SigningPrivateOps { @@ -142,7 +190,7 @@ trait SigningPrivateStoreOps extends SigningPrivateOps { protected val signingOps: SigningOps - override def signBytes( + override private[crypto] def signBytesInternal( bytes: ByteString, signingKeyId: Fingerprint, usage: NonEmpty[Set[SigningKeyUsage]], diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/SyncCryptoApiParticipantProvider.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/SyncCryptoApiParticipantProvider.scala index 73dc377cf8..a661111b2e 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/SyncCryptoApiParticipantProvider.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/SyncCryptoApiParticipantProvider.scala @@ -19,7 +19,7 @@ import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.discard.Implicits.DiscardOps import com.digitalasset.canton.lifecycle.{FlagCloseable, FutureUnlessShutdown, LifeCycle} import com.digitalasset.canton.logging.{ErrorLoggingContext, NamedLoggerFactory, NamedLogging} -import com.digitalasset.canton.metrics.KmsMetrics +import com.digitalasset.canton.metrics.CryptoMetrics import com.digitalasset.canton.protocol.StaticSynchronizerParameters import com.digitalasset.canton.serialization.DeserializationError import com.digitalasset.canton.topology.* @@ -54,7 +54,7 @@ class SyncCryptoApiParticipantProvider( val ips: IdentityProvidingServiceClient, val crypto: Crypto, cryptoConfig: CryptoConfig, - kmsMetrics: Option[KmsMetrics], + cryptoMetrics: CryptoMetrics, publicKeyConversionCacheConfig: CacheConfig, timeouts: ProcessingTimeout, futureSupervisor: FutureSupervisor, @@ -93,7 +93,7 @@ class SyncCryptoApiParticipantProvider( staticSynchronizerParameters, SynchronizerCrypto(crypto, staticSynchronizerParameters), cryptoConfig, - kmsMetrics, + cryptoMetrics, publicKeyConversionCacheConfig, timeouts, futureSupervisor, @@ -426,7 +426,7 @@ object SynchronizerCryptoClient { staticSynchronizerParameters: StaticSynchronizerParameters, synchronizerCrypto: SynchronizerCrypto, cryptoConfig: CryptoConfig, - kmsMetrics: Option[KmsMetrics], + cryptoMetrics: CryptoMetrics, publicKeyConversionCacheConfig: CacheConfig, timeouts: ProcessingTimeout, futureSupervisor: FutureSupervisor, @@ -440,7 +440,7 @@ object SynchronizerCryptoClient { member, synchronizerCrypto, cryptoConfig, - kmsMetrics, + cryptoMetrics, publicKeyConversionCacheConfig, futureSupervisor, timeouts, diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/SynchronizerCryptoPrivateApi.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/SynchronizerCryptoPrivateApi.scala index 005b8873e6..71abde8dd8 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/SynchronizerCryptoPrivateApi.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/SynchronizerCryptoPrivateApi.scala @@ -10,6 +10,7 @@ import com.digitalasset.canton.config.ProcessingTimeout import com.digitalasset.canton.health.ComponentHealthState import com.digitalasset.canton.lifecycle.FutureUnlessShutdown import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} +import com.digitalasset.canton.metrics.{DecryptionMetrics, SigningMetrics} import com.digitalasset.canton.protocol.StaticSynchronizerParameters import com.digitalasset.canton.serialization.DeserializationError import com.digitalasset.canton.tracing.TraceContext @@ -26,6 +27,8 @@ import scala.concurrent.ExecutionContext final class SynchronizerCryptoPrivateApi( override val staticSynchronizerParameters: StaticSynchronizerParameters, privateCrypto: CryptoPrivateApi, + override val signingMetrics: SigningMetrics, + override val decryptionMetrics: DecryptionMetrics, override protected val timeouts: ProcessingTimeout, override protected val loggerFactory: NamedLoggerFactory, )(implicit executionContext: ExecutionContext) @@ -36,7 +39,7 @@ final class SynchronizerCryptoPrivateApi( override private[crypto] def getInitialHealthState: ComponentHealthState = privateCrypto.getInitialHealthState - override def decrypt[M]( + override private[crypto] def decryptInternal[M]( encrypted: AsymmetricEncrypted[M] )( deserialize: ByteString => Either[DeserializationError, M] @@ -67,7 +70,7 @@ final class SynchronizerCryptoPrivateApi( override def signingSchemes: SigningCryptoSchemes = privateCrypto.signingSchemes - override def signBytes( + override private[crypto] def signBytesInternal( bytes: ByteString, signingKeyId: Fingerprint, usage: NonEmpty[Set[SigningKeyUsage]], diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/SynchronizerCryptoPureApi.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/SynchronizerCryptoPureApi.scala index ad04c3ac76..fbf8d6ba37 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/SynchronizerCryptoPureApi.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/SynchronizerCryptoPureApi.scala @@ -5,6 +5,7 @@ package com.digitalasset.canton.crypto import com.daml.nonempty.NonEmpty import com.digitalasset.canton.config.RequireTypes.PositiveInt +import com.digitalasset.canton.metrics.{DecryptionMetrics, SigningMetrics} import com.digitalasset.canton.protocol.StaticSynchronizerParameters import com.digitalasset.canton.serialization.DeserializationError import com.digitalasset.canton.tracing.TraceContext @@ -111,6 +112,8 @@ final class SynchronizerCryptoPureApi( symmetricKey: SymmetricKey, ): Either[EncryptionError, ByteString] = pureCrypto.encryptSymmetricWith(data, symmetricKey) + override def decryptionMetrics: DecryptionMetrics = pureCrypto.decryptionMetrics + override def decryptWith[M]( encrypted: Encrypted[M], symmetricKey: SymmetricKey, @@ -145,11 +148,14 @@ final class SynchronizerCryptoPureApi( override def signingAlgorithmSpecs: CryptoScheme[SigningAlgorithmSpec] = pureCrypto.signingAlgorithmSpecs - override protected[crypto] def signBytes( + override def signingMetrics: SigningMetrics = pureCrypto.signingMetrics + + override private[crypto] def signBytesInternal( bytes: ByteString, signingKey: SigningPrivateKey, usage: NonEmpty[Set[SigningKeyUsage]], signingAlgorithmSpec: SigningAlgorithmSpec = signingAlgorithmSpecs.default, )(implicit traceContext: TraceContext): Either[SigningError, Signature] = pureCrypto.signBytes(bytes, signingKey, usage, signingAlgorithmSpec) + } diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/provider/jce/JceCrypto.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/provider/jce/JceCrypto.scala index c71fd873d6..979ccdb7f6 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/provider/jce/JceCrypto.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/provider/jce/JceCrypto.scala @@ -13,6 +13,7 @@ import com.digitalasset.canton.config.{ import com.digitalasset.canton.crypto.store.{CryptoPrivateStore, CryptoPublicStore} import com.digitalasset.canton.crypto.{Crypto, CryptoSchemes} import com.digitalasset.canton.logging.NamedLoggerFactory +import com.digitalasset.canton.metrics.CryptoMetrics import com.digitalasset.canton.util.EitherUtil import scala.concurrent.ExecutionContext @@ -26,6 +27,7 @@ object JceCrypto { publicKeyConversionCacheConfig: CacheConfig, cryptoPrivateStore: CryptoPrivateStore, cryptoPublicStore: CryptoPublicStore, + cryptoMetrics: CryptoMetrics, timeouts: ProcessingTimeout, loggerFactory: NamedLoggerFactory, )(implicit @@ -46,6 +48,7 @@ object JceCrypto { sessionEncryptionKeyCacheConfig, publicKeyConversionCacheConfig, cryptoSchemes, + cryptoMetrics, loggerFactory, ) privateCrypto = @@ -54,6 +57,8 @@ object JceCrypto { signingSchemes = cryptoSchemes.signingSchemes, encryptionSchemes = cryptoSchemes.encryptionSchemes, store = cryptoPrivateStoreExtended, + signingMetrics = cryptoMetrics.signingMetrics, + decryptionMetrics = cryptoMetrics.decryptionMetrics, timeouts = timeouts, loggerFactory = loggerFactory, ) @@ -62,6 +67,7 @@ object JceCrypto { privateCrypto, cryptoPrivateStore, cryptoPublicStore, + cryptoMetrics, timeouts, loggerFactory, ) diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/provider/jce/JcePrivateCrypto.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/provider/jce/JcePrivateCrypto.scala index 16c16581f3..3492ed9b0d 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/provider/jce/JcePrivateCrypto.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/provider/jce/JcePrivateCrypto.scala @@ -13,6 +13,7 @@ import com.digitalasset.canton.crypto.store.CryptoPrivateStoreExtended import com.digitalasset.canton.health.ComponentHealthState import com.digitalasset.canton.lifecycle.FutureUnlessShutdown import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} +import com.digitalasset.canton.metrics.{DecryptionMetrics, SigningMetrics} import com.digitalasset.canton.tracing.TraceContext import com.google.protobuf.ByteString import org.bouncycastle.asn1.DEROctetString @@ -45,6 +46,8 @@ class JcePrivateCrypto( override val signingSchemes: SigningCryptoSchemes, override val encryptionSchemes: EncryptionCryptoSchemes, override protected val store: CryptoPrivateStoreExtended, + override val signingMetrics: SigningMetrics, + override val decryptionMetrics: DecryptionMetrics, override protected val timeouts: ProcessingTimeout, override protected val loggerFactory: NamedLoggerFactory, )(override implicit val ec: ExecutionContext) diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/provider/jce/JcePureCrypto.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/provider/jce/JcePureCrypto.scala index 5e4a97bf9d..d816feda33 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/provider/jce/JcePureCrypto.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/provider/jce/JcePureCrypto.scala @@ -22,6 +22,7 @@ import com.digitalasset.canton.crypto.HmacError.{ import com.digitalasset.canton.crypto.deterministic.encryption.DeterministicRandom import com.digitalasset.canton.crypto.{SignatureCheckError, *} import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} +import com.digitalasset.canton.metrics.{CryptoMetrics, DecryptionMetrics, SigningMetrics} import com.digitalasset.canton.serialization.{ DefaultDeserializationError, DeserializationError, @@ -90,6 +91,8 @@ class JcePureCrypto( publicKeyConversionCacheConfig: CacheConfig, privateKeyConversionCacheTtl: Option[FiniteDuration], override val signatureVerificationParallelism: PositiveInt, + override val signingMetrics: SigningMetrics, + override val decryptionMetrics: DecryptionMetrics, override val loggerFactory: NamedLoggerFactory, )(implicit ec: ExecutionContext) extends CryptoPureApi @@ -318,7 +321,7 @@ class JcePureCrypto( SymmetricKey.create(CryptoKeyFormat.Raw, bytes.unwrap, scheme) } - override def signBytes( + override private[crypto] def signBytesInternal( bytes: ByteString, signingKey: SigningPrivateKey, usage: NonEmpty[Set[SigningKeyUsage]], @@ -841,6 +844,13 @@ class JcePureCrypto( } } + override def signBytes( + bytes: ByteString, + signingKey: SigningPrivateKey, + usage: NonEmpty[Set[SigningKeyUsage]], + signingAlgorithmSpec: SigningAlgorithmSpec = signingAlgorithmSpecs.default, + )(implicit traceContext: TraceContext): Either[SigningError, Signature] = + super.signBytes(bytes, signingKey, usage, signingAlgorithmSpec) } object JcePureCrypto { @@ -850,6 +860,7 @@ object JcePureCrypto { sessionEncryptionKeyCacheConfig: SessionEncryptionKeyCacheConfig, publicKeyConversionCacheConfig: CacheConfig, cryptoSchemes: CryptoSchemes, + cryptoMetrics: CryptoMetrics, loggerFactory: NamedLoggerFactory, )(implicit ec: ExecutionContext): Either[String, JcePureCrypto] = { @@ -889,6 +900,8 @@ object JcePureCrypto { publicKeyConversionCacheConfig = publicKeyConversionCacheConfig, privateKeyConversionCacheTtl = minimumPrivateKeyCacheDuration, signatureVerificationParallelism = config.parallelism.signatureVerificationParallelism, + signingMetrics = cryptoMetrics.signingMetrics, + decryptionMetrics = cryptoMetrics.decryptionMetrics, loggerFactory = loggerFactory, ) } diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/provider/kms/KmsPrivateCrypto.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/provider/kms/KmsPrivateCrypto.scala index cfc2619b73..6675366855 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/provider/kms/KmsPrivateCrypto.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/provider/kms/KmsPrivateCrypto.scala @@ -24,6 +24,7 @@ import com.digitalasset.canton.health.{ } import com.digitalasset.canton.lifecycle.{FlagCloseable, FutureUnlessShutdown} import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} +import com.digitalasset.canton.metrics.{CryptoMetrics, DecryptionMetrics, SigningMetrics} import com.digitalasset.canton.serialization.DeserializationError import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.util.{ByteString256, ByteString4096} @@ -37,6 +38,8 @@ class KmsPrivateCrypto( private[kms] val publicStore: CryptoPublicStore, override val signingSchemes: SigningCryptoSchemes, override val encryptionSchemes: EncryptionCryptoSchemes, + override val signingMetrics: SigningMetrics, + override val decryptionMetrics: DecryptionMetrics, override protected val timeouts: ProcessingTimeout, override protected val loggerFactory: NamedLoggerFactory, )(implicit ec: ExecutionContext) @@ -137,7 +140,7 @@ class KmsPrivateCrypto( ) } yield publicKey - def signBytes( + override private[crypto] def signBytesInternal( bytes: ByteString, signingKeyId: Fingerprint, usage: NonEmpty[Set[SigningKeyUsage]], @@ -268,7 +271,7 @@ class KmsPrivateCrypto( _ = privateStore.storeKeyMetadata(KmsMetadata(publicKey.id, keyId, KeyPurpose.Encryption)) } yield publicKey - override def decrypt[M](encrypted: AsymmetricEncrypted[M])( + override private[crypto] def decryptInternal[M](encrypted: AsymmetricEncrypted[M])( deserialize: ByteString => Either[DeserializationError, M] )(implicit tc: TraceContext): EitherT[FutureUnlessShutdown, DecryptionError, M] = for { @@ -324,6 +327,7 @@ object KmsPrivateCrypto { encryptionSchemes: EncryptionCryptoSchemes, cryptoPublicStore: CryptoPublicStore, kmsCryptoPrivateStore: KmsCryptoPrivateStore, + cryptoMetrics: CryptoMetrics, timeouts: ProcessingTimeout, loggerFactory: NamedLoggerFactory, )(implicit executionContext: ExecutionContext): KmsPrivateCrypto = @@ -333,6 +337,8 @@ object KmsPrivateCrypto { cryptoPublicStore, signingSchemes, encryptionSchemes, + cryptoMetrics.signingMetrics, + cryptoMetrics.decryptionMetrics, timeouts, loggerFactory, ) diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/signer/SyncCryptoSigner.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/signer/SyncCryptoSigner.scala index d0cf61e031..d04f9b4806 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/signer/SyncCryptoSigner.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/signer/SyncCryptoSigner.scala @@ -24,7 +24,7 @@ import com.digitalasset.canton.crypto.{ import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.lifecycle.FutureUnlessShutdown import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} -import com.digitalasset.canton.metrics.KmsMetrics +import com.digitalasset.canton.metrics.CryptoMetrics import com.digitalasset.canton.protocol.StaticSynchronizerParameters import com.digitalasset.canton.sequencing.client.SequencerClientConfig import com.digitalasset.canton.time.Clock @@ -108,7 +108,7 @@ object SyncCryptoSigner { member: Member, crypto: SynchronizerCrypto, cryptoConfig: CryptoConfig, - kmsMetrics: Option[KmsMetrics], + cryptoMetrics: CryptoMetrics, publicKeyConversionCacheConfig: CacheConfig, futureSupervisor: FutureSupervisor, timeouts: ProcessingTimeout, @@ -130,7 +130,7 @@ object SyncCryptoSigner { staticSynchronizerParameters, member, crypto.privateCrypto, - kmsMetrics, + cryptoMetrics, crypto.cryptoPrivateStore, cryptoConfig.sessionSigningKeys, publicKeyConversionCacheConfig, diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/signer/SyncCryptoSignerWithSessionKeys.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/signer/SyncCryptoSignerWithSessionKeys.scala index 6e94d06a5b..c91a22adca 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/signer/SyncCryptoSignerWithSessionKeys.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/signer/SyncCryptoSignerWithSessionKeys.scala @@ -5,6 +5,8 @@ package com.digitalasset.canton.crypto.signer import cats.data.EitherT import cats.syntax.either.* +import com.daml.metrics.api.noop.NoOpMetricsFactory +import com.daml.metrics.api.{HistogramInventory, MetricName, MetricsContext} import com.daml.nonempty.NonEmpty import com.digitalasset.canton.concurrent.{ExecutorServiceExtensions, FutureSupervisor, Threading} import com.digitalasset.canton.config.RequireTypes.PositiveInt @@ -31,7 +33,7 @@ import com.digitalasset.canton.lifecycle.{ UnlessShutdown, } import com.digitalasset.canton.logging.NamedLoggerFactory -import com.digitalasset.canton.metrics.KmsMetrics +import com.digitalasset.canton.metrics.{CryptoMetrics, SigningHistograms, SigningMetrics} import com.digitalasset.canton.protocol.StaticSynchronizerParameters import com.digitalasset.canton.topology.client.TopologySnapshot import com.digitalasset.canton.topology.{Member, SynchronizerId} @@ -66,7 +68,7 @@ class SyncCryptoSignerWithSessionKeys( staticSynchronizerParameters: StaticSynchronizerParameters, member: Member, signPrivateApiWithLongTermKeys: SigningPrivateOps, - kmsMetrics: Option[KmsMetrics], + cryptoMetrics: CryptoMetrics, override protected val cryptoPrivateStore: CryptoPrivateStore, sessionSigningKeysConfig: SessionSigningKeysConfig, publicKeyConversionCacheConfig: CacheConfig, @@ -106,6 +108,13 @@ class SyncCryptoSignerWithSessionKeys( // this `JcePureCrypto` object only holds private key conversions spawned from sign calls privateKeyConversionCacheTtl = Some(sessionSigningKeysConfig.keyEvictionPeriod.underlying), signatureVerificationParallelism = PositiveInt.one, // not used + signingMetrics = new SigningMetrics( + new SigningHistograms(MetricName("signing"))(new HistogramInventory()), + NoOpMetricsFactory, + )( + MetricsContext.Empty + ), // not used since we only want to record latency for KMS signing requests + decryptionMetrics = cryptoMetrics.decryptionMetrics, // not used loggerFactory = loggerFactory, ) @@ -548,7 +557,9 @@ class SyncCryptoSignerWithSessionKeys( _.validityPeriodEnd.contains(CantonTimestamp.MaxValue) ) ) - kmsMetrics.foreach(_.sessionSigningKeysFallback.inc()) + cryptoMetrics.kmsMetricsO.foreach(kmsMetrics => + kmsMetrics.sessionSigningKeysFallback.inc() + ) signPrivateApiWithLongTermKeys .sign(hash, activeLongTermKey.id, usage) .leftMap[SyncCryptoError](SyncCryptoError.SyncCryptoSigningError.apply) diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/verifier/SyncCryptoVerifier.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/verifier/SyncCryptoVerifier.scala index 6308039807..540e39fcb5 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/verifier/SyncCryptoVerifier.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/crypto/verifier/SyncCryptoVerifier.scala @@ -6,6 +6,8 @@ package com.digitalasset.canton.crypto.verifier import cats.data.EitherT import cats.implicits.{catsSyntaxAlternativeSeparate, catsSyntaxValidatedId} import cats.syntax.either.* +import com.daml.metrics.api.noop.NoOpMetricsFactory +import com.daml.metrics.api.{HistogramInventory, MetricName, MetricsContext} import com.daml.nonempty.NonEmpty import com.digitalasset.canton.config.CacheConfig import com.digitalasset.canton.config.RequireTypes.PositiveInt @@ -33,6 +35,12 @@ import com.digitalasset.canton.crypto.{ } import com.digitalasset.canton.lifecycle.FutureUnlessShutdown import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} +import com.digitalasset.canton.metrics.{ + DecryptionHistograms, + DecryptionMetrics, + SigningHistograms, + SigningMetrics, +} import com.digitalasset.canton.protocol.StaticSynchronizerParameters import com.digitalasset.canton.topology.client.TopologySnapshot import com.digitalasset.canton.topology.{Member, SynchronizerId} @@ -88,6 +96,14 @@ class SyncCryptoVerifier( // with a public signing key, and the private key conversion cache is never used. privateKeyConversionCacheTtl = None, signatureVerificationParallelism = signatureVerificationParallelism, + signingMetrics = new SigningMetrics( + new SigningHistograms(MetricName("signing"))(new HistogramInventory()), + NoOpMetricsFactory, + )(MetricsContext.Empty), // not used + decryptionMetrics = new DecryptionMetrics( + new DecryptionHistograms(MetricName("decryption"))(new HistogramInventory()), + NoOpMetricsFactory, + )(MetricsContext.Empty), // not used loggerFactory = loggerFactory, ) diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/metrics/CryptoMetrics.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/metrics/CryptoMetrics.scala new file mode 100644 index 0000000000..31db14708f --- /dev/null +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/metrics/CryptoMetrics.scala @@ -0,0 +1,19 @@ +// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.metrics + +/** Aggregates all cryptographic-related metrics. + * + * @param signingMetrics + * Metrics for signing operations + * @param decryptionMetrics + * Metrics for decryption operations + * @param kmsMetricsO + * Optional metrics for KMS-backed operations; defined only when KMS is in use + */ +class CryptoMetrics( + val signingMetrics: SigningMetrics, + val decryptionMetrics: DecryptionMetrics, + val kmsMetricsO: Option[KmsMetrics], +) diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/metrics/DecryptionMetrics.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/metrics/DecryptionMetrics.scala new file mode 100644 index 0000000000..8fe279b71b --- /dev/null +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/metrics/DecryptionMetrics.scala @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.metrics + +import com.daml.metrics.api.HistogramInventory.Item +import com.daml.metrics.api.MetricHandle.{LabeledMetricsFactory, Timer} +import com.daml.metrics.api.{HistogramInventory, MetricName, MetricQualification, MetricsContext} + +class DecryptionHistograms(val parent: MetricName)(implicit + inventory: HistogramInventory +) { + + private[metrics] val prefix: MetricName = parent :+ "decryption" + + private[metrics] val decryptLatency: Item = Item( + prefix :+ "latency", + summary = "Latency of decryption requests.", + description = "Measures the latency of decryption operations.", + qualification = MetricQualification.Latency, + ) +} + +class DecryptionMetrics( + histograms: DecryptionHistograms, + labeledMetricsFactory: LabeledMetricsFactory, +)(implicit context: MetricsContext) { + + val prefix: MetricName = histograms.prefix + val decryptLatency: Timer = labeledMetricsFactory.timer(histograms.decryptLatency.info) +} diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/metrics/SequencerConnectionPoolMetrics.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/metrics/SequencerConnectionPoolMetrics.scala index c275e9c61e..3cdbd92113 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/metrics/SequencerConnectionPoolMetrics.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/metrics/SequencerConnectionPoolMetrics.scala @@ -105,25 +105,19 @@ class SequencerConnectionPoolMetrics( connectionHealthMetrics.getOrElseUpdate(mc, Eval.later(createConnectionHealthGauge)).value } - def removeMetricsForAllConnections(): Unit = { - connectionHealthMetrics.values.foreach(_.value.close) - connectionHealthMetrics.clear() + def removeMetricsForAllConnections(psidO: Option[String]): Unit = + removeInternal(_.labels.get("psid") == psidO) - subscriptionHealthMetrics.values.foreach(_.value.close) - subscriptionHealthMetrics.clear() - } - - def removeMetricsForConnection(toRemove: Set[String]): Unit = { - // remove from map - val connsToRemove = connectionHealthMetrics.keys - .filter(_.labels.get("connection").exists(toRemove(_))) - connsToRemove.foreach(mc => connectionHealthMetrics.remove(mc).foreach(_.value.close())) + def removeMetricsForConnection(toRemove: Set[String], psidO: Option[String]): Unit = + removeInternal(mc => + mc.labels.get("psid") == psidO && mc.labels.get("connection").exists(toRemove(_)) + ) - val subsToRemove = subscriptionHealthMetrics.keys - .filter(_.labels.get("connection").exists(toRemove(_))) - subsToRemove - .foreach(mc => subscriptionHealthMetrics.remove(mc).foreach(_.value.close)) - } + private def removeInternal[T](filter: MetricsContext => Boolean): Unit = + Seq(connectionHealthMetrics, subscriptionHealthMetrics).foreach { metricsMap => + val toRemove = metricsMap.keys.filter(filter) + toRemove.foreach(metricsMap.remove(_).foreach(_.value.close)) + } // Gauges don't support metrics context per update. So instead create a map with a gauge per context. private val subscriptionHealthMetrics: TrieMap[MetricsContext, Eval[Gauge[Int]]] = TrieMap.empty diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/metrics/SigningMetrics.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/metrics/SigningMetrics.scala new file mode 100644 index 0000000000..476b145934 --- /dev/null +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/metrics/SigningMetrics.scala @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.metrics + +import com.daml.metrics.api.HistogramInventory.Item +import com.daml.metrics.api.MetricHandle.{LabeledMetricsFactory, Timer} +import com.daml.metrics.api.{HistogramInventory, MetricName, MetricQualification, MetricsContext} + +class SigningHistograms(val parent: MetricName)(implicit + inventory: HistogramInventory +) { + + private[metrics] val prefix: MetricName = parent :+ "signing" + + private[metrics] val signingLatency: Item = Item( + prefix :+ "latency", + summary = "Latency of signing requests.", + description = "Measures the latency of signing operations.", + qualification = MetricQualification.Latency, + ) +} + +class SigningMetrics( + histograms: SigningHistograms, + labeledMetricsFactory: LabeledMetricsFactory, +)(implicit context: MetricsContext) { + + val prefix: MetricName = histograms.prefix + val signingLatency: Timer = labeledMetricsFactory.timer(histograms.signingLatency.info) +} diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/networking/grpc/GrpcError.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/networking/grpc/GrpcError.scala index 8378d5b32c..53d3b509f6 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/networking/grpc/GrpcError.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/networking/grpc/GrpcError.scala @@ -10,6 +10,7 @@ import com.digitalasset.canton.sequencing.authentication.MemberAuthentication.{ MissingToken, } import com.digitalasset.canton.sequencing.authentication.grpc.Constant +import com.digitalasset.canton.sequencing.protocol.SequencerErrors.AggregateSubmissionAlreadySent import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.util.ShowUtil.* import io.grpc.Status.Code.* @@ -122,6 +123,28 @@ object GrpcError { } } + /** The server rejected this request because it duplicates an existing resource. */ + final case class GrpcRequestRefusedAlreadyExists( + request: String, + serverName: String, + status: Status, + optTrailers: Option[Metadata], + decodedCantonError: Option[DecodedCantonError], + ) extends GrpcError { + + override protected def logFullCause: Boolean = true + + def isAuthenticationTokenMissing: Boolean = false + + override def log(logger: TracedLogger)(implicit traceContext: TraceContext): Unit = + // no need to log this dramatically as it is expected to happen + if (decodedCantonError.exists(_.code.id == AggregateSubmissionAlreadySent.id)) { + logger.info(s"""Request failed for $serverName.$hint + | ${getClass.getSimpleName}: ${status.getCode} / ${status.getDescription}""".mkString) + } else logger.info(toString) + + } + /** The client gave up waiting for a response. The server may or may not process the request. It * may or may not make sense to retry, depending on the specific situation. */ @@ -199,9 +222,12 @@ object GrpcError { else GrpcClientError(request, serverName, status, optTrailers, decodedError) case FAILED_PRECONDITION | NOT_FOUND | OUT_OF_RANGE | RESOURCE_EXHAUSTED | ABORTED | - PERMISSION_DENIED | ALREADY_EXISTS => + PERMISSION_DENIED => GrpcRequestRefusedByServer(request, serverName, status, optTrailers, decodedError) + case ALREADY_EXISTS => + GrpcRequestRefusedAlreadyExists(request, serverName, status, optTrailers, decodedError) + case DEADLINE_EXCEEDED | CANCELLED => GrpcClientGaveUp(request, serverName, status, optTrailers, decodedError) diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/resource/DbMigrations.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/resource/DbMigrations.scala index b7e6fe8dac..9568d0938f 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/resource/DbMigrations.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/resource/DbMigrations.scala @@ -26,6 +26,7 @@ import com.digitalasset.canton.util.ShowUtil.* import com.digitalasset.canton.util.retry.RetryEither import com.digitalasset.canton.util.{LoggerUtil, MonadUtil, ResourceUtil} import org.flywaydb.core.Flyway +import org.flywaydb.core.api.configuration.FluentConfiguration import org.flywaydb.core.api.{FlywayException, MigrationInfo} import slick.jdbc.JdbcBackend.Database import slick.jdbc.hikaricp.HikariCPJdbcDataSource @@ -35,6 +36,7 @@ import java.sql.SQLException import javax.sql.DataSource import scala.concurrent.duration.Duration import scala.concurrent.{ExecutionContext, blocking} +import scala.jdk.CollectionConverters.* /** Performs DB migrations using Flyway. * @@ -59,13 +61,20 @@ class DbMigrations( * https://flywaydb.org/documentation/getstarted/firststeps/api */ protected def createFlyway(dataSource: DataSource): Flyway = + createFlywayConfig(dataSource: DataSource).load() + + protected def createFlywayConfig(dataSource: DataSource): FluentConfiguration = Flyway.configure .locations(dbConfig.buildMigrationsPaths(alphaVersionSupport)*) .dataSource(dataSource) .cleanDisabled(!dbConfig.parameters.unsafeCleanOnValidationError) .baselineOnMigrate(dbConfig.parameters.unsafeBaselineOnMigrate) .lockRetryCount(60) - .load() + .placeholders( + Map( + "initialBftOrdererTablesPartitionSize" -> dbConfig.parameters.partitions.initialBftOrdererTablesPartitionSize.toString + ).asJava + ) protected def withCreatedDb[A](retryConfig: DbStorage.RetryConfig)( fn: Database => EitherT[UnlessShutdown, DbMigrations.Error, A] diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/resource/DbStorageMulti.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/resource/DbStorageMulti.scala index 3f63f46b7e..5950b788e1 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/resource/DbStorageMulti.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/resource/DbStorageMulti.scala @@ -193,7 +193,7 @@ final class DbStorageMulti private ( // Run the initial health check checkHealth(clock.now) - private val writeDb: Database = DbLockedConnectionPool.createDatabaseFromPool( + private[canton] val writeDb: Database = DbLockedConnectionPool.createDatabaseFromPool( writeConnectionPool, writeDbExecutor, ) diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/resource/DbStorageSingle.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/resource/DbStorageSingle.scala index 9c98041df8..b296c83d8f 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/resource/DbStorageSingle.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/resource/DbStorageSingle.scala @@ -38,7 +38,7 @@ import scala.concurrent.{ExecutionContext, Future, blocking} final class DbStorageSingle private ( override val profile: DbStorage.Profile, override val dbConfig: DbConfig, - db: Database, + private[canton] val db: Database, clock: Clock, override protected val logOperations: Boolean, override val metrics: DbStorageMetrics, diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/PeriodicAcknowledgements.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/PeriodicAcknowledgements.scala index fa1d37af6b..8f5c78708c 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/PeriodicAcknowledgements.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/PeriodicAcknowledgements.scala @@ -8,7 +8,12 @@ import com.daml.nameof.NameOf.functionFullName import com.digitalasset.canton.config.ProcessingTimeout import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.discard.Implicits.DiscardOps -import com.digitalasset.canton.lifecycle.{FlagCloseable, FutureUnlessShutdown, UnlessShutdown} +import com.digitalasset.canton.lifecycle.{ + FlagCloseable, + FutureUnlessShutdown, + HasCloseContext, + UnlessShutdown, +} import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.store.SequencerCounterTrackerStore import com.digitalasset.canton.time.Clock @@ -45,7 +50,8 @@ class PeriodicAcknowledgements( )(implicit executionContext: ExecutionContext) extends NamedLogging with FlagCloseable - with HasFlushFuture { + with HasFlushFuture + with HasCloseContext { private val priorAckRef = new AtomicReference[Option[CantonTimestamp]](None) @@ -94,7 +100,7 @@ class PeriodicAcknowledgements( withNewTraceContext("schedule_next_periodic_ack") { implicit traceContext => synchronizeWithClosingSync(functionFullName)( clock - .scheduleAfter( + .scheduleAfterCancelledOnShutdown( { _ => // Schedule the next update as soon as possible after the interval has passed; // for static time tests, this runs synchronously when the time is advanced @@ -102,6 +108,7 @@ class PeriodicAcknowledgements( scheduleNextUpdate() // Async-trampolined update() }, + "periodick-ack", interval.toJava, ) .discard[FutureUnlessShutdown[Unit]] diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClient.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClient.scala index d5d353dfd2..5dd311e907 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClient.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClient.scala @@ -60,6 +60,7 @@ import com.digitalasset.canton.sequencing.client.SendTracker.{LatestAttempt, Lat import com.digitalasset.canton.sequencing.client.SequencerClient.{ ConnectionContainer, SequencerTransports, + TrafficCostValidator, } import com.digitalasset.canton.sequencing.client.SequencerClientImpl.SequencerClientTimeSourcesPool import com.digitalasset.canton.sequencing.client.SequencerClientSend.SendRequestTimestamps @@ -121,7 +122,7 @@ import org.apache.pekko.{Done, NotUsed} import org.slf4j.event.Level import java.util.concurrent.atomic.AtomicReference -import scala.annotation.nowarn +import scala.annotation.{nowarn, unused} import scala.compat.java8.DurationConverters.FiniteDurationops import scala.concurrent.* import scala.concurrent.duration.* @@ -300,6 +301,7 @@ abstract class SequencerClientImpl( messageId: MessageId, aggregationRule: Option[AggregationRule], callback: SendCallback, + trafficCostValidator: TrafficCostValidator, amplify: Boolean, useConfirmationResponseAmplificationParameters: Boolean, )(implicit @@ -312,6 +314,7 @@ abstract class SequencerClientImpl( messageId, aggregationRule, callback, + trafficCostValidator, amplify, useConfirmationResponseAmplificationParameters, metricsContext, @@ -339,6 +342,7 @@ abstract class SequencerClientImpl( messageId: MessageId, aggregationRule: Option[AggregationRule], callback: SendCallback, + trafficCostValidator: TrafficCostValidator, amplify: Boolean, useConfirmationResponseAmplificationParameters: Boolean, metricsContext: MetricsContext, @@ -477,6 +481,9 @@ abstract class SequencerClientImpl( .checkSenderAndRecipientsAreRegistered(request, snapshot) .leftMap(_.toSendAsyncClientError) acceptableSequencersO <- EitherT.right(getAcceptableSequencers(snapshot)) + _ <- EitherT.liftF( + cost.parTraverse_(c => trafficCostValidator.validate(c.cost.unwrap, traceContext)) + ) latestAttemptRef <- EitherT.fromEither[FutureUnlessShutdown](trackSend) _ = recorderO.foreach(_.recordSubmission(request)) res <- performSend( @@ -1522,7 +1529,7 @@ class RichSequencerClientImpl( sequencerSubscriptionFactory, subscriptionHandlerFactory, metrics.connectionPool, - metricsContext = MetricsContext.Empty, + connectionPool.metricsContext, timeouts, loggerFactory, ) @@ -2340,4 +2347,25 @@ object SequencerClient { sequencerId: SequencerId, ): NamedLoggerFactory = loggerFactory.append("sequencerId", sequencerId.uid.toString) + + trait TrafficCostValidator { + + /** Validates that the traffic cost is valid in the context of the submitting member and the + * current submission request. + * + * Practically, this is relevant for requests from submitting participants that perform traffic + * enforcement against local user traffic accounts. + */ + def validate(trafficCost: Long, traceContext: TraceContext): FutureUnlessShutdown[Unit] + } + + object TrafficCostValidator { + val NoTrafficCostValidation: TrafficCostValidator = new TrafficCostValidator { + override def validate( + @unused trafficCost: Long, + @unused traceContext: TraceContext, + ): FutureUnlessShutdown[Unit] = + FutureUnlessShutdown.unit + } + } } diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClientFactory.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClientFactory.scala index d2dd73d0cc..535178e739 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClientFactory.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClientFactory.scala @@ -243,11 +243,13 @@ object SequencerClientFactory { // Make a BFT call to all the transports to retrieve the current traffic state from the synchronizer // and initialize the trafficStateController with it trafficInitTimestampO = latestSequencedTimestampO - .orElse( - synchronizerPredecessor.map( - _.upgradeTime - ) - ) + .orElse(synchronizerPredecessor.map(_.upgradeTime)) + /* + Mediator nodes don't expose traffic. + This also prevent them from connecting to the sequencer during LSU before upgrade time, which + is needed for the test sequencing messages. + */ + .filter(_ => member.code != MediatorId.Code) _ = logger.info( s"Initializing traffic state at timestamp: $trafficInitTimestampO" diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClientSend.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClientSend.scala index 9a0f9b95a6..dab9680511 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClientSend.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClientSend.scala @@ -8,6 +8,8 @@ import com.daml.metrics.api.MetricsContext import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.lifecycle.FutureUnlessShutdown import com.digitalasset.canton.protocol.messages.DefaultOpenEnvelope +import com.digitalasset.canton.sequencing.client.SequencerClient.TrafficCostValidator +import com.digitalasset.canton.sequencing.client.SequencerClient.TrafficCostValidator.NoTrafficCostValidation import com.digitalasset.canton.sequencing.client.SequencerClientSend.SendRequestTimestamps import com.digitalasset.canton.sequencing.protocol.{AggregationRule, Batch, MessageId} import com.digitalasset.canton.time.Clock @@ -71,6 +73,10 @@ trait SequencerClientSend { * independent of the configuration. * @param timestamps * Aggregated timestamps needed for sending a request. + * @param trafficCostValidator + * Validate the traffic cost of the submission batch against the local traffic state of the + * member, only if traffic control is enabled for the sequencer. If validation fails, the + * submission request is not sent to the sequencer and an error is returned. */ def sendAsync( batch: Batch[DefaultOpenEnvelope], @@ -78,6 +84,7 @@ trait SequencerClientSend { messageId: MessageId = generateMessageId, aggregationRule: Option[AggregationRule] = None, callback: SendCallback = SendCallback.empty, + trafficCostValidator: TrafficCostValidator, amplify: Boolean = false, useConfirmationResponseAmplificationParameters: Boolean = false, )(implicit @@ -95,6 +102,7 @@ trait SequencerClientSend { messageId: MessageId = generateMessageId, aggregationRule: Option[AggregationRule] = None, callback: SendCallback = SendCallback.empty, + trafficCostValidator: TrafficCostValidator = NoTrafficCostValidation, amplify: Boolean = false, useConfirmationResponseAmplificationParameters: Boolean = false, )(implicit @@ -106,6 +114,7 @@ trait SequencerClientSend { messageId = messageId, aggregationRule = aggregationRule, callback = callback, + trafficCostValidator = trafficCostValidator, amplify = amplify, useConfirmationResponseAmplificationParameters = useConfirmationResponseAmplificationParameters, ).value.flatMap(identity) diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/GrpcInternalSequencerConnection.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/GrpcInternalSequencerConnection.scala index 4723d6c8eb..8b21366659 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/GrpcInternalSequencerConnection.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/GrpcInternalSequencerConnection.scala @@ -66,7 +66,7 @@ class GrpcInternalSequencerConnection private[sequencing] ( private val connection: GrpcConnection = GrpcConnection(config, params, metrics, timeouts, loggerFactory) - private val connectionMetricsContext: MetricsContext = metricsContext.withExtraLabels( + private implicit val connectionMetricsContext: MetricsContext = metricsContext.withExtraLabels( "connection" -> connection.config.name ) private val stub: SequencerConnectionStub = diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/GrpcSequencerConnection.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/GrpcSequencerConnection.scala index 4f097360bb..cfe4e9138b 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/GrpcSequencerConnection.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/GrpcSequencerConnection.scala @@ -12,6 +12,7 @@ import com.digitalasset.canton.logging.NamedLoggerFactory import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} import com.digitalasset.canton.networking.grpc.GrpcError.{ GrpcClientError, + GrpcRequestRefusedAlreadyExists, GrpcRequestRefusedByServer, GrpcServiceUnavailable, } @@ -134,10 +135,17 @@ class GrpcSequencerConnection( // Adapted from GrpcSequencerClientTransportCommon Either.cond( !bubbleSendErrorPolicy(error), { - // log that we're swallowing the error - logger.info( - s"Send [$messageId] returned an error however may still be possibly sequenced so we are ignoring the error: $error" - ) + error match { + // TODO(#12377) Do not trust the sequencer but monitor and stop using the given sequencer if it is denying service + case ConnectionError.TransportError(error: GrpcRequestRefusedAlreadyExists) => + // already logged as an info in GrpcConnection + case _ => + // log that we're swallowing the error + logger.info( + s"Send [$messageId] returned an error however may still be possibly sequenced so we are ignoring the error: $error" + ) + } + () }, error match { @@ -172,6 +180,8 @@ class GrpcSequencerConnection( case _: GrpcError.GrpcClientError => true // the request was rejected by the server as it wasn't in a state to accept it case _: GrpcError.GrpcRequestRefusedByServer => true + // the request was rejected by the server because it already exists, so we don't need to bubble up + case _: GrpcError.GrpcRequestRefusedAlreadyExists => false // an internal error happened at the server, this could have been when constructing or sending the response // after accepting the request so we cannot safely bubble the error case _: GrpcError.GrpcServerError => false diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/GrpcUserSequencerConnectionStub.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/GrpcUserSequencerConnectionStub.scala index 317330bbf4..0ea28583ea 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/GrpcUserSequencerConnectionStub.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/GrpcUserSequencerConnectionStub.scala @@ -91,9 +91,8 @@ class GrpcUserSequencerConnectionStub( SendAsyncRequest(signedSubmissionRequest = request.toByteString) ) ) - .leftMap( - SequencerConnectionStubError.ConnectionError.apply - ) + .leftMap(SequencerConnectionStubError.ConnectionError.apply) + } yield () } diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/SequencerConnectionPool.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/SequencerConnectionPool.scala index 88ad976667..33771788b8 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/SequencerConnectionPool.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/SequencerConnectionPool.scala @@ -6,6 +6,7 @@ package com.digitalasset.canton.sequencing.client.pool import cats.data.EitherT import cats.syntax.either.* import com.daml.grpc.adapter.ExecutionSequencerFactory +import com.daml.metrics.api.MetricsContext import com.daml.nonempty.NonEmpty import com.digitalasset.canton.SequencerAlias import com.digitalasset.canton.config.RequireTypes.{NonNegativeInt, PositiveInt} @@ -145,6 +146,9 @@ trait SequencerConnectionPool extends FlagCloseable with NamedLogging { /** Obtain all the sequencer IDs present in the pool, associated to their sequencer alias */ def getAllSequencerIds(implicit traceContext: TraceContext): Map[SequencerAlias, SequencerId] + /** The context for the pool metrics */ + def metricsContext: MetricsContext + /** Determine whether the connection pool can still reach the given threshold, ignoring the * `ignored` connections and considering an additional `extraUndecided` number of undecided * connections. diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/SequencerConnectionPoolImpl.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/SequencerConnectionPoolImpl.scala index 4d19be7be2..1e4dfa60b9 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/SequencerConnectionPoolImpl.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/SequencerConnectionPoolImpl.scala @@ -64,7 +64,7 @@ class SequencerConnectionPoolImpl private[sequencing] ( crypto: Crypto, seedForRandomnessO: Option[Long], metrics: SequencerConnectionPoolMetrics, - metricsContext: MetricsContext, + override val metricsContext: MetricsContext, futureSupervisor: FutureSupervisor, override protected val timeouts: ProcessingTimeout, override protected val loggerFactory: NamedLoggerFactory, @@ -138,7 +138,8 @@ class SequencerConnectionPoolImpl private[sequencing] ( override def staticSynchronizerParametersO: Option[StaticSynchronizerParameters] = bootstrapCell.get.map(_.staticParameters) - private implicit def mc: MetricsContext = metricsContext + private implicit val mc: MetricsContext = metricsContext + metrics.trustThreshold.updateValue(config.trustThreshold.value) override def start()(implicit @@ -153,7 +154,6 @@ class SequencerConnectionPoolImpl private[sequencing] ( updateTrackedConnections( toBeAdded = config.connections, toBeRemoved = Set.empty, - isInitialUpdate = true, ) } @@ -236,7 +236,6 @@ class SequencerConnectionPoolImpl private[sequencing] ( private def updateTrackedConnections( toBeAdded: immutable.Iterable[ConnectionConfig], toBeRemoved: Set[ConnectionConfig], - isInitialUpdate: Boolean, )(implicit traceContext: TraceContext): Unit = lock.exclusive { val removedConnections = @@ -260,11 +259,8 @@ class SequencerConnectionPoolImpl private[sequencing] ( removedConnections.foreach { connection => connection.fatal("Removed from configuration") } - if (isInitialUpdate) { - metrics.removeMetricsForAllConnections() - } else { - metrics.removeMetricsForConnection(toBeRemoved.map(_.name)) - } + + metrics.removeMetricsForConnection(toBeRemoved.map(_.name), mc.labels.get("psid")) // If start() or updateConfig() is called after the pool has been closed, we don't want to start new connections if (!isClosing) { @@ -464,7 +460,6 @@ class SequencerConnectionPoolImpl private[sequencing] ( updateTrackedConnections( toBeAdded = changedConnections.added, toBeRemoved = changedConnections.removed, - isInitialUpdate = false, ) } } @@ -573,6 +568,7 @@ class SequencerConnectionPoolImpl private[sequencing] ( // We close the connections outside the critical section to avoid shutdown problems in case // it triggers health callbacks LifeCycle.close(instances*)(logger) + metrics.removeMetricsForAllConnections(mc.labels.get("psid")) super.onClosed() } diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/SequencerSubscriptionPoolImpl.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/SequencerSubscriptionPoolImpl.scala index 9be2ed91f7..d003bd97d1 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/SequencerSubscriptionPoolImpl.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/pool/SequencerSubscriptionPoolImpl.scala @@ -86,7 +86,8 @@ final class SequencerSubscriptionPoolImpl private[sequencing] ( private def currentConfigWithThreshold: ConfigWithThreshold = ConfigWithThreshold(config, pool.config.trustThreshold) - private implicit def mc: MetricsContext = metricsContext + private implicit val mc: MetricsContext = metricsContext + metrics.subscriptionThreshold.updateValue( currentConfigWithThreshold.activeThreshold.value ) diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/transports/GrpcSubscriptionErrorRetryPolicy.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/transports/GrpcSubscriptionErrorRetryPolicy.scala index 09378fccfe..53541d302d 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/transports/GrpcSubscriptionErrorRetryPolicy.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/transports/GrpcSubscriptionErrorRetryPolicy.scala @@ -50,6 +50,9 @@ object GrpcSubscriptionErrorRetryPolicy { loggingContext.debug("Not trying to reconnect.") retry + case error: GrpcError.GrpcRequestRefusedAlreadyExists => + false // not retrying + case _: GrpcError.GrpcServerError => // We believe these errors (INTERNAL, UNKNOWN, DATA_LOSS) can in some circumstances by transient, and // therefore we err on the side of caution and retry. diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/protocol/ClosedEnvelope.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/protocol/ClosedEnvelope.scala index 5c0a307cdf..9286bbd00f 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/protocol/ClosedEnvelope.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/protocol/ClosedEnvelope.scala @@ -60,6 +60,20 @@ object ClosedEnvelope { snapshot.verifySignatures(hash, sender, signatures, SigningKeyUsage.ProtocolOnly) } + def verifyKeyUsage( + snapshot: SyncCryptoApi, + sender: Member, + signature: Signature, + )(implicit + traceContext: TraceContext + ): EitherT[FutureUnlessShutdown, SignatureCheckError, Unit] = + snapshot.verifyKeyUsage( + sender, + signature.authorizingLongTermKey, + signature.signatureDelegation, + SigningKeyUsage.ProtocolOnly, + ) + def verifyMediatorSignatures( snapshot: SyncCryptoApi, mediatorGroupIndex: MediatorGroupIndex, diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/protocol/ClosedUncompressedEnvelope.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/protocol/ClosedUncompressedEnvelope.scala index 60a0b7cb85..817b1c8c82 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/protocol/ClosedUncompressedEnvelope.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/protocol/ClosedUncompressedEnvelope.scala @@ -26,7 +26,7 @@ import com.digitalasset.canton.serialization.ProtoConverter import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult import com.digitalasset.canton.topology.Member import com.digitalasset.canton.tracing.TraceContext -import com.digitalasset.canton.util.{ByteStringUtil, MaxBytesToDecompress} +import com.digitalasset.canton.util.{ByteStringUtil, MaxBytesToDecompress, MonadUtil} import com.digitalasset.canton.version.{ HasProtocolVersionedWrapper, ProtoVersion, @@ -171,6 +171,15 @@ final case class ClosedUncompressedEnvelope private[protocol] ( .from(signatures) .traverse_(ClosedEnvelope.verifySignatures(snapshot, sender, bytes, _)) + def verifyKeyUsage( + snapshot: SyncCryptoApi, + sender: Member, + )(implicit + ec: ExecutionContext, + traceContext: TraceContext, + ): EitherT[FutureUnlessShutdown, SignatureCheckError, Unit] = + MonadUtil.sequentialTraverse_(signatures)(ClosedEnvelope.verifyKeyUsage(snapshot, sender, _)) + @VisibleForTesting override def withRecipients(newRecipients: Recipients): ClosedUncompressedEnvelope = copy(recipients = newRecipients) diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/protocol/SendAsyncError.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/protocol/SendAsyncError.scala index 196aa7052b..c2d4473de3 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/protocol/SendAsyncError.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/protocol/SendAsyncError.scala @@ -24,6 +24,7 @@ sealed trait SendAsyncError extends PrettyPrinting { /** The max sequencing time has elapsed and the request was refused */ def hasMaxSequencingTimeElapsed: Boolean + } object SendAsyncError { @@ -56,6 +57,7 @@ object SendAsyncError { } case _ => false } + } /** Implementation of [[SendAsyncError]]s for direct transports */ @@ -67,5 +69,6 @@ object SendAsyncError { // Only used for amplification, but direct sequencer transport doesn't use amplification override def isMaxSequencingTimeTooFar: Boolean = false + } } diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/protocol/SequencerDeliverError.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/protocol/SequencerDeliverError.scala index 17c30276a0..0c624940c1 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/protocol/SequencerDeliverError.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/protocol/SequencerDeliverError.scala @@ -17,6 +17,7 @@ import com.digitalasset.canton.error.CantonErrorGroups.SequencerErrorGroup import com.digitalasset.canton.error.{CantonBaseError, TransactionError, TransactionErrorImpl} import com.digitalasset.canton.networking.grpc.GrpcError import com.digitalasset.canton.topology.Member +import com.digitalasset.canton.version.ProtocolVersion import com.google.rpc.status.Status import java.time.Instant @@ -202,6 +203,28 @@ object SequencerErrors extends SequencerErrorGroup { extends SequencerDeliverErrorCode( id = "SEQUENCER_AGGREGATE_SUBMISSION_ALREADY_SENT", ErrorCategory.InvalidGivenCurrentSystemStateOther, + ) { + + def apply(message: String, protocolVersion: ProtocolVersion): SequencerDeliverError = + // for backward compatibility reasons, we need to keep the message format unchanged + if (protocolVersion <= ProtocolVersion.v35) + super.apply(message) + else + AggregateSubmissionAlreadySentV2.apply(message) + } + + @Explanation( + """This error occurs when the sequencer has already sent out the aggregate submission for the request.""" + ) + @Resolution( + """This is expected to happen during operation of a system with aggregate submissions enabled. No action required. + |This error code has been modified to report Grpc RESOURCE_EXISTS and is used for synchronous rejects and + |async rejects starting with PV36.""" + ) + case object AggregateSubmissionAlreadySentV2 + extends SequencerDeliverErrorCode( + id = "SEQUENCER_AGGREGATE_SUBMISSION_ALREADY_SENT", + ErrorCategory.InvalidGivenCurrentSystemStateResourceExists, ) @Explanation( diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/protocol/SignedContent.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/protocol/SignedContent.scala index 1a755c6baa..dac698fc19 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/protocol/SignedContent.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/sequencing/protocol/SignedContent.scala @@ -84,6 +84,19 @@ final case class SignedContent[+A <: HasCryptographicEvidence] private ( snapshot.verifySignature(hash, member, signature, SigningKeyUsage.ProtocolOnly) } + def verifyKeyUsage( + snapshot: SyncCryptoApi, + member: Member, + )(implicit + traceContext: TraceContext + ): EitherT[FutureUnlessShutdown, SignatureCheckError, Unit] = + snapshot.verifyKeyUsage( + member, + signature.authorizingLongTermKey, + signature.signatureDelegation, + SigningKeyUsage.ProtocolOnly, + ) + def deserializeContent[B <: HasCryptographicEvidence]( contentDeserializer: ByteString => ParsingResult[B] ): ParsingResult[SignedContent[B]] = diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/store/Purgeable.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/store/Purgeable.scala index ee92f296b6..abfcaae62f 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/store/Purgeable.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/store/Purgeable.scala @@ -23,6 +23,8 @@ trait Purgeable { trait ChunkPurgeable { self: FlagCloseable => + def name: String + /** Deletes a chunk of items from this store. No guarantees are made around transactionality, nor * about which specific items are deleted. * diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/time/Clock.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/time/Clock.scala index 3c1e027b6d..7a6fecc6b0 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/time/Clock.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/time/Clock.scala @@ -12,11 +12,15 @@ import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.discard.Implicits.DiscardOps import com.digitalasset.canton.error.CantonError import com.digitalasset.canton.error.CantonErrorGroups.ClockErrorGroup +import com.digitalasset.canton.lifecycle.FutureUnlessShutdownImpl.* import com.digitalasset.canton.lifecycle.UnlessShutdown.AbortedDueToShutdown import com.digitalasset.canton.lifecycle.{ + CloseContext, FlagCloseable, FutureUnlessShutdown, LifeCycle, + LifeCycleRegistrationHandle, + RunOnClosing, SyncCloseable, UnlessShutdown, } @@ -42,6 +46,7 @@ import com.digitalasset.canton.topology.admin.v30.{ } import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.util.ShowUtil.* +import com.digitalasset.canton.util.Thereafter.syntax.* import com.digitalasset.canton.util.retry.{AllExceptionRetryPolicy, Pause} import com.digitalasset.canton.util.{ErrorUtil, PriorityBlockingQueueUtil} import com.google.common.annotations.VisibleForTesting @@ -52,7 +57,7 @@ import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference} import java.util.concurrent.{Callable, PriorityBlockingQueue, TimeUnit} import scala.annotation.tailrec import scala.concurrent.duration.DurationInt -import scala.concurrent.{ExecutionContextExecutor, Promise} +import scala.concurrent.{ExecutionContext, ExecutionContextExecutor, Promise} import scala.util.Try /** A clock returning the current time, but with a twist: it always returns unique timestamps. If @@ -271,6 +276,70 @@ abstract class Clock() extends TimeProvider with AutoCloseable with NamedLogging } } + /** Schedule an action to be performed at the given timestamp. If the closeContext is closed, then + * the task is cancelled. + * + * If the provided timestamp is before `now`, the action skips queueing and is executed + * immediately. + * + * @param action + * action to run at the given timestamp (passing in the timestamp for when the task was + * scheduled) + * @param taskName + * name of the task + * @param timestamp + * timestamp when to run the task + * @return + * a future for the given task + */ + def scheduleAtCancelledOnShutdown[A]( + action: CantonTimestamp => A, + taskName: String, + timestamp: CantonTimestamp, + )(implicit ec: ExecutionContext, closeContext: CloseContext): FutureUnlessShutdown[A] = { + + val (f, handle) = scheduleAtCancellable(action, timestamp) + + val cancelTask = new RunOnClosing { + override def name: String = s"cancel-$taskName" + override def done: Boolean = f.isCompleted + override def run()(implicit traceContext: TraceContext): Unit = + handle.cancel(AbortedDueToShutdown) + } + + // Cancel the task upon shutdown + val lifeCycleRegistrationHandleUS: UnlessShutdown[LifeCycleRegistrationHandle] = + closeContext.context.runOnClose(cancelTask) + + f.thereafter { _ => + // Remove the lifeCycleRegistrationHandle when the task is finished + lifeCycleRegistrationHandleUS.foreach(_.cancel().discard) + } + } + + /** Schedule an action to be performed at the given timestamp. If the closeContext is closed, then + * the task is cancelled. + * + * If the provided timestamp is before `now`, the action skips queueing and is executed + * immediately. + * + * @param action + * action to run at the given timestamp (passing in the timestamp for when the task was + * scheduled) + * @param taskName + * name of the task + * @param delta + * duration to wait before running the task + * @return + * a future for the given task + */ + def scheduleAfterCancelledOnShutdown[A]( + action: CantonTimestamp => A, + taskName: String, + delta: Duration, + )(implicit ec: ExecutionContext, closeContext: CloseContext): FutureUnlessShutdown[A] = + scheduleAtCancelledOnShutdown(action, taskName, now.add(delta)) + // flush the task queue, stopping once we hit a task in the future @tailrec private def doFlush(): Option[CantonTimestamp] = { @@ -487,9 +556,8 @@ class SimClock( override def close(): Unit = {} - override protected def addToQueue(queue: Queued[?]): Unit = { - val _ = tasks.add(queue) - } + override protected def addToQueue(queue: Queued[?]): Unit = + tasks.add(queue).discard def reset(): Unit = { failTasks() diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/time/SynchronizerTimeTracker.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/time/SynchronizerTimeTracker.scala index ff93efb981..ec2951b9b1 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/time/SynchronizerTimeTracker.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/time/SynchronizerTimeTracker.scala @@ -15,6 +15,7 @@ import com.digitalasset.canton.discard.Implicits.* import com.digitalasset.canton.lifecycle.{ FlagCloseable, FutureUnlessShutdown, + HasCloseContext, LifeCycle, UnlessShutdown, } @@ -71,7 +72,8 @@ class SynchronizerTimeTracker( )(implicit executionContext: ExecutionContext) extends NamedLogging with FlagCloseable - with HasFlushFuture { + with HasFlushFuture + with HasCloseContext { /** Timestamps that we are waiting to observe held in ascending order. Queue access must be made * while holding the [[lock]]. @@ -517,7 +519,14 @@ class SynchronizerTimeTracker( val latestTimestamp = timestampRef.get().latest.fold(clock.now)(_.receivedAt) val expectUpdateBy = latestTimestamp.add(minObservationDuration).immediateSuccessor - val _ = clock.scheduleAt(performUpdate, expectUpdateBy) + clock + .scheduleAtCancelledOnShutdown( + action = performUpdate, + taskName = "min-observation", + expectUpdateBy, + ) + .discard + }.onShutdown(()) scheduleNextUpdate() diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/topology/ConfiguredPhysicalSynchronizerId.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/topology/ConfiguredPhysicalSynchronizerId.scala index 0ed122456c..440e66ec1a 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/topology/ConfiguredPhysicalSynchronizerId.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/topology/ConfiguredPhysicalSynchronizerId.scala @@ -22,6 +22,7 @@ final case class KnownPhysicalSynchronizerId(psid: PhysicalSynchronizerId) override def toOption: Option[PhysicalSynchronizerId] = Some(psid) } + case object UnknownPhysicalSynchronizerId extends ConfiguredPhysicalSynchronizerId { override protected def pretty: Pretty[UnknownPhysicalSynchronizerId.type] = prettyOfString(_ => "UnknownPhysicalSynchronizerId") diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/topology/TopologyManager.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/topology/TopologyManager.scala index 4499f4332f..4541401f27 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/topology/TopologyManager.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/topology/TopologyManager.scala @@ -451,6 +451,8 @@ abstract class TopologyManager[+StoreID <: TopologyStoreId, +CryptoType <: BaseC * the mapping that should be added * @param signingKeys * the keys which should be used to sign + * @param namespacesToSignFor + * the namespaces for which to sign * @param protocolVersion * the protocol version corresponding to the transaction * @param expectFullAuthorization @@ -466,6 +468,7 @@ abstract class TopologyManager[+StoreID <: TopologyStoreId, +CryptoType <: BaseC mapping: TopologyMapping, serial: Option[PositiveInt], signingKeys: Seq[Fingerprint], + namespacesToSignFor: Seq[Namespace], protocolVersion: ProtocolVersion, expectFullAuthorization: Boolean, forceChanges: ForceFlags = ForceFlags.none, @@ -490,6 +493,7 @@ abstract class TopologyManager[+StoreID <: TopologyStoreId, +CryptoType <: BaseC signedTx <- signTransaction( tx, signingKeys, + namespacesToSignFor, isProposal = !expectFullAuthorization, protocolVersion, existingTransaction, @@ -553,7 +557,12 @@ abstract class TopologyManager[+StoreID <: TopologyStoreId, +CryptoType <: BaseC .Failure(transactionHash, effective, tooManyActiveTransactionsWithSameHash) ) }) - extendedTransaction <- extendSignature(existingTransaction, signingKeys, forceChanges) + extendedTransaction <- extendSignature( + existingTransaction, + signingKeys, + namespacesToSignFor = Seq.empty, + forceChanges, + ) _ <- add( Seq(extendedTransaction), expectFullAuthorization = expectFullAuthorization, @@ -631,6 +640,7 @@ abstract class TopologyManager[+StoreID <: TopologyStoreId, +CryptoType <: BaseC private def signTransaction[Op <: TopologyChangeOp, M <: TopologyMapping]( transaction: TopologyTransaction[Op, M], signingKeys: Seq[Fingerprint], + namespacesToSignFor: Seq[Namespace], isProposal: Boolean, protocolVersion: ProtocolVersion, existingTransaction: Option[GenericSignedTopologyTransaction], @@ -644,7 +654,12 @@ abstract class TopologyManager[+StoreID <: TopologyStoreId, +CryptoType <: BaseC val transactionMapping = transaction.mapping for { // find signing keys. - keysToUseForSigning <- determineKeysToUse(transaction, signingKeys, forceChanges) + keysToUseForSigning <- determineKeysToUse( + transaction, + signingKeys, + namespacesToSignFor, + forceChanges, + ) // If the same operation and mapping is proposed repeatedly, insist that // new keys are being added. Otherwise, reject consistently with daml 2.x-based topology management. _ <- existingTransactionTuple match { @@ -678,13 +693,19 @@ abstract class TopologyManager[+StoreID <: TopologyStoreId, +CryptoType <: BaseC def extendSignature[Op <: TopologyChangeOp, M <: TopologyMapping]( transaction: SignedTopologyTransaction[Op, M], signingKeys: Seq[Fingerprint], + namespacesToSignFor: Seq[Namespace], forceFlags: ForceFlags, )(implicit traceContext: TraceContext ): EitherT[FutureUnlessShutdown, TopologyManagerError, SignedTopologyTransaction[Op, M]] = for { // find signing keys - keys <- determineKeysToUse(transaction.transaction, signingKeys, forceFlags) + keys <- determineKeysToUse( + transaction.transaction, + signingKeys, + namespacesToSignFor, + forceFlags, + ) keysWithNoExistingSignature = keys.diff(transaction.signatures.map(_.authorizingLongTermKey)) updatedSignedTransaction <- NonEmpty.from(keysWithNoExistingSignature) match { case Some(keysWithNoExistingSignatureNE) => @@ -712,6 +733,7 @@ abstract class TopologyManager[+StoreID <: TopologyStoreId, +CryptoType <: BaseC private def determineKeysToUse( transaction: GenericTopologyTransaction, signingKeysToUse: Seq[Fingerprint], + namespacesToSignFor: Seq[Namespace], forceFlags: ForceFlags, )(implicit traceContext: TraceContext @@ -723,6 +745,7 @@ abstract class TopologyManager[+StoreID <: TopologyStoreId, +CryptoType <: BaseC for { requiredAuthAndUsableKeys <- loadValidSigningKeys( transaction, + namespacesToSignFor, returnAllValidKeys = true, ) (requiredAuth, usableKeys) = requiredAuthAndUsableKeys @@ -750,6 +773,7 @@ abstract class TopologyManager[+StoreID <: TopologyStoreId, +CryptoType <: BaseC for { requiredAuthAndDetectedKeysToUse <- loadValidSigningKeys( transaction, + namespacesToSignFor, returnAllValidKeys = false, ) (requiredAuth, detectedKeysToUse) = requiredAuthAndDetectedKeysToUse @@ -764,6 +788,7 @@ abstract class TopologyManager[+StoreID <: TopologyStoreId, +CryptoType <: BaseC private def loadValidSigningKeys( transaction: GenericTopologyTransaction, + namespacesToSignFor: Seq[Namespace], returnAllValidKeys: Boolean, )(implicit traceContext: TraceContext) = for { @@ -785,6 +810,7 @@ abstract class TopologyManager[+StoreID <: TopologyStoreId, +CryptoType <: BaseC ts, transaction, existing.headOption.map(_.transaction), // there should be at most one entry + namespacesToSignFor, returnAllValidKeys, ) } yield result diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/topology/processing/TopologyManagerSigningKeyDetection.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/topology/processing/TopologyManagerSigningKeyDetection.scala index 927a6f1cb7..51117d8c99 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/topology/processing/TopologyManagerSigningKeyDetection.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/topology/processing/TopologyManagerSigningKeyDetection.scala @@ -114,6 +114,8 @@ class TopologyManagerSigningKeyDetection[+PureCrypto <: CryptoPureApi]( * the topology transaction to sign * @param inStore * the latest fully authorized topology transaction with the same unique key as `toSign` + * @param namespacesToSignFor + * if non empty, only keys for the specified namespaces are returned * @param returnAllValidKeys * if true, returns all keys that can be used to sign. if false, only returns the most specific * keys per namespace/uid. @@ -124,6 +126,7 @@ class TopologyManagerSigningKeyDetection[+PureCrypto <: CryptoPureApi]( asOfExclusive: CantonTimestamp, toSign: GenericTopologyTransaction, inStore: Option[GenericTopologyTransaction], + namespacesToSignFor: Seq[Namespace], returnAllValidKeys: Boolean, )(implicit traceContext: TraceContext @@ -142,12 +145,17 @@ class TopologyManagerSigningKeyDetection[+PureCrypto <: CryptoPureApi]( relaxChecksForBackwardsCompatibility = false, ) ) + requestedAuthScope = Option.when(namespacesToSignFor.nonEmpty)( + ReferencedAuthorizations(namespaces = namespacesToSignFor.toSet) + ) - referencedAuth = requiredAuthFor( - toSign, - inStore, - relaxChecksForBackwardsCompatibility = false, - ).referenced + referencedAuth = requestedAuthScope.getOrElse( + requiredAuthFor( + toSign, + inStore, + relaxChecksForBackwardsCompatibility = false, + ).referenced + ) knownNsKeys = referencedAuth.namespaces.toSeq .parFlatTraverse(namespace => @@ -169,7 +177,8 @@ class TopologyManagerSigningKeyDetection[+PureCrypto <: CryptoPureApi]( selfSigned = EitherT.rightT[FutureUnlessShutdown, CryptoPrivateStoreError]( toSign.mapping match { case nsd @ NamespaceDelegation(ns, target, _) - if ns.fingerprint == target.fingerprint && nsd.canSign(Code.NamespaceDelegation) => + if ns.fingerprint == target.fingerprint && nsd + .canSign(Code.NamespaceDelegation) && referencedAuth.namespaces.contains(ns) => Seq(target.fingerprint) case _ => Seq.empty } diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/topology/store/TopologyStore.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/topology/store/TopologyStore.scala index 2c8389f429..fa254d2012 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/topology/store/TopologyStore.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/topology/store/TopologyStore.scala @@ -260,6 +260,8 @@ abstract class TopologyStore[+StoreID <: TopologyStoreId](implicit def predecessor: Option[SynchronizerPredecessor] + def name: String = s"topology-store ($storeId)" + /** fetch the effective time updates greater than or equal to a certain timestamp * * this function is used to recover the future effective timestamp such that we can reschedule @@ -335,6 +337,33 @@ abstract class TopologyStore[+StoreID <: TopologyStoreId](implicit traceContext: TraceContext ): FutureUnlessShutdown[PositiveStoredTopologyTransactions] + /** Returns the upgrade time of the LSU that lead to current synchronizer. + */ + def findUpgradeTimeFromPredecessor()(implicit + ev: StoreID <:< SynchronizerStore, + traceContext: TraceContext, + ): FutureUnlessShutdown[ + Option[CantonTimestamp] + ] = { + val currentPsid = ev(storeId).psid + + inspect( + proposals = false, + timeQuery = TimeQuery.Range(None, None), + asOfExclusiveO = None, + op = Some(TopologyChangeOp.Replace), + types = Seq(TopologyMapping.Code.LsuAnnouncement), + idFilter = Some(currentPsid.identifier.toProtoPrimitive), + namespaceFilter = Some(currentPsid.namespace.toProtoPrimitive), + ).map( + _.collectOfMapping[LsuAnnouncement] + .filter(_.mapping.successor.psid == currentPsid) + .result + .maxByOption(_.serial) + .map(_.mapping.upgradeTime) + ) + } + /** Same as [[findPositiveTransactions]] but returns negative transactions (with a remove * operation) */ @@ -608,7 +637,14 @@ abstract class TopologyStore[+StoreID <: TopologyStoreId](implicit .map(_.psid)}]" ) } else if (ongoingCopyFromPredecessor.compareAndSet(None, newRef)) { - val work = doCopyFromPredecessorSynchronizerStore(sourceStore) + errorLoggingContext.info( + s"LSU: About to copy topology from ${sourceStore.storeId.psid.suffix}" + ) + val work = doCopyFromPredecessorSynchronizerStore(sourceStore).map { _ => + errorLoggingContext.info( + s"LSU: Done copying topology from ${sourceStore.storeId.psid.suffix}" + ) + } newPromise .completeWithUS( work.thereafter(_ => ongoingCopyFromPredecessor.compareAndSet(newRef, None).discard) diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/topology/store/db/DbTopologyStore.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/topology/store/db/DbTopologyStore.scala index bf1f90cf4c..435432a18d 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/topology/store/db/DbTopologyStore.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/topology/store/db/DbTopologyStore.scala @@ -1454,8 +1454,8 @@ class DbTopologyStore[+StoreId <: TopologyStoreId]( } } yield { logger.info( - if (deleted > 0) s"Deleted chunk of $deleted from topology store $storeId." - else s"No chunk to delete from topology store $storeId." + if (deleted > 0) s"Deleted chunk of $deleted from topology store" + else s"No chunk to delete from topology store." ) deleted > 0 } diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/topology/transaction/TopologyMapping.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/topology/transaction/TopologyMapping.scala index 0dfbd2b545..73510a8468 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/topology/transaction/TopologyMapping.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/topology/transaction/TopologyMapping.scala @@ -236,7 +236,7 @@ object TopologyMapping { } - // Small wrapper to not have to work with a tuple3 (Set[Namespace], Set[Uid], Set[Fingerprint]) + // Small wrapper to not have to work with a 2-tuple (Set[Namespace], Set[Fingerprint]) final case class ReferencedAuthorizations( namespaces: Set[Namespace] = Set.empty, extraKeys: Set[Fingerprint] = Set.empty, @@ -1169,14 +1169,14 @@ object SynchronizerTrustCertificate extends TopologyMappingCompanion { /** When this feature flag is enabled, the participant will allow to reassign contracts between * synchronizers. This feature is in alpha and should not be used in production. */ - val EnableAlphaMultiSynchronizer: ParticipantTopologyFeatureFlag = + val EnableMultiSynchronizer: ParticipantTopologyFeatureFlag = ParticipantTopologyFeatureFlag( - v30.Enums.ParticipantFeatureFlag.PARTICIPANT_FEATURE_FLAG_ENABLE_ALPHA_MULTI_SYNCHRONIZER.value - )(Some("EnableAlphaMultiSynchronizer")) + v30.Enums.ParticipantFeatureFlag.PARTICIPANT_FEATURE_FLAG_ENABLE_MULTI_SYNCHRONIZER.value + )(Some("EnableMultiSynchronizer")) val knownTopologyFeatureFlags: Seq[ParticipantTopologyFeatureFlag] = Seq( ExternalSigningLocalContractsInSubview, - EnableAlphaMultiSynchronizer, + EnableMultiSynchronizer, ) def fromProtoV30( diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/util/PekkoUtil.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/util/PekkoUtil.scala index 9e39cd78d1..c89aa88bc7 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/util/PekkoUtil.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/util/PekkoUtil.scala @@ -1149,6 +1149,8 @@ object PekkoUtil extends HasLoggerName { def apply(index: Long): Unit } + type ShutdownInProgress = () => Boolean + final case class FutureQueueConsumer[T]( futureQueue: FutureQueue[(Long, T)], fromExclusive: Long, @@ -1198,8 +1200,7 @@ object PekkoUtil extends HasLoggerName { retryAttemptErrorThreshold: Int, uncommittedWarnTreshold: Int, recoveringQueueMetrics: RecoveringQueueMetrics, - consumerFactory: Commit => Future[FutureQueueConsumer[T]], - initializationKillSwitch: Option[() => Unit], + consumerFactory: Commit => ShutdownInProgress => Future[Future[FutureQueueConsumer[T]]], ) extends RecoveringFutureQueue[T] { assert(maxBlockedOffer > 0) assert(retryAttemptWarnThreshold > 0) @@ -1209,7 +1210,7 @@ object PekkoUtil extends HasLoggerName { private val logger = loggerFactory.getLogger(this.getClass) private implicit val directEC: ExecutionContext = DirectExecutionContext(logger) - private var consumer: Consumer[T] = Consumer.InitializationInProgress(initializationKillSwitch) + private var consumer: Consumer[T] = Consumer.InitializationInProgress private val recoveringQueue: RecoveringQueue[T] = new RecoveringQueue( maxBlocked = maxBlockedOffer, @@ -1220,7 +1221,7 @@ object PekkoUtil extends HasLoggerName { ) private val lock = new Mutex() private val timer: Timer = new Timer() - private var shuttingDown: Boolean = false + private val shuttingDown: AtomicBoolean = new AtomicBoolean(false) private var shuttingDownTimerCancelled: Boolean = false private val donePromise: Promise[Done] = Promise() private val firstSuccessfulConsumerInitializationPromise: Promise[Unit] = Promise() @@ -1234,7 +1235,7 @@ object PekkoUtil extends HasLoggerName { firstSuccessfulConsumerInitializationPromise.future override def offer(elem: T): Future[Done] = blockingSynchronized { - if (shuttingDown) { + if (shuttingDown.get()) { Future.failed( new IllegalStateException( "Cannot offer new elements to the queue, after shutdown is initiated" @@ -1248,7 +1249,7 @@ object PekkoUtil extends HasLoggerName { } override def shutdown(): Unit = blockingSynchronized { - if (shuttingDown || shuttingDownTimerCancelled) { + if (shuttingDown.get() || shuttingDownTimerCancelled) { logger.debug("Already shutting down, nothing to do") } else { shuttingDownTimerCancelled = true @@ -1272,23 +1273,17 @@ object PekkoUtil extends HasLoggerName { private def shutdownStepTwo(): Unit = blockingSynchronized { logger.info("Shutdown initiated") - shuttingDown = true + shuttingDown.set(true) recoveringQueue.shutdown() consumer match { case Consumer.Initialized(c) => logger.info("Consumer shutdown initiated") c.shutdown() - case Consumer.InitializationInProgress(killSwitch) => - killSwitch match { - case Some(kill) => - logger.info( - "Consumer initialization is in progress, invoking initialization kill switch..." - ) - kill() - case None => - logger.debug("Consumer initialization is in progress, delaying shutdown...") - } + case Consumer.InitializationInProgress => + logger.debug( + "Consumer initialization is in progress, shutdown signal will be propagated to consumer..." + ) case Consumer.WaitingForRetry => logger.info("Interrupting wait for initialization retry, shutdown complete") @@ -1298,8 +1293,12 @@ object PekkoUtil extends HasLoggerName { private def initializeConsumer(attempt: Int = 1): Unit = blockingSynchronized { logger.info("Initializing consumer...") - consumer = Consumer.InitializationInProgress(initializationKillSwitch) - consumerFactory(recoveringQueue.commit) + consumer = Consumer.InitializationInProgress + consumerFactory(recoveringQueue.commit)(() => shuttingDown.get()) + .flatMap { innerFuture => + firstSuccessfulConsumerInitializationPromise.trySuccess(()).discard + innerFuture + }(directEC) .onComplete(consumerInitialized(_, attempt))(directEC) } @@ -1316,14 +1315,13 @@ object PekkoUtil extends HasLoggerName { logger.error(s"Exception caught while recovering: ${t.getMessage}. Shutting down.", t) shutdown() } - if (shuttingDown) { + if (shuttingDown.get()) { logger.info( "Consumer initialized, but since shutdown already in progress, consumer shutdown initiated" ) queueConsumer.futureQueue.shutdown() queueConsumer.futureQueue.done.onComplete(consumerTerminated)(directEC) } else { - firstSuccessfulConsumerInitializationPromise.trySuccess(()).discard logger.info("Consumer initialized") consumer = Consumer.Initialized( new FutureQueuePullProxy( @@ -1339,7 +1337,7 @@ object PekkoUtil extends HasLoggerName { } case Failure(failure) => - if (shuttingDown) { + if (shuttingDown.get()) { logger.info( "Consumer initialization failed, but not retrying anymore since already shutting down", failure, @@ -1373,7 +1371,7 @@ object PekkoUtil extends HasLoggerName { case Failure(failure) => logger.info("Consumer terminated with a failure", failure) } - if (shuttingDown) { + if (shuttingDown.get()) { logger.info("Terminated (consumer terminated), shutdown complete") discard(donePromise.trySuccess(Done)) } else { @@ -1400,8 +1398,7 @@ object PekkoUtil extends HasLoggerName { } private object Consumer { - final case class InitializationInProgress(killSwitch: Option[() => Unit]) - extends Consumer[Nothing] + case object InitializationInProgress extends Consumer[Nothing] case object WaitingForRetry extends Consumer[Nothing] diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/util/ShardedSequentialProcessingQueue.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/util/ShardedSequentialProcessingQueue.scala new file mode 100644 index 0000000000..0ee6a0930c --- /dev/null +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/util/ShardedSequentialProcessingQueue.scala @@ -0,0 +1,174 @@ +// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.util + +import cats.data.{EitherT, Nested} +import cats.syntax.functor.* +import com.digitalasset.canton.concurrent.FutureSupervisor +import com.digitalasset.canton.config.ProcessingTimeout +import com.digitalasset.canton.discard.Implicits.* +import com.digitalasset.canton.lifecycle.{ + FlagCloseable, + FutureUnlessShutdown, + PromiseUnlessShutdown, +} +import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} +import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} +import com.digitalasset.canton.tracing.TraceContext +import com.digitalasset.canton.util.Thereafter.syntax.ThereafterOps +import com.google.common.annotations.VisibleForTesting + +import scala.collection.concurrent.TrieMap +import scala.concurrent.ExecutionContext + +/** A processing queue that allows scheduling work associated with a particular identifier. + * + * It comes in two flavours: + * - [[GarbageCollectedShardedSequentialProcessingQueue]] when the number of identifiers is big + * and each identifier is usually short-lived. + * - [[NonGarbageCollectedShardedSequentialProcessingQueue]] when the number of identifiers is + * small and each identifier is potentially long-lived. + * + * @tparam Ident + * The type of the identifiers + */ +sealed trait ShardedSequentialProcessingQueue[Ident] { + def executeUS[A](id: Ident)(action: => FutureUnlessShutdown[A], taskName: String)(implicit + traceContext: TraceContext + ): FutureUnlessShutdown[A] + + def executeEUS[A, B](id: Ident)(action: => EitherT[FutureUnlessShutdown, A, B], taskName: String)( + implicit traceContext: TraceContext + ): EitherT[FutureUnlessShutdown, A, B] = EitherT(executeUS(id)(action.value, taskName)) + + @VisibleForTesting + def isQueueEmpty(id: Ident): Boolean + + @VisibleForTesting + def areQueuesEmpty: Boolean +} + +/** A processing queue that runs units of work for a particular identifier in a sequential manner, + * but allows parallel processing of work for different identifiers. If a unit of work fails or + * throws an exception, subsequent units of work for the same identifier are not executed. + * + * Use this queue when the number of identifiers is big and each identifier is usually short-lived. + * + * @tparam Ident + * The type of the identifiers + */ +class GarbageCollectedShardedSequentialProcessingQueue[Ident](implicit ec: ExecutionContext) + extends ShardedSequentialProcessingQueue[Ident] { + + @VisibleForTesting + val processingQueuePerId = new TrieMap[Ident, FutureUnlessShutdown[Unit]]() + + override def isQueueEmpty(id: Ident): Boolean = !processingQueuePerId.isDefinedAt(id) + override def areQueuesEmpty: Boolean = processingQueuePerId.isEmpty + + override def executeUS[A]( + id: Ident + )(action: => FutureUnlessShutdown[A], taskName: String)(implicit + traceContext: TraceContext + ): FutureUnlessShutdown[A] = { + val processingPromise: PromiseUnlessShutdown[Unit] = PromiseUnlessShutdown.unsupervised() + val processingFuture = processingPromise.futureUS + + val previousProcessingFuture: FutureUnlessShutdown[Unit] = processingQueuePerId + .put(id, processingFuture) + .getOrElse(FutureUnlessShutdown.unit) + + previousProcessingFuture.flatMap(_ => action).thereafter { result => + processingPromise.complete(Nested(result).void.value) + // cleanup the processing queue + processingQueuePerId + .updateWith(id) { + case Some(`processingFuture`) => + // if the "processing queue" still contains the same future that we put in, we can remove the entry from the map + None + case Some(other) => + // some other future was put into the map, retain it + Some(other) + case None => + // the entry was already removed, nothing to do + None + } + .discard + + } + } +} + +/** A processing queue that runs units of work for a particular identifier in a sequential manner, + * but allows parallel processing of work for different identifiers. For an identifier, the + * behavior of the queue when one task fails or throws an exception depends on the failureMode. + * + * If a unit of work fails or throws an exception, subsequent units of work for the same identifier + * are not executed. + * + * IMPORTANT NOTE: The underlying execution queues for the identifiers are not cleaned up. If you + * have many short-lived identifier, then [[GarbageCollectedShardedSequentialProcessingQueue]] + * should be preferred. + * + * Use this queue when the number of identifiers is small and each identifier is potentially + * long-lived. + * + * @param name + * For logging purposes + * @param logTaskTiming + * If true logs wait and run time for each of the tasks + * @param failureMode + * How the queue handles the execution of tasks after a previous task had failed + * @tparam Ident + * The type of the identifiers + */ +class NonGarbageCollectedShardedSequentialProcessingQueue[Ident: Pretty]( + private val name: String, + futureSupervisor: FutureSupervisor, + override val timeouts: ProcessingTimeout, + override val loggerFactory: NamedLoggerFactory, + private val logTaskTiming: Boolean, + failureMode: FailureMode, +) extends ShardedSequentialProcessingQueue[Ident] + with PrettyPrinting + with NamedLogging + with FlagCloseable { + + private val processingQueues = new TrieMap[Ident, SimpleExecutionQueue]() + + override def isQueueEmpty(id: Ident): Boolean = processingQueues.get(id).fold(true)(_.isEmpty) + override def areQueuesEmpty: Boolean = processingQueues.forall { case (_, queue) => + queue.isEmpty + } + + override def executeUS[A]( + id: Ident + )(action: => FutureUnlessShutdown[A], taskName: String)(implicit + traceContext: TraceContext + ): FutureUnlessShutdown[A] = { + + val queue = processingQueues.getOrElseUpdate( + id, + new SimpleExecutionQueue( + name = s"$name-$id", + futureSupervisor = futureSupervisor, + timeouts = timeouts, + loggerFactory = loggerFactory, + logTaskTiming = logTaskTiming, + failureMode = failureMode, + ), + ) + + queue.executeUS(action, taskName) + } + + override protected def pretty + : Pretty[NonGarbageCollectedShardedSequentialProcessingQueue[Ident]] = + prettyOfClass( + param("tasks", _.processingQueues) + ) + + override protected def onClosed(): Unit = + processingQueues.readOnlySnapshot().values.foreach(_.onClosed()) +} diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/util/SimpleExecutionQueue.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/util/SimpleExecutionQueue.scala index 6c4d60d427..43d8a637a1 100644 --- a/canton/community/base/src/main/scala/com/digitalasset/canton/util/SimpleExecutionQueue.scala +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/util/SimpleExecutionQueue.scala @@ -13,6 +13,11 @@ import com.digitalasset.canton.lifecycle.UnlessShutdown.AbortedDueToShutdown import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} import com.digitalasset.canton.logging.{ErrorLoggingContext, NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.tracing.TraceContext +import com.digitalasset.canton.util.FailureMode.{ + ContinueAfterFailure, + CrashAfterFailure, + StopAfterFailure, +} import com.digitalasset.canton.util.ShowUtil.* import com.digitalasset.canton.util.SimpleExecutionQueue.TaskCell import com.digitalasset.canton.util.Thereafter.syntax.* @@ -27,18 +32,21 @@ import scala.util.{Failure, Success, Try} */ sealed trait FailureMode -/** Causes the queue to crash the entire process if a task is scheduled after a previously failed - * task. - */ -object CrashAfterFailure extends FailureMode +object FailureMode { -/** Causes the queue to not process any further tasks after a previously failed task. - */ -object StopAfterFailure extends FailureMode + /** Causes the queue to crash the entire process if a task is scheduled after a previously failed + * task. + */ + object CrashAfterFailure extends FailureMode -/** The queue will continue the execution of tasks even if previous tasks had failed. - */ -object ContinueAfterFailure extends FailureMode + /** Causes the queue to not process any further tasks after a previously failed task. + */ + object StopAfterFailure extends FailureMode + + /** The queue will continue the execution of tasks even if previous tasks had failed. + */ + object ContinueAfterFailure extends FailureMode +} /** Functions executed with this class will only run when all previous calls have completed * executing. This can be used when async code should not be run concurrently. @@ -86,7 +94,7 @@ class SimpleExecutionQueue( timeouts, loggerFactory, logTaskTiming, - if (crashOnFailure) CrashAfterFailure else StopAfterFailure, + failureMode = if (crashOnFailure) CrashAfterFailure else StopAfterFailure, ) protected val directExecutionContext: DirectExecutionContext = diff --git a/canton/community/base/src/main/scala/com/digitalasset/canton/util/TracedPossiblyPrevalidated.scala b/canton/community/base/src/main/scala/com/digitalasset/canton/util/TracedPossiblyPrevalidated.scala new file mode 100644 index 0000000000..beb681b233 --- /dev/null +++ b/canton/community/base/src/main/scala/com/digitalasset/canton/util/TracedPossiblyPrevalidated.scala @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.util + +import com.digitalasset.canton.tracing.{TraceContext, Traced} + +/** Tag an object to have already passed some validation steps + * + * We use this specifically to optimistically validate signatures against older topology snapshots. + * If they pass validation (signature is valid and key is valid), we can then skip the more + * expensive validation steps and only validate the correct key usage. + */ +final case class TracedPossiblyPrevalidated[E](value: E, prevalidated: Boolean)(implicit + val traceContext: TraceContext +) { + def map[F](fn: E => F): TracedPossiblyPrevalidated[F] = + TracedPossiblyPrevalidated(fn(value), prevalidated) + + def tracedValue: Traced[E] = Traced(value)(traceContext) + + def withTraceContext[F](fn: TraceContext => E => F): F = fn(traceContext)(value) + +} + +object TracedPossiblyPrevalidated { + def notValidated[T](value: T)(implicit + traceContext: TraceContext + ): TracedPossiblyPrevalidated[T] = + TracedPossiblyPrevalidated(value, prevalidated = false) +} diff --git a/canton/community/common/src/main/resources/db/migration/canton/h2/dev/V5_3__tea_initial.sha256 b/canton/community/common/src/main/resources/db/migration/canton/h2/dev/V5_3__tea_initial.sha256 new file mode 100644 index 0000000000..8ae5e4d649 --- /dev/null +++ b/canton/community/common/src/main/resources/db/migration/canton/h2/dev/V5_3__tea_initial.sha256 @@ -0,0 +1 @@ +4d9b1a8be5e6e28261f6306c7b4a356e4172cfd98512185b72c401901cbbbfe9 diff --git a/canton/community/common/src/main/resources/db/migration/canton/h2/dev/V5_3__tea_initial.sql b/canton/community/common/src/main/resources/db/migration/canton/h2/dev/V5_3__tea_initial.sql new file mode 100644 index 0000000000..dd6b236801 --- /dev/null +++ b/canton/community/common/src/main/resources/db/migration/canton/h2/dev/V5_3__tea_initial.sql @@ -0,0 +1,75 @@ +-- Traffic enforcement tables +create table par_traffic_enforcement_event +( + -- Increasing sequence number for each event + -- This is used to provide a total ordering of events + sequence_nb bigint generated always as identity, + -- Event source is a small integer that identifies the source of the event (e.g: 0 == LEDGER_API, 1 = TEA_API) + event_source smallint not null, + -- Event type is a small integer that identifies the type of event (e.g 0 == USAGE for standard traffic usage) + event_type smallint not null, + -- Event ID is a unique identifier per event_source, it is used for event deduplication + event_id varchar not null, + -- Account ID is the identifier of the account that the event applies to + account_id varchar not null, + -- Amount is the amount of traffic tied to the event, it can be positive or negative. + -- The actual semantics depend on the event type. + amount bigint not null, + -- Timestamp when the event was recorded + -- Critically this is NOT the record time of a transaction. It is generated by the database. + -- Note that it is possible for events to have timestamps reversed compared to their sequence number + timestamp bigint not null, + -- Events are unique per (event_source, event_id). + primary key (event_source, event_id) +); + +-- For fast querying by sequence number +create index idx_event_sequence on par_traffic_enforcement_event (sequence_nb); +-- For fast querying by account ID, event type and timestamp +create index account_id_timestamp_index on par_traffic_enforcement_event (account_id, event_type, timestamp); + +-- Balance table for each account +-- Contains one row per account, continuously updated with the total debits and credits for that account +-- as events are processed. +create table par_traffic_enforcement_balance +( + -- Account ID is the identifier of the account that the balance applies to + account_id varchar not null, + -- Event sequence number is the sequence number of the last event that was applied to the balance + event_sequence_nb bigint not null, + -- Matches the event_type of the event table. There's one row per account and event type. + event_type smallint not null, + -- Total debits is the total amount of traffic that has been debited from the account + total_debits bigint not null, + -- Total credits is the total amount of traffic that has been credited to the account + total_credits bigint not null, + -- Updated at is the timestamp of the last update to the balance. + -- It is at least as recent as the timestamp of the last event that was applied to the balance. + updated_at bigint not null, + -- One event type per account + primary key (account_id, event_type) +); + +-- Offset tables used by pekko projection +-- See https://pekko.apache.org/docs/pekko-projection/current/jdbc.html#schema +create table pekko_projection_offset_store +( + projection_name varchar not null, + projection_key varchar not null, + current_offset varchar not null, + manifest varchar not null, + mergeable boolean not null, + last_updated bigint not null, + primary key (projection_name, projection_key) +); + +create index projection_name_index on pekko_projection_offset_store (projection_name); + +create table pekko_projection_management +( + projection_name varchar not null, + projection_key varchar not null, + paused boolean not null, + last_updated bigint not null, + primary key (projection_name, projection_key) +); \ No newline at end of file diff --git a/canton/community/common/src/main/resources/db/migration/canton/h2/stable/V5_2__dabft_partitioning.sha256 b/canton/community/common/src/main/resources/db/migration/canton/h2/stable/V5_2__dabft_partitioning.sha256 new file mode 100644 index 0000000000..82524ad01b --- /dev/null +++ b/canton/community/common/src/main/resources/db/migration/canton/h2/stable/V5_2__dabft_partitioning.sha256 @@ -0,0 +1 @@ +327fa628515fb0a00c639d2718cefaa37ad1566203ae157e6236a5914afbbc9e diff --git a/canton/community/common/src/main/resources/db/migration/canton/h2/stable/V5_2__dabft_partitioning.sql b/canton/community/common/src/main/resources/db/migration/canton/h2/stable/V5_2__dabft_partitioning.sql new file mode 100644 index 0000000000..7801bf7980 --- /dev/null +++ b/canton/community/common/src/main/resources/db/migration/canton/h2/stable/V5_2__dabft_partitioning.sql @@ -0,0 +1,8 @@ +drop table ord_availability_batch; +create table ord_availability_batch( + id varchar not null, + batch binary large object not null, + -- assigned at batch creation and used to calculate epoch expiration + epoch_number bigint not null, + primary key (epoch_number, id) +); diff --git a/canton/community/common/src/main/resources/db/migration/canton/postgres/dev/V5_3__tea_initial.sha256 b/canton/community/common/src/main/resources/db/migration/canton/postgres/dev/V5_3__tea_initial.sha256 new file mode 100644 index 0000000000..18b4877df7 --- /dev/null +++ b/canton/community/common/src/main/resources/db/migration/canton/postgres/dev/V5_3__tea_initial.sha256 @@ -0,0 +1 @@ +65699df26b7762f024355555db78f6d6e6f363fb2f2d9684fa5bcbbd09d589c4 diff --git a/canton/community/common/src/main/resources/db/migration/canton/postgres/dev/V5_3__tea_initial.sql b/canton/community/common/src/main/resources/db/migration/canton/postgres/dev/V5_3__tea_initial.sql new file mode 100644 index 0000000000..d4c3323032 --- /dev/null +++ b/canton/community/common/src/main/resources/db/migration/canton/postgres/dev/V5_3__tea_initial.sql @@ -0,0 +1,75 @@ +-- Traffic enforcement tables +create table par_traffic_enforcement_event +( + -- Increasing sequence number for each event + -- This is used to provide a total ordering of events + sequence_nb bigint generated always as identity, + -- Event source is a small integer that identifies the source of the event (e.g: 0 == LEDGER_API, 1 = TEA_API) + event_source smallint not null, + -- Event type is a small integer that identifies the type of event (e.g 0 == USAGE for standard traffic usage) + event_type smallint not null, + -- Event ID is a unique identifier per event_source, it is used for event deduplication + event_id varchar collate "C" not null, + -- Account ID is the identifier of the account that the event applies to + account_id varchar collate "C" not null, + -- Amount is the amount of traffic tied to the event, it can be positive or negative. + -- The actual semantics depend on the event type. + amount bigint not null, + -- Timestamp when the event was recorded + -- Critically this is NOT the record time of a transaction. It is generated by the database. + -- Note that it is possible for events to have timestamps reversed compared to their sequence number + timestamp bigint not null, + -- Events are unique per (event_source, event_id). + primary key (event_source, event_id) +); + +-- For fast querying by sequence number +create index idx_event_sequence on par_traffic_enforcement_event (sequence_nb); +-- For fast querying by account ID, event type and timestamp +create index account_id_timestamp_index on par_traffic_enforcement_event (account_id, event_type, timestamp); + +-- Balance table for each account +-- Contains one row per account, continuously updated with the total debits and credits for that account +-- as events are processed. +create table par_traffic_enforcement_balance +( + -- Account ID is the identifier of the account that the balance applies to + account_id varchar collate "C" not null, + -- Event sequence number is the sequence number of the last event that was applied to the balance + event_sequence_nb bigint not null, + -- Matches the event_type of the event table. There's one row per account and event type. + event_type smallint not null, + -- Total debits is the total amount of traffic that has been debited from the account + total_debits bigint not null, + -- Total credits is the total amount of traffic that has been credited to the account + total_credits bigint not null, + -- Updated at is the timestamp of the last update to the balance. + -- It is at least as recent as the timestamp of the last event that was applied to the balance. + updated_at bigint not null, + -- One event type per account + primary key (account_id, event_type) +); + +-- Offset tables used by pekko projection +-- See https://pekko.apache.org/docs/pekko-projection/current/jdbc.html#schema +create table pekko_projection_offset_store +( + projection_name varchar collate "C" not null, + projection_key varchar collate "C" not null, + current_offset varchar collate "C" not null, + manifest varchar collate "C" not null, + mergeable boolean not null, + last_updated bigint not null, + primary key (projection_name, projection_key) +); + +create index projection_name_index on pekko_projection_offset_store (projection_name); + +create table pekko_projection_management +( + projection_name varchar collate "C" not null, + projection_key varchar collate "C" not null, + paused boolean not null, + last_updated bigint not null, + primary key (projection_name, projection_key) +); \ No newline at end of file diff --git a/canton/community/common/src/main/resources/db/migration/canton/postgres/stable/V5_1__optimize_contract_key_lookup_index.sha256 b/canton/community/common/src/main/resources/db/migration/canton/postgres/stable/V5_1__optimize_contract_key_lookup_index.sha256 new file mode 100644 index 0000000000..a4578f2695 --- /dev/null +++ b/canton/community/common/src/main/resources/db/migration/canton/postgres/stable/V5_1__optimize_contract_key_lookup_index.sha256 @@ -0,0 +1 @@ +12fb43a202d6a5927ba49d647dcfc73aff99a5807f3e5d0ed506f8be09f916be diff --git a/canton/community/common/src/main/resources/db/migration/canton/postgres/stable/V5_1__optimize_contract_key_lookup_index.sql b/canton/community/common/src/main/resources/db/migration/canton/postgres/stable/V5_1__optimize_contract_key_lookup_index.sql new file mode 100644 index 0000000000..8b0b70b1f7 --- /dev/null +++ b/canton/community/common/src/main/resources/db/migration/canton/postgres/stable/V5_1__optimize_contract_key_lookup_index.sql @@ -0,0 +1,6 @@ +-- Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- Recreate the activate contract key index with internal_contract_id as an included column. +drop index lapi_events_activate_contract_key_idx; +create index lapi_events_activate_contract_key_idx on lapi_events_activate_contract using btree (create_key_hash, event_sequential_id) include (internal_contract_id) where create_key_hash is not null; diff --git a/canton/community/common/src/main/resources/db/migration/canton/postgres/stable/V5_2__dabft_partitioning.sha256 b/canton/community/common/src/main/resources/db/migration/canton/postgres/stable/V5_2__dabft_partitioning.sha256 new file mode 100644 index 0000000000..d03ff16a35 --- /dev/null +++ b/canton/community/common/src/main/resources/db/migration/canton/postgres/stable/V5_2__dabft_partitioning.sha256 @@ -0,0 +1 @@ +d4263917805c62bd820e184206f217fa074e727ffa7414a843a71adec87b1baa diff --git a/canton/community/common/src/main/resources/db/migration/canton/postgres/stable/V5_2__dabft_partitioning.sql b/canton/community/common/src/main/resources/db/migration/canton/postgres/stable/V5_2__dabft_partitioning.sql new file mode 100644 index 0000000000..07835ba25b --- /dev/null +++ b/canton/community/common/src/main/resources/db/migration/canton/postgres/stable/V5_2__dabft_partitioning.sql @@ -0,0 +1,293 @@ +drop view debug.ord_epochs; +drop view debug.ord_availability_batch; +drop view debug.ord_pbft_messages_in_progress; +drop view debug.ord_pbft_messages_completed; +drop view debug.ord_metadata_output_blocks; +drop view debug.ord_metadata_output_epochs; +drop view debug.ord_leader_selection_state; + +alter table ord_epochs rename to ord_epochs_old; +alter table ord_availability_batch rename to ord_availability_batch_old; +alter table ord_pbft_messages_in_progress rename to ord_pbft_messages_in_progress_old; +alter table ord_pbft_messages_completed rename to ord_pbft_messages_completed_old; +alter table ord_metadata_output_blocks rename to ord_metadata_output_blocks_old; +alter table ord_metadata_output_epochs rename to ord_metadata_output_epochs_old; +alter table ord_leader_selection_state rename to ord_leader_selection_state_old; + +-- Stores metadata for epochs +-- Individual blocks/transactions exist in separate table +create table ord_epochs ( + -- strictly-increasing, contiguous epoch number + epoch_number bigint not null primary key, + -- first block sequence number (globally) of the epoch + start_block_number bigint not null, + -- number of total blocks in the epoch + epoch_length bigint not null, + -- Sequencing instant of the topology snapshot in force for the epoch + topology_ts bigint not null, + -- whether the epoch is in progress + in_progress bool not null +) partition by range (epoch_number); + +create table ord_availability_batch ( + id varchar collate "C" not null, + batch bytea not null, + -- assigned at batch creation and used to calculate epoch expiration + epoch_number bigint not null, + primary key (epoch_number, id) +) partition by range (epoch_number); + +-- messages stored during the progress of a block possibly across different pbft views +create table ord_pbft_messages_in_progress( + -- global sequence number of the ordered block + block_number bigint not null, + + -- epoch number of the block + epoch_number bigint not null, + + -- view number + view_number bigint not null, + + -- pbft message for the block + message bytea not null, + + -- pbft message discriminator (0 = pre-prepare, 1 = prepare, 2 = commit) + discriminator smallint not null, + + -- sender of the message + from_sequencer_id varchar collate "C" not null, + + -- for each block number, we only expect one message of each kind for the same sender and view number. + primary key (epoch_number, block_number, view_number, from_sequencer_id, discriminator) +) partition by range (epoch_number); + +create table ord_pbft_messages_completed( + -- global sequence number of the ordered block + block_number bigint not null, + + -- epoch number of the block + epoch_number bigint not null, + + -- pbft message for the block + message bytea not null, + + -- pbft message discriminator (0 = pre-prepare, 2 = commit) + discriminator smallint not null, + + -- sender of the message + from_sequencer_id varchar collate "C" not null, + + -- for each completed block number, we only expect one message of each kind for the same sender. + -- in the case of pre-prepare, we only expect one message for the whole block, but for simplicity + -- we won't differentiate that at the database level. + primary key (epoch_number, block_number, from_sequencer_id, discriminator) +) partition by range (epoch_number); + +-- Stores metadata for blocks that have been assigned timestamps in the output module +create table ord_metadata_output_blocks ( + epoch_number bigint not null, + block_number bigint not null, + bft_ts bigint not null, + primary key (epoch_number, block_number) +) partition by range (epoch_number); + +create index idx_ord_metadata_output_blocks_bft_ts on ord_metadata_output_blocks(bft_ts); +create index idx_ord_metadata_output_blocks_block_number on ord_metadata_output_blocks(block_number); + +-- Stores output metadata for epochs +create table ord_metadata_output_epochs ( + epoch_number bigint not null primary key, + could_alter_ordering_topology bool not null +) partition by range (epoch_number); + +create table ord_leader_selection_state ( + epoch_number bigint not null primary key, + state bytea not null +) partition by range (epoch_number); + +create function create_partitions_for_epoch_range( + parent_table regclass, + min_val bigint, + max_val bigint +) returns void as $$ +declare + partition_size bigint := ${initialBftOrdererTablesPartitionSize}; + current_val bigint; + from_val bigint; + next_val bigint; + final_val bigint; + partition_index bigint; + partition_name text; +begin + if partition_size <= 0 then + raise exception 'partition_size must be positive'; + end if; + + current_val := floor(min_val::numeric / partition_size)::bigint * partition_size; + + -- creates one extra partition after the partition containing max_val + final_val := max_val + partition_size; + + while current_val <= final_val loop + next_val := current_val + partition_size; + partition_index := current_val / partition_size; + partition_name := format( + '%s_p%s', + replace(parent_table::text, '.', '_'), + partition_index + ); + + -- the first partition will start at -1 + if current_val = 0 then + from_val := -1; + else + from_val := current_val; + end if; + + execute format( + 'create table if not exists %I partition of %s for values from (%L) to (%L)', + partition_name, + parent_table::text, + from_val, + next_val + ); + + current_val := next_val; + end loop; +end; +$$ language plpgsql; + +do $$ +declare + min_epoch bigint; + max_epoch bigint; +begin + -- Use a shared epoch range across ord_epochs_old and ord_availability_batch_old. + -- ord_availability_batch_old can contain epochs ahead of ord_epochs_old, + -- while other transient tables (ord_pbft_messages_in_progress) may be empty during the migration. + select min(epoch_number), max(epoch_number) + into min_epoch, max_epoch + from ( + select epoch_number from ord_epochs_old + union all + select epoch_number from ord_availability_batch_old + ) all_epochs; + + if min_epoch is null or max_epoch is null then + min_epoch := 0; + max_epoch := 0; + end if; + + perform create_partitions_for_epoch_range('ord_epochs'::regclass, min_epoch, max_epoch); + perform create_partitions_for_epoch_range('ord_availability_batch'::regclass, min_epoch, max_epoch); + perform create_partitions_for_epoch_range('ord_pbft_messages_in_progress'::regclass, min_epoch, max_epoch); + perform create_partitions_for_epoch_range('ord_pbft_messages_completed'::regclass, min_epoch, max_epoch); + perform create_partitions_for_epoch_range('ord_metadata_output_blocks'::regclass, min_epoch, max_epoch); + perform create_partitions_for_epoch_range('ord_metadata_output_epochs'::regclass, min_epoch, max_epoch); + perform create_partitions_for_epoch_range('ord_leader_selection_state'::regclass, min_epoch, max_epoch); +end $$; + +insert into ord_epochs (epoch_number, start_block_number, epoch_length, topology_ts, in_progress) +select epoch_number, start_block_number, epoch_length, topology_ts, in_progress +from ord_epochs_old; + +insert into ord_availability_batch (id, batch, epoch_number) +select id, batch, epoch_number +from ord_availability_batch_old; + +insert into ord_pbft_messages_in_progress (block_number, epoch_number, view_number, message, discriminator, from_sequencer_id) +select block_number, epoch_number, view_number, message, discriminator, from_sequencer_id +from ord_pbft_messages_in_progress_old; + +insert into ord_pbft_messages_completed (block_number, epoch_number, message, discriminator, from_sequencer_id) +select block_number, epoch_number, message, discriminator, from_sequencer_id +from ord_pbft_messages_completed_old; + +insert into ord_metadata_output_blocks (epoch_number, block_number, bft_ts) +select epoch_number, block_number, bft_ts +from ord_metadata_output_blocks_old; + +insert into ord_metadata_output_epochs (epoch_number, could_alter_ordering_topology) +select epoch_number, could_alter_ordering_topology +from ord_metadata_output_epochs_old; + +insert into ord_leader_selection_state (epoch_number, state) +select epoch_number, state +from ord_leader_selection_state_old; + +drop table ord_epochs_old; +drop table ord_availability_batch_old; +drop table ord_pbft_messages_in_progress_old; +drop table ord_pbft_messages_completed_old; +drop table ord_metadata_output_blocks_old; +drop table ord_metadata_output_epochs_old; +drop table ord_leader_selection_state_old; + +create table ord_partition_size_history ( + epoch_number bigint not null primary key, + partition_number bigint not null, + partition_size integer not null +); +insert into ord_partition_size_history(epoch_number, partition_number, partition_size) + values (0, 0, ${initialBftOrdererTablesPartitionSize}); + +create or replace view debug.ord_epochs as +select + epoch_number, + start_block_number, + epoch_length, + debug.canton_timestamp(topology_ts) as topology_ts, + in_progress +from ord_epochs; + +create or replace view debug.ord_availability_batch as +select + id, + batch, + epoch_number +from ord_availability_batch; + +create or replace view debug.ord_pbft_messages_in_progress as +select + block_number, + epoch_number, + view_number, + message, + discriminator, + from_sequencer_id +from ord_pbft_messages_in_progress; + +create or replace view debug.ord_pbft_messages_completed as +select + block_number, + epoch_number, + message, + discriminator, + from_sequencer_id +from ord_pbft_messages_completed; + +create or replace view debug.ord_metadata_output_blocks as +select + epoch_number, + block_number, + debug.canton_timestamp(bft_ts) as bft_ts +from ord_metadata_output_blocks; + +create or replace view debug.ord_metadata_output_epochs as +select + epoch_number, + could_alter_ordering_topology +from ord_metadata_output_epochs; + +create or replace view debug.ord_leader_selection_state as +select + epoch_number, + state +from ord_leader_selection_state; + +create or replace view debug.ord_partition_size_history as +select + epoch_number, + partition_number, + partition_size +from ord_partition_size_history; diff --git a/canton/community/common/src/main/resources/db/migration/canton/postgres/table_settings/R___default_auto_vacuum_analyze_table_settings.sha256 b/canton/community/common/src/main/resources/db/migration/canton/postgres/table_settings/R___default_auto_vacuum_analyze_table_settings.sha256 index 005d982cdb..9b76b97111 100644 --- a/canton/community/common/src/main/resources/db/migration/canton/postgres/table_settings/R___default_auto_vacuum_analyze_table_settings.sha256 +++ b/canton/community/common/src/main/resources/db/migration/canton/postgres/table_settings/R___default_auto_vacuum_analyze_table_settings.sha256 @@ -1 +1 @@ -ba9abfee60cf3b9023278798a98247376a623edc14fc6873c01e0c9cdfaa3491 +33e30818cb98745e054432c6e6cd015c7160cd1918bcf136cd23a338f32c5d7b diff --git a/canton/community/common/src/main/resources/db/migration/canton/postgres/table_settings/R___default_auto_vacuum_analyze_table_settings.sql b/canton/community/common/src/main/resources/db/migration/canton/postgres/table_settings/R___default_auto_vacuum_analyze_table_settings.sql index 6b371faf21..2ac380c719 100644 --- a/canton/community/common/src/main/resources/db/migration/canton/postgres/table_settings/R___default_auto_vacuum_analyze_table_settings.sql +++ b/canton/community/common/src/main/resources/db/migration/canton/postgres/table_settings/R___default_auto_vacuum_analyze_table_settings.sql @@ -55,65 +55,3 @@ alter table seq_in_flight_aggregation autovacuum_freeze_max_age = 600000000, autovacuum_freeze_table_age = 600000000 ); - --- ====== BFT ordering autovacuum settings ===== -alter table ord_epochs - set ( - autovacuum_freeze_min_age = 1000000, - autovacuum_freeze_max_age = 600000000, - autovacuum_freeze_table_age = 600000000 - ); - -alter table ord_availability_batch - set ( - autovacuum_freeze_min_age = 1000000, - autovacuum_freeze_max_age = 600000000, - autovacuum_freeze_table_age = 600000000 - ); - -alter table ord_pbft_messages_in_progress - set ( - autovacuum_vacuum_scale_factor = 0.0, - autovacuum_vacuum_threshold = 10000, - autovacuum_vacuum_cost_limit = 2000, - autovacuum_vacuum_cost_delay = 5, - autovacuum_vacuum_insert_scale_factor = 0.0, - autovacuum_vacuum_insert_threshold = 100000, - autovacuum_freeze_min_age = 1000000, - autovacuum_freeze_max_age = 600000000, - autovacuum_freeze_table_age = 600000000 - ); - -alter table ord_pbft_messages_completed - set ( - autovacuum_freeze_min_age = 1000000, - autovacuum_freeze_max_age = 600000000, - autovacuum_freeze_table_age = 600000000 - ); - -alter table ord_metadata_output_blocks - set ( - autovacuum_freeze_min_age = 1000000, - autovacuum_freeze_max_age = 600000000, - autovacuum_freeze_table_age = 600000000 - ); - -alter table ord_metadata_output_epochs - set ( - autovacuum_freeze_min_age = 1000000, - autovacuum_freeze_max_age = 600000000, - autovacuum_freeze_table_age = 600000000 - ); - -alter table ord_leader_selection_state - set ( - autovacuum_vacuum_scale_factor = 0.0, - autovacuum_vacuum_threshold = 10000, - autovacuum_vacuum_cost_limit = 2000, - autovacuum_vacuum_cost_delay = 5, - autovacuum_vacuum_insert_scale_factor = 0.0, - autovacuum_vacuum_insert_threshold = 100000, - autovacuum_freeze_min_age = 1000000, - autovacuum_freeze_max_age = 600000000, - autovacuum_freeze_table_age = 600000000 - ); diff --git a/canton/community/common/src/main/scala/com/digitalasset/canton/environment/CantonNodeBootstrap.scala b/canton/community/common/src/main/scala/com/digitalasset/canton/environment/CantonNodeBootstrap.scala index 3cf9311836..5742afb50a 100644 --- a/canton/community/common/src/main/scala/com/digitalasset/canton/environment/CantonNodeBootstrap.scala +++ b/canton/community/common/src/main/scala/com/digitalasset/canton/environment/CantonNodeBootstrap.scala @@ -63,7 +63,7 @@ import com.digitalasset.canton.lifecycle.{ } import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.metrics.ActiveRequestsMetrics.GrpcServerMetricsX -import com.digitalasset.canton.metrics.{DbStorageMetrics, DeclarativeApiMetrics} +import com.digitalasset.canton.metrics.{CryptoMetrics, DbStorageMetrics, DeclarativeApiMetrics} import com.digitalasset.canton.networking.grpc.{ CantonGrpcUtil, CantonMutableHandlerRegistry, @@ -181,6 +181,7 @@ trait BaseMetrics { new CacheMetrics("topology", openTelemetryMetricsFactory) def healthMetrics: HealthMetrics def storageMetrics: DbStorageMetrics + def cryptoMetrics: CryptoMetrics val declarativeApiMetrics: DeclarativeApiMetrics } @@ -492,6 +493,7 @@ abstract class CantonNodeBootstrapImpl[ ReleaseProtocolVersion.latest, arguments.futureSupervisor, arguments.clock, + arguments.metrics.cryptoMetrics, executionContext, bootstrapStageCallback.timeouts, arguments.config.parameters.batching, @@ -1268,6 +1270,7 @@ abstract class CantonNodeBootstrapImpl[ mapping, serial = None, keys, + namespacesToSignFor = Seq.empty, protocolVersion, expectFullAuthorization = true, waitToBecomeEffective = None, diff --git a/canton/community/common/src/main/scala/com/digitalasset/canton/topology/admin/grpc/GrpcTopologyManagerReadService.scala b/canton/community/common/src/main/scala/com/digitalasset/canton/topology/admin/grpc/GrpcTopologyManagerReadService.scala index 644de6ea87..efa8d4a987 100644 --- a/canton/community/common/src/main/scala/com/digitalasset/canton/topology/admin/grpc/GrpcTopologyManagerReadService.scala +++ b/canton/community/common/src/main/scala/com/digitalasset/canton/topology/admin/grpc/GrpcTopologyManagerReadService.scala @@ -724,13 +724,15 @@ class GrpcTopologyManagerReadService( override def exportTopologySnapshot( request: ExportTopologySnapshotRequest, responseObserver: StreamObserver[ExportTopologySnapshotResponse], - ): Unit = + ): Unit = { + implicit val traceContext: TraceContext = TraceContextGrpc.fromGrpcContext GrpcStreamingUtils.streamToClient[ExportTopologySnapshotResponse]( (out: OutputStream) => getTopologySnapshot(request, out), responseObserver, byteString => ExportTopologySnapshotResponse(byteString), processingTimeout.unbounded.duration, ) + } private def getTopologySnapshot( request: ExportTopologySnapshotRequest, @@ -759,13 +761,15 @@ class GrpcTopologyManagerReadService( override def exportTopologySnapshotV2( request: ExportTopologySnapshotV2Request, responseObserver: StreamObserver[ExportTopologySnapshotV2Response], - ): Unit = + ): Unit = { + implicit val traceContext: TraceContext = TraceContextGrpc.fromGrpcContext GrpcStreamingUtils.streamToClient[ExportTopologySnapshotV2Response]( (out: OutputStream) => getTopologySnapshotV2(request, out), responseObserver, byteString => ExportTopologySnapshotV2Response(byteString), processingTimeout.unbounded.duration, ) + } private def getTopologySnapshotV2( request: ExportTopologySnapshotV2Request, @@ -844,13 +848,15 @@ class GrpcTopologyManagerReadService( override def genesisState( request: GenesisStateRequest, responseObserver: StreamObserver[GenesisStateResponse], - ): Unit = + ): Unit = { + implicit val traceContext: TraceContext = TraceContextGrpc.fromGrpcContext GrpcStreamingUtils.streamToClient( (out: OutputStream) => getGenesisState(request.synchronizerStore, request.timestamp, out), responseObserver, byteString => GenesisStateResponse(byteString), processingTimeout.unbounded.duration, ) + } private def getGenesisState( filterSynchronizerStore: Option[StoreId], @@ -867,12 +873,15 @@ class GrpcTopologyManagerReadService( override def genesisStateV2( request: GenesisStateV2Request, responseObserver: StreamObserver[GenesisStateV2Response], - ): Unit = GrpcStreamingUtils.streamToClient( - (out: OutputStream) => getGenesisStateV2(request.synchronizerStore, request.timestamp, out), - responseObserver, - byteString => GenesisStateV2Response(byteString), - processingTimeout.unbounded.duration, - ) + ): Unit = { + implicit val traceContext: TraceContext = TraceContextGrpc.fromGrpcContext + GrpcStreamingUtils.streamToClient( + (out: OutputStream) => getGenesisStateV2(request.synchronizerStore, request.timestamp, out), + responseObserver, + byteString => GenesisStateV2Response(byteString), + processingTimeout.unbounded.duration, + ) + } private def getGenesisStateV2( filterSynchronizerStore: Option[StoreId], @@ -949,13 +958,16 @@ class GrpcTopologyManagerReadService( override def sequencerLsuState( request: SequencerLsuStateRequest, responseObserver: StreamObserver[SequencerLsuStateResponse], - ): Unit = GrpcStreamingUtils.streamToClient( - (out: OutputStream) => - getLogicalUpgradeState(request.synchronizerStore, request.timestamp, out), - responseObserver, - byteString => SequencerLsuStateResponse(byteString), - processingTimeout.unbounded.duration, - ) + ): Unit = { + implicit val traceContext: TraceContext = TraceContextGrpc.fromGrpcContext + GrpcStreamingUtils.streamToClient( + (out: OutputStream) => + getLogicalUpgradeState(request.synchronizerStore, request.timestamp, out), + responseObserver, + byteString => SequencerLsuStateResponse(byteString), + processingTimeout.unbounded.duration, + ) + } private def getLogicalUpgradeState( synchronizerStore: Option[StoreId], diff --git a/canton/community/common/src/main/scala/com/digitalasset/canton/topology/admin/grpc/GrpcTopologyManagerWriteService.scala b/canton/community/common/src/main/scala/com/digitalasset/canton/topology/admin/grpc/GrpcTopologyManagerWriteService.scala index e76da95d8b..d1e94e1007 100644 --- a/canton/community/common/src/main/scala/com/digitalasset/canton/topology/admin/grpc/GrpcTopologyManagerWriteService.scala +++ b/canton/community/common/src/main/scala/com/digitalasset/canton/topology/admin/grpc/GrpcTopologyManagerWriteService.scala @@ -148,6 +148,7 @@ class GrpcTopologyManagerWriteService( validatedMapping, serial, signingKeys, + namespacesToSignFor = Seq.empty, manager.managerVersion.serialization, expectFullAuthorization = mustFullyAuthorize, forceChanges = forceChanges, @@ -184,7 +185,7 @@ class GrpcTopologyManagerWriteService( extendedTransactions <- signedTxs.parTraverse(tx => targetManager - .extendSignature(tx, signingKeys, forceFlags) + .extendSignature(tx, signingKeys, namespacesToSignFor = Seq.empty, forceFlags) .leftWiden[RpcError] ) } yield extendedTransactions diff --git a/canton/community/common/src/main/scala/com/digitalasset/canton/util/GrpcStreamingUtils.scala b/canton/community/common/src/main/scala/com/digitalasset/canton/util/GrpcStreamingUtils.scala index e6b78de10d..c5cfbd1aa5 100644 --- a/canton/community/common/src/main/scala/com/digitalasset/canton/util/GrpcStreamingUtils.scala +++ b/canton/community/common/src/main/scala/com/digitalasset/canton/util/GrpcStreamingUtils.scala @@ -7,8 +7,10 @@ import better.files.* import better.files.File.newTemporaryFile import com.digitalasset.canton.ProtoDeserializationError import com.digitalasset.canton.config.DefaultProcessingTimeouts +import com.digitalasset.canton.discard.Implicits.DiscardOps import com.digitalasset.canton.grpc.ByteStringStreamObserverWithContext import com.digitalasset.canton.lifecycle.FutureUnlessShutdown +import com.digitalasset.canton.lifecycle.FutureUnlessShutdownImpl.thereafterFutureUnlessShutdown import com.digitalasset.canton.lifecycle.UnlessShutdown.{AbortedDueToShutdown, Outcome} import com.digitalasset.canton.logging.ErrorLoggingContext import com.digitalasset.canton.networking.grpc.CantonGrpcUtil.GrpcErrors @@ -21,28 +23,22 @@ import com.digitalasset.canton.version.{ VersioningCompanion, } import com.google.protobuf.ByteString -import io.grpc.Context -import io.grpc.stub.StreamObserver +import io.grpc.stub.{ServerCallStreamObserver, StreamObserver} +import io.grpc.{Context, Status} import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream import org.apache.pekko.NotUsed import org.apache.pekko.stream.scaladsl.{Source, Source as PekkoSource} -import java.io.{ - BufferedInputStream, - ByteArrayInputStream, - ByteArrayOutputStream, - InputStream, - OutputStream, - PipedInputStream, - PipedOutputStream, -} +import java.io.{InputStream, OutputStream, PipedInputStream, PipedOutputStream} import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference} import scala.annotation.tailrec -import scala.concurrent.duration.Duration +import scala.concurrent.duration.{Duration, DurationInt} import scala.concurrent.{Await, ExecutionContext, Future, Promise, blocking} +import scala.util.control.NonFatal import scala.util.{Failure, Success, Try} object GrpcStreamingUtils { + private[util] final val defaultChunkSize: Int = 1024 * 1024 * 2 // 2MB - This is half of the default max message size of gRPC @@ -313,22 +309,54 @@ object GrpcStreamingUtils { fromByteString: FromByteString[T], processingTimeout: Duration = DefaultProcessingTimeouts.unbounded.duration, chunkSizeO: Option[Int] = None, - )(implicit ec: ExecutionContext): Unit = { - val context = io.grpc.Context - .current() - .withCancellation() + )(implicit ec: ExecutionContext, loggingContext: ErrorLoggingContext): Unit = { + val context = io.grpc.Context.current().withCancellation() + val chunkSize = chunkSizeO.getOrElse(defaultChunkSize) + val pipedInput = new PipedInputStream(chunkSize) + // Introduced so that clients do not have to enclose calls in blocking + val blockingPipedOutput = new PipedOutputStream(pipedInput) { + override def write(b: Int): Unit = blocking(super.write(b)) + + override def write(b: Array[Byte], off: Int, len: Int): Unit = blocking { + super.write(b, off, len) + } + + override def write(b: Array[Byte]): Unit = blocking(super.write(b)) + } + + def closeQuietly(c: java.io.Closeable): Unit = Try(c.close()).discard - val outputStream = new ByteArrayOutputStream() context.run { () => - val processingResult = responseF(outputStream).map { _ => - val chunkSize = chunkSizeO.getOrElse(defaultChunkSize) - val inputStream = new ByteArrayInputStream(outputStream.toByteArray) + // Close both piped streams on context cancellation so the producer unblocks promptly + context.addListener( + (_: Context) => { + closeQuietly(pipedInput) + closeQuietly(blockingPipedOutput) + }, + (command: Runnable) => command.run(), + ) + + val consumerF = streamResponseChunks(context, responseObserver)( - new BufferedInputStream(inputStream), + pipedInput, chunkSize, fromByteString, ) + + val producerF = responseF(blockingPipedOutput).transform { result => + val closeResult = Try(blockingPipedOutput.close()) + result match { + case Success(_) => closeResult + case Failure(ex) => + closeResult.forFailed(ex.addSuppressed(_)) + result + } } + + val processingResult = producerF + .flatMap(_ => consumerF) + .thereafter(_ => closeQuietly(pipedInput)) + finishStream(context, responseObserver)(processingResult, processingTimeout) } } @@ -339,7 +367,7 @@ object GrpcStreamingUtils { fromByteString: FromByteString[T], processingTimeout: Duration = DefaultProcessingTimeouts.unbounded.duration, chunkSizeO: Option[Int] = None, - )(implicit ec: ExecutionContext): Unit = { + )(implicit ec: ExecutionContext, loggingContext: ErrorLoggingContext): Unit = { val file = newTemporaryFile() val context = io.grpc.Context @@ -347,7 +375,7 @@ object GrpcStreamingUtils { .withCancellation() context.run { () => - val processingResult = responseF(file).map { _ => + val processingResult = responseF(file).flatMap { _ => val chunkSize = chunkSizeO.getOrElse(defaultChunkSize) streamResponseChunks(context, responseObserver)( file.newInputStream.buffered(chunkSize), @@ -415,42 +443,123 @@ object GrpcStreamingUtils { read(Nil) } - private def streamResponseChunks[T]( + private[util] def streamResponseChunks[T]( context: Context.CancellableContext, responseObserver: StreamObserver[T], )( inputStream: InputStream, chunkSize: Int, fromByteString: FromByteString[T], - ): Unit = - inputStream.autoClosed { s => - Iterator - .continually(s.readNBytes(chunkSize)) + )(implicit + executionContext: ExecutionContext, + loggingContext: ErrorLoggingContext, + ): Future[Unit] = + withServerCallStreamObserverF(responseObserver) { scso => + val iter = Iterator + .continually(inputStream.readNBytes(chunkSize)) // Before pushing new chunks to the stream, keep checking that the context has not been cancelled // This avoids the server reading the entire dump file for nothing if the client has already cancelled .takeWhile(_.nonEmpty && !context.isCancelled) - .foreach { byteArray => - val chunk: ByteString = ByteString.copyFrom(byteArray) - responseObserver.onNext(fromByteString.toT(chunk)) - } + + val isWorkerRunning = new AtomicBoolean(false) + val allBytesWrittenPromise = Promise[Unit]() + + def runWorkerInBackground(): Unit = FutureUtil.doNotAwait( + Future { + // Returns true if the stream was exhausted (EOF reached), false if the loop stopped + // because the observer is no longer ready + Try { + @tailrec + def sendChunks(): Boolean = + if (!scso.isReady) { + false + } else if (iter.hasNext) { + val byteArray = iter.next() + val chunk: ByteString = ByteString.copyFrom(byteArray) + responseObserver.onNext(fromByteString.toT(chunk)) + sendChunks() + } else { + true + } + sendChunks() + } match { + case Failure(ex) => + isWorkerRunning.set(false) + allBytesWrittenPromise.tryFailure(ex).discard + case Success(exhausted) => + if (exhausted) { + allBytesWrittenPromise.trySuccess(()).discard + } + isWorkerRunning.set(false) + if (!exhausted) { + if (context.isCancelled) { + allBytesWrittenPromise.trySuccess(()).discard + } else if (scso.isReady) { + // this check is important in case the `onReadyHandler` gets triggered before setting `isWorkerRunning` to `false` above. Not triggering here would mean that the stream will stall. + triggerWorker() + } else { + // Fix for misbehaving observer - that never triggers onReady/onCancel + DelayUtil + .delay(100.millis) + .foreach(_ => triggerWorker().discard) + } + } + } + }, + "Cannot start streaming response to client", + ) + + def triggerWorker(): Future[Unit] = Future { + // Do not start a new worker once streaming is finished or the client has gone away, + // to avoid spinning up workers (and rescheduling them) for an already-completed stream. + if (context.isCancelled) { + allBytesWrittenPromise.trySuccess(()).discard + } else if ( + !allBytesWrittenPromise.isCompleted && isWorkerRunning + .compareAndSet(false, true) + ) + runWorkerInBackground() + } + + scso.setOnReadyHandler(() => triggerWorker().discard) + scso.setOnCancelHandler { () => + Try(inputStream.close()).discard + allBytesWrittenPromise.trySuccess(()).discard + } + + triggerWorker().discard + + allBytesWrittenPromise.future.thereafter(_ => Try(inputStream.close()).discard) } private def finishStream[T]( context: Context.CancellableContext, responseObserver: StreamObserver[T], - )(f: Future[Unit], timeout: Duration): Unit = - Try(Await.result(f, timeout)) match { - case Failure(exception) => + )(f: Future[Unit], timeout: Duration): Unit = { + def cancelContext(): Unit = { + context.cancel(new io.grpc.StatusRuntimeException(io.grpc.Status.CANCELLED)) + () + } + + try { + Await.result(f, timeout) + if (!context.isCancelled) responseObserver.onCompleted() + else cancelContext() + } catch { + case _: InterruptedException => + // The serving thread was interrupted (e.g. node shutdown or call cancellation) while blocked + // in `Await.result`. Cancel the context to terminate the gRPC call exactly once (CANCELLED), + // and only restore the thread's interrupt status. We must NOT rethrow: letting the + // InterruptedException escape into the gRPC request handler makes it close the call a second + // time, writing to the already-terminated HTTP/2 stream, which Netty logs as + // "Stream closed before write could take place". + cancelContext() + Thread.currentThread().interrupt() + case NonFatal(exception) => responseObserver.onError(exception) - context.cancel(new io.grpc.StatusRuntimeException(io.grpc.Status.CANCELLED)) - () - case Success(_) => - if (!context.isCancelled) responseObserver.onCompleted() - else { - context.cancel(new io.grpc.StatusRuntimeException(io.grpc.Status.CANCELLED)) - () - } + cancelContext() } + } def futureUnlessShutdownToStreamObserver[A]( fus: FutureUnlessShutdown[A], @@ -463,6 +572,58 @@ object GrpcStreamingUtils { case Success(AbortedDueToShutdown) => observer.onError(GrpcErrors.AbortedDueToShutdown.Error().asGrpcError) } + + private def withServerCallStreamObserverG[R, A]( + observer: StreamObserver[R] + )(ifNotSupported: => A)( + handler: ServerCallStreamObserver[R] => A + )(implicit errorLoggingContext: ErrorLoggingContext): A = + observer match { + case serverCallStreamObserver: ServerCallStreamObserver[R] => + handler(serverCallStreamObserver) + case other => + val statusException = + Status.INTERNAL + .withDescription(s"Unknown stream observer request") + .asException() + errorLoggingContext.warn( + s"${statusException.getMessage} StreamObserver:(${other.getClass})", + statusException, + ) + observer.onError(statusException) + ifNotSupported + } + + /** Ensure the observer is a ServerCallStreamObserver, running `handler` if so. Otherwise reports + * an INTERNAL error to the observer. See `withServerCallStreamObserverG`. + * + * @param observer + * underlying observer + * @param handler + * handler requiring a ServerCallStreamObserver + */ + def withServerCallStreamObserver[R]( + observer: StreamObserver[R] + )(handler: ServerCallStreamObserver[R] => Unit)(implicit + errorLoggingContext: ErrorLoggingContext + ): Unit = + withServerCallStreamObserverG(observer)(())(handler) + + /** Ensure the observer is a ServerCallStreamObserver, running `handler` if so. Otherwise reports + * an INTERNAL error to the observer and returns a completed future. See + * `withServerCallStreamObserverG`. + * + * @param observer + * underlying observer + * @param handler + * handler requiring a ServerCallStreamObserver + */ + def withServerCallStreamObserverF[R]( + observer: StreamObserver[R] + )(handler: ServerCallStreamObserver[R] => Future[Unit])(implicit + errorLoggingContext: ErrorLoggingContext + ): Future[Unit] = + withServerCallStreamObserverG(observer)(Future.unit)(handler) } // Define a type class for converting ByteString to the generic type T diff --git a/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/kms/KmsTest.scala b/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/kms/KmsTest.scala index fc7687c806..9b07fb6e33 100644 --- a/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/kms/KmsTest.scala +++ b/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/kms/KmsTest.scala @@ -38,6 +38,7 @@ import com.digitalasset.canton.crypto.{ SigningPublicKey, } import com.digitalasset.canton.lifecycle.FutureUnlessShutdown +import com.digitalasset.canton.metrics.CommonMockMetrics import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.util.Thereafter.syntax.* import com.digitalasset.canton.util.{ByteString190, ByteString256, ByteString4096} @@ -94,6 +95,7 @@ trait KmsTest extends BaseTest with BeforeAndAfterAll { SessionEncryptionKeyCacheConfig(), CachingConfigs.defaultPublicKeyConversionCache, CryptoSchemes.tryFromConfig(config), + CommonMockMetrics.cryptoMetrics, loggerFactory, ) .valueOrFail("create crypto with JCE provider") diff --git a/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/provider/jce/JceCryptoTest.scala b/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/provider/jce/JceCryptoTest.scala index ec5311cce1..b0ea5359ff 100644 --- a/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/provider/jce/JceCryptoTest.scala +++ b/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/provider/jce/JceCryptoTest.scala @@ -19,6 +19,7 @@ import com.digitalasset.canton.crypto.CryptoKeyFormat.Raw import com.digitalasset.canton.crypto.CryptoTestHelper.TestMessage import com.digitalasset.canton.crypto.SigningKeySpec.EcSecp256k1 import com.digitalasset.canton.lifecycle.FutureUnlessShutdown +import com.digitalasset.canton.metrics.CommonMockMetrics import com.digitalasset.canton.replica.ReplicaManager import com.digitalasset.canton.resource.MemoryStorage import com.digitalasset.canton.tracing.NoReportingTracerProvider @@ -61,6 +62,7 @@ class JceCryptoTest testedReleaseProtocolVersion, futureSupervisor, wallClock, + CommonMockMetrics.cryptoMetrics, executionContext, timeouts, BatchingConfig(), diff --git a/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/provider/kms/KmsCryptoTest.scala b/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/provider/kms/KmsCryptoTest.scala index a391d86633..afa455d0b4 100644 --- a/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/provider/kms/KmsCryptoTest.scala +++ b/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/provider/kms/KmsCryptoTest.scala @@ -21,6 +21,7 @@ import com.digitalasset.canton.config.{ import com.digitalasset.canton.crypto.* import com.digitalasset.canton.crypto.kms.Kms import com.digitalasset.canton.lifecycle.FutureUnlessShutdown +import com.digitalasset.canton.metrics.CommonMockMetrics import com.digitalasset.canton.replica.ReplicaManager import com.digitalasset.canton.resource.MemoryStorage import com.digitalasset.canton.tracing.NoReportingTracerProvider @@ -87,6 +88,7 @@ trait KmsCryptoTest testedReleaseProtocolVersion, futureSupervisor, wallClock, + CommonMockMetrics.cryptoMetrics, executorService, timeouts, BatchingConfig(), diff --git a/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/sync/SyncCryptoTest.scala b/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/sync/SyncCryptoTest.scala index ee1335ca74..dc56ab8381 100644 --- a/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/sync/SyncCryptoTest.scala +++ b/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/sync/SyncCryptoTest.scala @@ -28,6 +28,7 @@ import com.digitalasset.canton.crypto.{ TestHash, } import com.digitalasset.canton.lifecycle.* +import com.digitalasset.canton.metrics.CommonMockMetrics import com.digitalasset.canton.protocol.StaticSynchronizerParameters import com.digitalasset.canton.replica.ReplicaManager import com.digitalasset.canton.resource.MemoryStorage @@ -139,6 +140,7 @@ trait SyncCryptoTest testedReleaseProtocolVersion, futureSupervisor, wallClock, + CommonMockMetrics.cryptoMetrics, executorService, timeouts, BatchingConfig(), diff --git a/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/sync/SyncCryptoWithLongTermKeysTest.scala b/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/sync/SyncCryptoWithLongTermKeysTest.scala index 47850f6612..aaf6c10867 100644 --- a/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/sync/SyncCryptoWithLongTermKeysTest.scala +++ b/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/sync/SyncCryptoWithLongTermKeysTest.scala @@ -39,7 +39,7 @@ class SyncCryptoWithLongTermKeysTest TestingTopology() .withSimpleParticipants(participant1) .withCryptoConfig( - cryptoConfigWithSessionSigningKeysConfig(SessionSigningKeysConfig.default) + cryptoConfigWithSessionSigningKeysConfig(SessionSigningKeysConfig.enabled) ) .build(crypto, loggerFactory) diff --git a/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/sync/SyncCryptoWithSessionKeysTest.scala b/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/sync/SyncCryptoWithSessionKeysTest.scala index 9f90314c18..a78b14fa16 100644 --- a/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/sync/SyncCryptoWithSessionKeysTest.scala +++ b/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/sync/SyncCryptoWithSessionKeysTest.scala @@ -36,7 +36,7 @@ class SyncCryptoWithSessionKeysTest with ProtocolVersionChecksAnyWordSpec { override protected lazy val sessionSigningKeysConfig: SessionSigningKeysConfig = - if (testedProtocolVersion >= ProtocolVersion.v35) SessionSigningKeysConfig.default + if (testedProtocolVersion >= ProtocolVersion.v35) SessionSigningKeysConfig.enabled else SessionSigningKeysConfig.disabled private var p1PV34: SynchronizerCryptoClient = _ @@ -601,7 +601,7 @@ class SyncCryptoWithSessionKeysTest val signature = loggerFactory.assertLoggedWarningsAndErrorsSeq( { - p1PV34 = createTestingTopologyWith(SessionSigningKeysConfig.default) + p1PV34 = createTestingTopologyWith(SessionSigningKeysConfig.enabled) .forOwnerAndSynchronizer(participant1) p1PV34.syncCryptoSigner.isInstanceOf[SyncCryptoSignerWithLongTermKeys] shouldBe true p1PV34.syncCryptoSigner diff --git a/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/validations/SyncSchemeValidationsTest.scala b/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/validations/SyncSchemeValidationsTest.scala index ed5ad45a4b..0a1b1df707 100644 --- a/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/validations/SyncSchemeValidationsTest.scala +++ b/canton/community/common/src/test/scala/com/digitalasset/canton/crypto/validations/SyncSchemeValidationsTest.scala @@ -16,6 +16,7 @@ import com.digitalasset.canton.config.{ } import com.digitalasset.canton.crypto.* import com.digitalasset.canton.crypto.store.CryptoPrivateStoreExtended +import com.digitalasset.canton.metrics.CommonMockMetrics import com.digitalasset.canton.protocol.StaticSynchronizerParameters import com.digitalasset.canton.replica.ReplicaManager import com.digitalasset.canton.resource.MemoryStorage @@ -48,6 +49,7 @@ class SyncSchemeValidationsTest extends AnyWordSpec with BaseTest with HasExecut testedReleaseProtocolVersion, futureSupervisor, wallClock, + CommonMockMetrics.cryptoMetrics, executorService, timeouts, BatchingConfig(), diff --git a/canton/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/SequencerClientTest.scala b/canton/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/SequencerClientTest.scala index 5def0ffae6..462e15a3ac 100644 --- a/canton/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/SequencerClientTest.scala +++ b/canton/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/SequencerClientTest.scala @@ -1822,7 +1822,7 @@ final class SequencerClientTest override protected def loggerFactory: NamedLoggerFactory = SequencerClientTest.this.loggerFactory - override def physicalSynchronizerIdO: Option[PhysicalSynchronizerId] = ??? + override def physicalSynchronizerIdO: Option[PhysicalSynchronizerId] = None override def staticSynchronizerParametersO: Option[StaticSynchronizerParameters] = ??? @@ -1884,6 +1884,8 @@ final class SequencerClientTest )(implicit traceContext: TraceContext ): Either[SequencerConnectionPoolError.ThresholdUnreachableError, Unit] = Either.unit + + override val metricsContext: MetricsContext = MetricsContext.Empty } private object MockPool { diff --git a/canton/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/TestSequencerClientSend.scala b/canton/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/TestSequencerClientSend.scala index 7e136a22ea..0f1ac98fee 100644 --- a/canton/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/TestSequencerClientSend.scala +++ b/canton/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/TestSequencerClientSend.scala @@ -7,6 +7,7 @@ import cats.data.{EitherT, Nested} import com.daml.metrics.api.MetricsContext import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.protocol.messages.DefaultOpenEnvelope +import com.digitalasset.canton.sequencing.client.SequencerClient.TrafficCostValidator import com.digitalasset.canton.sequencing.client.SequencerClientSend.SendRequestTimestamps import com.digitalasset.canton.sequencing.client.TestSequencerClientSend.Request import com.digitalasset.canton.sequencing.protocol.{ @@ -43,6 +44,7 @@ class TestSequencerClientSend(override protected[canton] val clock: Clock)(impli messageId: MessageId, aggregationRule: Option[AggregationRule], callback: SendCallback, + trafficCostValidator: TrafficCostValidator, amplify: Boolean, useConfirmationResponseAmplificationParameters: Boolean, )(implicit diff --git a/canton/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/pool/ConnectionPoolTestHelpers.scala b/canton/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/pool/ConnectionPoolTestHelpers.scala index f0f29ae5a7..3e90226379 100644 --- a/canton/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/pool/ConnectionPoolTestHelpers.scala +++ b/canton/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/pool/ConnectionPoolTestHelpers.scala @@ -222,6 +222,7 @@ trait ConnectionPoolTestHelpers { poolDelays: SequencerConnectionPoolDelays = SequencerConnectionPoolDelays.default, blockValidation: Int => Boolean = _ => false, metrics: SequencerConnectionPoolMetrics = CommonMockMetrics.sequencerClient.connectionPool, + metricsContext: MetricsContext = MetricsContext.Empty, namePrefix: String = "test", )( f: ( @@ -251,6 +252,7 @@ trait ConnectionPoolTestHelpers { testCrypto.crypto, Some(seedForRandomness), metrics = metrics, + metricsContext = metricsContext, futureSupervisor, testTimeouts, loggerFactory, @@ -286,7 +288,7 @@ trait ConnectionPoolTestHelpers { sequencerSubscriptionFactory = new TestSequencerSubscriptionFactory(timeouts, loggerFactory), subscriptionHandlerFactory = TestSubscriptionHandlerFactory, metrics = CommonMockMetrics.sequencerClient.connectionPool, - metricsContext = MetricsContext.Empty, + metricsContext = connectionPool.metricsContext, timeouts = timeouts, loggerFactory = loggerFactory, ) @@ -450,6 +452,7 @@ protected object ConnectionPoolTestHelpers { crypto: Crypto, seedForRandomnessO: Option[Long], metrics: SequencerConnectionPoolMetrics, + metricsContext: MetricsContext, futureSupervisor: FutureSupervisor, timeouts: ProcessingTimeout, loggerFactory: NamedLoggerFactory, @@ -462,6 +465,7 @@ protected object ConnectionPoolTestHelpers { responsesForConnection, validationBlocker, metrics, + metricsContext, futureSupervisor, timeouts, loggerFactory, @@ -489,7 +493,7 @@ protected object ConnectionPoolTestHelpers { crypto, seedForRandomnessO, metrics, - MetricsContext.Empty, + metricsContext, futureSupervisor, timeouts, loggerFactory, @@ -514,6 +518,7 @@ protected object ConnectionPoolTestHelpers { responsesForConnection: PartialFunction[Int, TestResponses], validationBlocker: TestValidationBlocker, metrics: SequencerConnectionPoolMetrics, + metricsContext: MetricsContext, futureSupervisor: FutureSupervisor, timeouts: ProcessingTimeout, loggerFactory: NamedLoggerFactory, @@ -559,7 +564,7 @@ protected object ConnectionPoolTestHelpers { ClientChannelParams.ForTesting, stubFactory = stubFactory, metrics = metrics, - metricsContext = MetricsContext.Empty, + metricsContext = metricsContext, futureSupervisor = futureSupervisor, timeouts = timeouts, loggerFactory = loggerFactory.append("connection", config.name), diff --git a/canton/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/pool/SequencerConnectionPoolImplTest.scala b/canton/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/pool/SequencerConnectionPoolImplTest.scala index 257bda3ecf..999e4e85be 100644 --- a/canton/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/pool/SequencerConnectionPoolImplTest.scala +++ b/canton/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/pool/SequencerConnectionPoolImplTest.scala @@ -749,6 +749,9 @@ class SequencerConnectionPoolImplTest } } + // Closing the pool closes all the metrics + poolMetrics.connectionHealthMetrics shouldBe empty + withConnectionPool( nbConnections = PositiveInt.three, trustThreshold = PositiveInt.two, @@ -756,15 +759,6 @@ class SequencerConnectionPoolImplTest metrics = poolMetrics, // reuse the same metrics namePrefix = "second-config", ) { (pool, _, _, _) => - // when creating a new pool for the same synchronizer, the same metrics are reused and - // the connections from the first connect are still in there. - // this means, disconnecting from a synchronizer keeps the metric in fatal state - poolMetrics.connectionHealthMetrics should not be empty - forAll(currentMetrics) { case (name, value) => - name should startWith("first-config") - value shouldBe 0 // fatal - } - pool.start().futureValueUS.value // the pool startup removes all previous (lingering) metrics and creates new ones @@ -777,6 +771,51 @@ class SequencerConnectionPoolImplTest } } } + + "clean up only the metrics associated to a pool" in { + val poolMetrics = new SequencerClientMetrics( + new SequencerClientHistograms(MetricName("test"))(new HistogramInventory()), + NoOpMetricsFactory, + )(MetricsContext.Empty).connectionPool + + def currentMetricsPsids = + poolMetrics.connectionHealthMetrics.map { case (mc, _gauge) => mc.labels.get("psid") }.toSet + def currentMetricsSize = poolMetrics.connectionHealthMetrics.size + + withConnectionPool( + nbConnections = PositiveInt.three, + trustThreshold = PositiveInt.three, + i => mkConnectionAttributes(synchronizerIndex = 1, sequencerIndex = i + 1), + metrics = poolMetrics, // reuse the same metrics + metricsContext = MetricsContext("psid" -> "psid1"), + namePrefix = "first-config", + ) { (pool1, _, _, _) => + pool1.start().futureValueUS.value + + currentMetricsSize shouldBe 3 + currentMetricsPsids shouldBe Set(Some("psid1")) + + withConnectionPool( + nbConnections = PositiveInt.two, + trustThreshold = PositiveInt.two, + i => mkConnectionAttributes(synchronizerIndex = 1, sequencerIndex = i + 1), + metrics = poolMetrics, // reuse the same metrics + metricsContext = MetricsContext("psid" -> "psid2"), + namePrefix = "second-config", + ) { (pool2, _, _, _) => + pool2.start().futureValueUS.value + + currentMetricsSize shouldBe 5 + currentMetricsPsids shouldBe Set(Some("psid1"), Some("psid2")) + } + + currentMetricsSize shouldBe 3 + currentMetricsPsids shouldBe Set(Some("psid1")) + } + + currentMetricsSize shouldBe 0 + currentMetricsPsids shouldBe Set() + } } private def badBootstrapAssertion( diff --git a/canton/community/common/src/test/scala/com/digitalasset/canton/store/db/DbTest.scala b/canton/community/common/src/test/scala/com/digitalasset/canton/store/db/DbTest.scala index 3a3a222a08..d6e26ba598 100644 --- a/canton/community/common/src/test/scala/com/digitalasset/canton/store/db/DbTest.scala +++ b/canton/community/common/src/test/scala/com/digitalasset/canton/store/db/DbTest.scala @@ -44,7 +44,7 @@ trait DbTest protected def cleanDb(storage: DbStorage)(implicit tc: TraceContext): FutureUnlessShutdown[?] @SuppressWarnings(Array("org.wartremover.warts.Var", "org.wartremover.warts.Null")) - private var setup: DbStorageSetup = _ + protected var setup: DbStorageSetup = _ /** Stores the db storage implementation. Will throw if accessed before the test has started */ protected lazy val storage: DbStorageIdempotency = { diff --git a/canton/community/common/src/test/scala/com/digitalasset/canton/topology/processing/TopologyManagerSigningKeyDetectionTest.scala b/canton/community/common/src/test/scala/com/digitalasset/canton/topology/processing/TopologyManagerSigningKeyDetectionTest.scala index ef2a31b175..0c297585cb 100644 --- a/canton/community/common/src/test/scala/com/digitalasset/canton/topology/processing/TopologyManagerSigningKeyDetectionTest.scala +++ b/canton/community/common/src/test/scala/com/digitalasset/canton/topology/processing/TopologyManagerSigningKeyDetectionTest.scala @@ -12,6 +12,7 @@ import com.digitalasset.canton.topology.store.* import com.digitalasset.canton.topology.store.TopologyStoreId.SynchronizerStore import com.digitalasset.canton.topology.store.memory.InMemoryTopologyStore import com.digitalasset.canton.topology.transaction.* +import com.digitalasset.canton.topology.transaction.ParticipantPermission.Submission import com.digitalasset.canton.topology.transaction.TopologyChangeOp.Replace import com.digitalasset.canton.{BaseTest, HasExecutionContext} import org.scalatest.wordspec.AnyWordSpec @@ -62,13 +63,25 @@ class TopologyManagerSigningKeyDetectionTest .futureValueUS detector - .getValidSigningKeysForTransaction(ts(1), dtc_uid1a, None, returnAllValidKeys = false) + .getValidSigningKeysForTransaction( + ts(1), + dtc_uid1a, + None, + returnAllValidKeys = false, + namespacesToSignFor = Seq.empty, + ) .map(_._2) .futureValueUS shouldBe Right(Seq(SigningKeys.key3.fingerprint)) // test getting all valid keys detector - .getValidSigningKeysForTransaction(ts(1), dtc_uid1a, None, returnAllValidKeys = true) + .getValidSigningKeysForTransaction( + ts(1), + dtc_uid1a, + None, + returnAllValidKeys = true, + namespacesToSignFor = Seq.empty, + ) .futureValueUS .value ._2 should contain theSameElementsAs Seq( @@ -94,7 +107,13 @@ class TopologyManagerSigningKeyDetectionTest loggerFactory.assertLoggedWarningsAndErrorsSeq( detector - .getValidSigningKeysForTransaction(ts(2), dtc_uid1a, None, returnAllValidKeys = false) + .getValidSigningKeysForTransaction( + ts(2), + dtc_uid1a, + None, + returnAllValidKeys = false, + namespacesToSignFor = Seq.empty, + ) .map(_._2) .futureValueUS shouldBe Right( Seq(SigningKeys.key1.fingerprint) @@ -112,6 +131,66 @@ class TopologyManagerSigningKeyDetectionTest ) } + "respsect the requested authorization scope" in { + val detector = mk() + + detector.store + .update( + SequencedTime(ts(0)), + EffectiveTime(ts(0)), + removals = Map.empty, + additions = Seq(ns1k1_k1, ns2k2_k2, ns3k3_k3).map(ValidatedTopologyTransaction(_)), + ) + .futureValueUS + + val ptp = PartyToParticipant.tryCreate( + PartyId.tryCreate("alice", ns1), + PositiveInt.one, + Seq( + HostingParticipant(ParticipantId(UniqueIdentifier.tryCreate("p2", ns2)), Submission), + HostingParticipant(ParticipantId(UniqueIdentifier.tryCreate("p3", ns3)), Submission), + ), + ) + + detector + .getValidSigningKeysForTransaction( + ts(1), + TopologyTransaction( + TopologyChangeOp.Replace, + PositiveInt.one, + ptp, + testedProtocolVersion, + ), + None, + namespacesToSignFor = Seq(ns2), + returnAllValidKeys = false, + ) + .map(_._2) + .futureValueUS shouldBe Right(Seq(SigningKeys.key2.fingerprint)) + + // test getting all valid keys + detector + .getValidSigningKeysForTransaction( + ts(1), + TopologyTransaction( + TopologyChangeOp.Replace, + PositiveInt.one, + ptp, + testedProtocolVersion, + ), + None, + namespacesToSignFor = Seq.empty, + returnAllValidKeys = true, + ) + .futureValueUS + .value + ._2 should contain theSameElementsAs Seq( + SigningKeys.key1, + SigningKeys.key2, + SigningKeys.key3, + ).map(_.fingerprint) + } + "resolves decentralized namespace definitions for finding appropriate signing keys" in { val detector = mk() @@ -140,7 +219,13 @@ class TopologyManagerSigningKeyDetectionTest .futureValueUS detector - .getValidSigningKeysForTransaction(ts(1), otk, None, returnAllValidKeys = false) + .getValidSigningKeysForTransaction( + ts(1), + otk, + None, + returnAllValidKeys = false, + namespacesToSignFor = Seq.empty, + ) .futureValueUS .value ._2 should contain theSameElementsAs Seq( @@ -151,7 +236,13 @@ class TopologyManagerSigningKeyDetectionTest ).map(_.fingerprint) detector - .getValidSigningKeysForTransaction(ts(1), otk, None, returnAllValidKeys = true) + .getValidSigningKeysForTransaction( + ts(1), + otk, + None, + returnAllValidKeys = true, + namespacesToSignFor = Seq.empty, + ) .futureValueUS .value ._2 should contain theSameElementsAs Seq( diff --git a/canton/community/common/src/test/scala/com/digitalasset/canton/topology/store/DownloadTopologyStateForInitializationServiceTest.scala b/canton/community/common/src/test/scala/com/digitalasset/canton/topology/store/DownloadTopologyStateForInitializationServiceTest.scala index 41728c80bc..1187a4d9d3 100644 --- a/canton/community/common/src/test/scala/com/digitalasset/canton/topology/store/DownloadTopologyStateForInitializationServiceTest.scala +++ b/canton/community/common/src/test/scala/com/digitalasset/canton/topology/store/DownloadTopologyStateForInitializationServiceTest.scala @@ -12,7 +12,7 @@ import com.digitalasset.canton.topology.processing.{EffectiveTime, SequencedTime import com.digitalasset.canton.topology.store.StoredTopologyTransactions.GenericStoredTopologyTransactions import com.digitalasset.canton.topology.store.TopologyStoreId.SynchronizerStore import com.digitalasset.canton.topology.transaction.SignedTopologyTransaction.GenericSignedTopologyTransaction -import com.digitalasset.canton.{FailOnShutdown, HasActorSystem} +import com.digitalasset.canton.{FailOnShutdown, HasActorSystem, HasExecutionContext} import org.apache.pekko.NotUsed import org.apache.pekko.stream.scaladsl.{Sink, Source} import org.scalatest.wordspec.AsyncWordSpec @@ -21,7 +21,8 @@ trait DownloadTopologyStateForInitializationServiceTest extends AsyncWordSpec with TopologyStoreTestBase with FailOnShutdown - with HasActorSystem { + with HasActorSystem + with HasExecutionContext { protected def mkStore( synchronizerId: PhysicalSynchronizerId, @@ -29,7 +30,7 @@ trait DownloadTopologyStateForInitializationServiceTest ): TopologyStore[SynchronizerStore] private val testData = - new TopologyStoreTestData(testedProtocolVersion, loggerFactory, executionContext) + new TopologyStoreTestData(testedProtocolVersion, loggerFactory) import testData.* val bootstrapTransactions = StoredTopologyTransactions( diff --git a/canton/community/common/src/test/scala/com/digitalasset/canton/topology/store/TopologyStoreTest.scala b/canton/community/common/src/test/scala/com/digitalasset/canton/topology/store/TopologyStoreTest.scala index cd9abc9f72..746988f9a0 100644 --- a/canton/community/common/src/test/scala/com/digitalasset/canton/topology/store/TopologyStoreTest.scala +++ b/canton/community/common/src/test/scala/com/digitalasset/canton/topology/store/TopologyStoreTest.scala @@ -30,7 +30,7 @@ import com.digitalasset.canton.topology.transaction.TopologyMapping.Code import com.digitalasset.canton.topology.transaction.{TopologyMapping, *} import com.digitalasset.canton.util.MonadUtil import com.digitalasset.canton.version.ProtocolVersion -import com.digitalasset.canton.{FailOnShutdown, HasActorSystem} +import com.digitalasset.canton.{FailOnShutdown, HasActorSystem, HasExecutionContext} import org.apache.pekko.stream.scaladsl.Sink import org.scalatest.Assertion import org.scalatest.wordspec.AsyncWordSpec @@ -40,12 +40,13 @@ trait TopologyStoreTest extends AsyncWordSpec with TopologyStoreTestBase with FailOnShutdown - with HasActorSystem { + with HasActorSystem + with HasExecutionContext { implicit def closeContext: CloseContext private[store] val testData = - new TopologyStoreTestData(testedProtocolVersion, loggerFactory, executionContext) + new TopologyStoreTestData(testedProtocolVersion, loggerFactory) import testData.* private[store] lazy val largeTestSnapshot = { diff --git a/canton/community/common/src/test/scala/com/digitalasset/canton/topology/store/TopologyStoreTestData.scala b/canton/community/common/src/test/scala/com/digitalasset/canton/topology/store/TopologyStoreTestData.scala index e0a5e5c7c2..e760154ade 100644 --- a/canton/community/common/src/test/scala/com/digitalasset/canton/topology/store/TopologyStoreTestData.scala +++ b/canton/community/common/src/test/scala/com/digitalasset/canton/topology/store/TopologyStoreTestData.scala @@ -20,6 +20,7 @@ import com.digitalasset.canton.topology.transaction.* import com.digitalasset.canton.topology.transaction.DelegationRestriction.CanSignAllMappings import com.digitalasset.canton.topology.transaction.ParticipantPermission.Submission import com.digitalasset.canton.topology.transaction.SignedTopologyTransaction.GenericSignedTopologyTransaction +import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.version.ProtocolVersion import org.scalatest.Assertions.fail import org.scalatest.concurrent.ScalaFutures.convertScalaFuture @@ -31,15 +32,13 @@ import scala.concurrent.ExecutionContext class TopologyStoreTestData( testedProtocolVersion: ProtocolVersion, loggerFactory: NamedLoggerFactory, - executionContext: ExecutionContext, -) { +)(implicit executionContext: ExecutionContext) { def makeSignedTx[Op <: TopologyChangeOp, M <: TopologyMapping]( mapping: M, op: Op = TopologyChangeOp.Replace, isProposal: Boolean = false, serial: PositiveInt = PositiveInt.one, )(signingKeys: SigningPublicKey*): SignedTopologyTransaction[Op, M] = { - import com.digitalasset.canton.tracing.TraceContext.Implicits.Empty.* val tx = TopologyTransaction( op, serial, @@ -57,7 +56,7 @@ class TopologyStoreTestData( .from( keysWithUsage.toSeq.map { case (keyId, usage) => factory.syncCryptoClient.crypto.privateCrypto - .sign(tx.hash.hash, keyId, usage) + .sign(tx.hash.hash, keyId, usage)(executionContext, TraceContext.empty) .value .onShutdown(fail("shutdown"))( DirectExecutionContext(loggerFactory.getLogger(this.getClass)) @@ -67,7 +66,6 @@ class TopologyStoreTestData( } ) .getOrElse(fail("no keys provided")) - SignedTopologyTransaction.withSignatures[Op, M]( tx, signatures = signatures, diff --git a/canton/community/common/src/test/scala/com/digitalasset/canton/traffic/TrafficPurchasedSubmissionHandlerTest.scala b/canton/community/common/src/test/scala/com/digitalasset/canton/traffic/TrafficPurchasedSubmissionHandlerTest.scala index 575307e710..bcf81d5c4d 100644 --- a/canton/community/common/src/test/scala/com/digitalasset/canton/traffic/TrafficPurchasedSubmissionHandlerTest.scala +++ b/canton/community/common/src/test/scala/com/digitalasset/canton/traffic/TrafficPurchasedSubmissionHandlerTest.scala @@ -18,6 +18,7 @@ import com.digitalasset.canton.protocol.messages.{ } import com.digitalasset.canton.protocol.{DynamicSynchronizerParameters, SynchronizerParameters} import com.digitalasset.canton.sequencing.TrafficControlParameters +import com.digitalasset.canton.sequencing.client.SequencerClient.TrafficCostValidator import com.digitalasset.canton.sequencing.client.SequencerClientSend.SendRequestTimestamps import com.digitalasset.canton.sequencing.client.{ SendAsyncClientError, @@ -96,6 +97,7 @@ class TrafficPurchasedSubmissionHandlerTest any[MessageId], aggregationRuleCapture.capture(), callbackCapture.capture(), + any[TrafficCostValidator], amplify = any[Boolean], useConfirmationResponseAmplificationParameters = eqTo(false), )(any[TraceContext], any[MetricsContext]) @@ -175,6 +177,7 @@ class TrafficPurchasedSubmissionHandlerTest any[MessageId], any[Option[AggregationRule]], callbackCapture.capture(), + any[TrafficCostValidator], amplify = any[Boolean], useConfirmationResponseAmplificationParameters = eqTo(false), )(any[TraceContext], any[MetricsContext]) @@ -231,6 +234,7 @@ class TrafficPurchasedSubmissionHandlerTest any[MessageId], any[Option[AggregationRule]], any[SendCallback], + any[TrafficCostValidator], amplify = any[Boolean], useConfirmationResponseAmplificationParameters = eqTo(false), )(any[TraceContext], any[MetricsContext]) @@ -265,6 +269,7 @@ class TrafficPurchasedSubmissionHandlerTest any[MessageId], any[Option[AggregationRule]], callbackCapture.capture(), + any[TrafficCostValidator], amplify = any[Boolean], useConfirmationResponseAmplificationParameters = eqTo(false), )(any[TraceContext], any[MetricsContext]) @@ -325,6 +330,7 @@ class TrafficPurchasedSubmissionHandlerTest any[MessageId], any[Option[AggregationRule]], callbackCapture.capture(), + any[TrafficCostValidator], amplify = any[Boolean], useConfirmationResponseAmplificationParameters = eqTo(false), )(any[TraceContext], any[MetricsContext]) diff --git a/canton/community/common/src/test/scala/com/digitalasset/canton/util/GrpcStreamingUtilsTest.scala b/canton/community/common/src/test/scala/com/digitalasset/canton/util/GrpcStreamingUtilsTest.scala index f7f26c70e6..cfc0571763 100644 --- a/canton/community/common/src/test/scala/com/digitalasset/canton/util/GrpcStreamingUtilsTest.scala +++ b/canton/community/common/src/test/scala/com/digitalasset/canton/util/GrpcStreamingUtilsTest.scala @@ -3,15 +3,18 @@ package com.digitalasset.canton.util -import com.digitalasset.canton.BaseTest -import io.grpc.stub.StreamObserver +import com.digitalasset.canton.{BaseTest, HasExecutionContext} +import com.google.protobuf.ByteString +import io.grpc.stub.{ServerCallStreamObserver, StreamObserver} import org.scalatest.wordspec.AnyWordSpec -import java.io.ByteArrayInputStream -import java.util.concurrent.atomic.AtomicInteger +import java.io.{ByteArrayInputStream, OutputStream} +import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger, AtomicReference} +import java.util.concurrent.{CountDownLatch, TimeUnit} import scala.collection.mutable +import scala.concurrent.{Future, Promise, blocking} -final class GrpcStreamingUtilsTest extends AnyWordSpec with BaseTest { +final class GrpcStreamingUtilsTest extends AnyWordSpec with BaseTest with HasExecutionContext { // we need to use the same value as in GrpcStreamingUtils since it's not configurable val defaultChunkSize = GrpcStreamingUtils.defaultChunkSize private def load( @@ -27,6 +30,54 @@ final class GrpcStreamingUtilsTest extends AnyWordSpec with BaseTest { } def makeRequest(bytes: Array[Byte]): String = new String(bytes) + /** Base test double for [[ServerCallStreamObserver]] with no-op implementations of the + * flow-control and lifecycle callbacks. Tests override only what they care about (typically + * `onNext` and the readiness behavior). + */ + private class TestServerCallStreamObserver[T] extends ServerCallStreamObserver[T] { + override def isReady: Boolean = true + override def setOnReadyHandler(onReadyHandler: Runnable): Unit = () + override def disableAutoInboundFlowControl(): Unit = () + override def request(count: Int): Unit = () + override def setMessageCompression(enable: Boolean): Unit = () + override def setCompression(compression: String): Unit = () + override def isCancelled: Boolean = false + override def setOnCancelHandler(onCancelHandler: Runnable): Unit = () + + override def onNext(value: T): Unit = () + override def onError(t: Throwable): Unit = () + override def onCompleted(): Unit = () + } + + /** Test double whose readiness can be toggled to mimic gRPC flow control. It starts ready by + * default. `setReady` flips readiness synchronously and runs the registered onReady handler, + * while `goNotReadyThenReadyAfter` simulates the transport buffer filling up and then draining + * asynchronously (firing the onReady handler from another thread). + */ + private class ControllableReadyObserver[T](initiallyReady: Boolean = true) + extends TestServerCallStreamObserver[T] { + private val readyRef = new AtomicBoolean(initiallyReady) + @volatile private var onReadyHandler: Runnable = () => () + + override def isReady: Boolean = readyRef.get() + override def setOnReadyHandler(handler: Runnable): Unit = onReadyHandler = handler + + def setReady(value: Boolean): Unit = { + readyRef.set(value) + onReadyHandler.run() + } + + def goNotReadyThenReadyAfter(delayMillis: Long): Unit = { + readyRef.set(false) + val handler = onReadyHandler + val _ = Future { + blocking(Thread.sleep(delayMillis)) + readyRef.set(true) + handler.run() + } + } + } + "streamToServer with InputStream" should { "stream all chunks to the server" in { val data = Array.tabulate[Byte](defaultChunkSize * 2 + 100)(_.toByte) @@ -114,4 +165,314 @@ final class GrpcStreamingUtilsTest extends AnyWordSpec with BaseTest { } } } + + "streamToClient" should { + + val fromByteString: FromByteString[ByteString] = (chunk: ByteString) => chunk + + "deliver chunks incrementally (not all at once after producer finishes)" in { + val chunkSize = 1024 * 64 + val totalChunks = 5 + + val chunkDelivered = Array.fill(totalChunks)(new CountDownLatch(1)) + val proceedWithNext = Array.fill(totalChunks)(new CountDownLatch(1)) + val completed = new CountDownLatch(1) + val chunksReceived = new AtomicInteger(0) + val producerCompleted = new CountDownLatch(1) + + def awaitLatch(latch: CountDownLatch, clue: String): Unit = + withClue(clue)(latch.await(10, TimeUnit.SECONDS) shouldBe true) + + def assertProducerStillRunning(chunkIdx: Int): Unit = + withClue(s"Producer must not have completed when chunk $chunkIdx is delivered") { + producerCompleted.getCount shouldBe 1L + } + + val responseObserver = new TestServerCallStreamObserver[ByteString] { + override def onNext(value: ByteString): Unit = { + val idx = chunksReceived.getAndIncrement() + if (idx < totalChunks) { + chunkDelivered(idx).countDown() + awaitLatch(proceedWithNext(idx), s"Chunk $idx should be allowed to proceed") + } + } + override def onError(t: Throwable): Unit = completed.countDown() + override def onCompleted(): Unit = completed.countDown() + } + + def producer(os: OutputStream): Future[Unit] = Future { + blocking { + val data = new Array[Byte](chunkSize) + for (i <- 0 until totalChunks) { + java.util.Arrays.fill(data, i.toByte) + os.write(data) + os.flush() + awaitLatch(chunkDelivered(i), s"Chunk $i should be delivered before producer continues") + } + producerCompleted.countDown() + } + } + + // Run in background because streamToClient blocks via Await.result + val streamingF = Future { + blocking { + GrpcStreamingUtils.streamToClient( + responseF = producer, + responseObserver = responseObserver, + fromByteString = fromByteString, + chunkSizeO = Some(chunkSize), + ) + } + } + + // Verify first chunk is delivered while producer is still writing + awaitLatch(chunkDelivered(0), "First chunk should be delivered") + assertProducerStillRunning(0) + proceedWithNext(0).countDown() + + // Verify remaining chunks are delivered incrementally + for (i <- 1 until totalChunks) { + awaitLatch(chunkDelivered(i), s"Chunk $i should be delivered") + if (i < totalChunks - 1) assertProducerStillRunning(i) + proceedWithNext(i).countDown() + } + + completed.await(30, TimeUnit.SECONDS) shouldBe true + chunksReceived.get() shouldBe totalChunks + streamingF.futureValue + } + + "wait until observer is ready before calling onNext" in { + val chunkSize = 8 + val inputData = Array.tabulate[Byte](chunkSize)((i: Int) => i.toByte) + val context = io.grpc.Context.current().withCancellation() + + val chunksReceived = new AtomicInteger(0) + val calledWhileNotReady = new AtomicInteger(0) + val onNextCalled = new CountDownLatch(1) + + val responseObserver = new ControllableReadyObserver[ByteString](initiallyReady = false) { + override def onNext(value: ByteString): Unit = { + if (!isReady) calledWhileNotReady.incrementAndGet() + chunksReceived.incrementAndGet() + onNextCalled.countDown() + } + } + + val streamingF = + GrpcStreamingUtils.streamResponseChunks(context, responseObserver)( + new ByteArrayInputStream(inputData), + chunkSize, + (chunk: ByteString) => chunk, + ) + + onNextCalled.await(5, TimeUnit.SECONDS) shouldBe false + calledWhileNotReady.get() shouldBe 0 + chunksReceived.get() shouldBe 0 + + responseObserver.setReady(true) + + onNextCalled.await(10, TimeUnit.SECONDS) shouldBe true + chunksReceived.get() shouldBe 1 + calledWhileNotReady.get() shouldBe 0 + + streamingF.futureValue + } + + "complete the future when responseObserver.onNext throws an exception" in { + val chunkSize = 8 + val inputData = Array.tabulate[Byte](chunkSize)((i: Int) => i.toByte) + val context = io.grpc.Context.current().withCancellation() + + val responseObserver = new TestServerCallStreamObserver[ByteString] { + override def onNext(value: ByteString): Unit = + throw new RuntimeException("onNext exploded") + } + + val resultF = GrpcStreamingUtils.streamResponseChunks(context, responseObserver)( + new ByteArrayInputStream(inputData), + chunkSize, + (chunk: ByteString) => chunk, + ) + + // If the bug is present, this future never completes and the test times out + resultF.failed.futureValue shouldBe a[RuntimeException] + } + + "handle producer failure gracefully" in { + val completed = new CountDownLatch(1) + val error = new AtomicInteger(0) + + val responseObserver = new TestServerCallStreamObserver[ByteString] { + override def onError(t: Throwable): Unit = { + error.incrementAndGet() + completed.countDown() + } + override def onCompleted(): Unit = completed.countDown() + } + + GrpcStreamingUtils.streamToClient( + responseF = { (_: OutputStream) => + Future.failed(new RuntimeException("producer exploded")) + }, + responseObserver = responseObserver, + fromByteString = fromByteString, + chunkSizeO = Some(defaultChunkSize), + ) + + completed.await(10, TimeUnit.SECONDS) shouldBe true + error.get() shouldBe 1 + } + + "complete the future when observer becomes not-ready right at EOF" in { + // Reproduces a race where scso.isReady transitions to false right after sending a chunk + // and the worker exits the loop without observing EOF. The completion must still be + // signaled once the observer becomes ready again, even if iter.hasNext was never + // queried at the point where the observer was ready. + val chunkSize = 8 + val inputData = Array.tabulate[Byte](chunkSize)(_.toByte) + val context = io.grpc.Context.current().withCancellation() + + val responseObserver = new ControllableReadyObserver[ByteString] { + override def onNext(value: ByteString): Unit = + // Simulate flow control: observer goes not-ready right after buffering a chunk, + // then becomes ready again shortly after (as the network drains the buffer). + goNotReadyThenReadyAfter(20) + } + + val resultF = GrpcStreamingUtils.streamResponseChunks(context, responseObserver)( + new ByteArrayInputStream(inputData), + chunkSize, + (chunk: ByteString) => chunk, + ) + + // If the bug is present, this future never completes and the test times out + resultF.futureValue + } + + "complete streamToClient end-to-end with a ServerCallStreamObserver under flow control" in { + // Mirrors RemoteDumpIntegrationTest: a producer writes multiple chunks to a piped + // output stream and a ServerCallStreamObserver simulates flow control by going + // not-ready after each onNext and firing onReadyHandler shortly afterwards. + // Regression test for a hang where the worker exited the send loop with the observer + // not ready and EOF was never signaled. + val chunkSize = 1024 + val totalChunks = 50 + + val onCompletedLatch = new CountDownLatch(1) + val received = new AtomicInteger(0) + + val responseObserver = new ControllableReadyObserver[ByteString] { + override def onNext(value: ByteString): Unit = { + received.incrementAndGet() + // Half the time, simulate the buffer filling up: go not-ready and let it drain + // asynchronously, firing onReadyHandler from another thread. + if (received.get() % 2 == 0) goNotReadyThenReadyAfter(5) + } + + override def onError(t: Throwable): Unit = onCompletedLatch.countDown() + override def onCompleted(): Unit = onCompletedLatch.countDown() + } + + def producer(os: OutputStream): Future[Unit] = Future { + blocking { + val data = new Array[Byte](chunkSize) + for (i <- 0 until totalChunks) { + java.util.Arrays.fill(data, i.toByte) + os.write(data) + } + } + } + + val streamingF = Future { + blocking { + GrpcStreamingUtils.streamToClient( + responseF = producer, + responseObserver = responseObserver, + fromByteString = (chunk: ByteString) => chunk, + chunkSizeO = Some(chunkSize), + ) + } + } + + onCompletedLatch.await(30, TimeUnit.SECONDS) shouldBe true + received.get() shouldBe totalChunks + streamingF.futureValue + } + + "complete the future when the context is cancelled while the observer is not ready" in { + val chunkSize = 8 + val inputData = Array.tabulate[Byte](chunkSize * 4)(_.toByte) + val context = io.grpc.Context.current().withCancellation() + + val onNextCalls = new AtomicInteger(0) + + val workerParkedWhileNotReady = new CountDownLatch(1) + + val responseObserver = new TestServerCallStreamObserver[ByteString] { + // The observer never becomes ready and never invokes the onReady/onCancel handlers + override def isReady: Boolean = { + workerParkedWhileNotReady.countDown() + false + } + + override def onNext(value: ByteString): Unit = { + onNextCalls.incrementAndGet() + () + } + } + + val resultF = GrpcStreamingUtils.streamResponseChunks(context, responseObserver)( + new ByteArrayInputStream(inputData), + chunkSize, + (chunk: ByteString) => chunk, + ) + + workerParkedWhileNotReady.await(10, TimeUnit.SECONDS) shouldBe true + context.cancel(new io.grpc.StatusRuntimeException(io.grpc.Status.CANCELLED)) + + resultF.futureValue + onNextCalls.get() shouldBe 0 + } + + "not propagate InterruptedException when the serving thread is interrupted while awaiting" in { + // Regression test: when the gRPC serving thread is interrupted (e.g. the client cancelled or + // timed out) while finishStream is blocked in Await.result, the InterruptedException must be + // handled internally and not escape. Otherwise it bubbles up to the gRPC request handler and + // is logged as an "unexpected throwable" + val responseObserver = new TestServerCallStreamObserver[ByteString] + + val producerStarted = new CountDownLatch(1) + val producer: OutputStream => Future[Unit] = { _ => + producerStarted.countDown() + Promise[Unit]().future + } + + val thrown = new AtomicReference[Throwable](null) + val finished = new CountDownLatch(1) + val thread = new Thread(() => { + try + GrpcStreamingUtils.streamToClient( + responseF = producer, + responseObserver = responseObserver, + fromByteString = fromByteString, + chunkSizeO = Some(defaultChunkSize), + ) + catch { + case t: Throwable => thrown.set(t) + } finally finished.countDown() + }) + thread.start() + + producerStarted.await(10, TimeUnit.SECONDS) shouldBe true + blocking(Thread.sleep(200)) + thread.interrupt() + + withClue("streamToClient must return after the serving thread is interrupted") { + finished.await(10, TimeUnit.SECONDS) shouldBe true + } + // With the fix, the InterruptedException is handled internally and never propagates. + Option(thrown.get()) shouldBe None + } + } } diff --git a/canton/community/common/src/test/scala/com/digitalasset/canton/util/PekkoUtilTest.scala b/canton/community/common/src/test/scala/com/digitalasset/canton/util/PekkoUtilTest.scala index fb42c470bb..481a6f44b5 100644 --- a/canton/community/common/src/test/scala/com/digitalasset/canton/util/PekkoUtilTest.scala +++ b/canton/community/common/src/test/scala/com/digitalasset/canton/util/PekkoUtilTest.scala @@ -970,43 +970,45 @@ class PekkoUtilTest uncommittedWarnTreshold = 100, recoveringQueueMetrics = RecoveringQueueMetrics.NoOp, consumerFactory = commit => - if (finalSucceeded.isCompleted) - fail("Should not get so far") - else if (thirdFailed.isCompleted) - finalSucceed.future.map { _ => - finalSucceeded.trySuccess(()) - val (sourceQueue, sourceDone) = Source - .queue[(Long, Int)](20, OverflowStrategy.backpressure, 1) - .map { elem => - commit(elem._1) - } - .toMat(Sink.ignore)(Keep.both) - .run() - FutureQueueConsumer( - futureQueue = new PekkoSourceQueueToFutureQueue( - sourceQueue = sourceQueue, - sourceDone = sourceDone, - loggerFactory = loggerFactory, - ), - fromExclusive = 0, - ) - } - else if (secondFailed.isCompleted) - thirdFail.future.map { _ => - thirdFailed.trySuccess(()) - throw new Exception("boom") - } - else if (firstFailed.isCompleted) - secondFail.future.map { _ => - secondFailed.trySuccess(()) - throw new Exception("boom") - } - else - firstFail.future.map { _ => - firstFailed.trySuccess(()) - throw new Exception("boom") - }, - initializationKillSwitch = None, + _ => + if (finalSucceeded.isCompleted) + fail("Should not get so far") + else if (thirdFailed.isCompleted) + finalSucceed.future.map { _ => + finalSucceeded.trySuccess(()) + val (sourceQueue, sourceDone) = Source + .queue[(Long, Int)](20, OverflowStrategy.backpressure, 1) + .map { elem => + commit(elem._1) + } + .toMat(Sink.ignore)(Keep.both) + .run() + Future.successful( + FutureQueueConsumer( + futureQueue = new PekkoSourceQueueToFutureQueue( + sourceQueue = sourceQueue, + sourceDone = sourceDone, + loggerFactory = loggerFactory, + ), + fromExclusive = 0, + ) + ) + } + else if (secondFailed.isCompleted) + thirdFail.future.map { _ => + thirdFailed.trySuccess(()) + throw new Exception("boom") + } + else if (firstFailed.isCompleted) + secondFail.future.map { _ => + secondFailed.trySuccess(()) + throw new Exception("boom") + } + else + firstFail.future.map { _ => + firstFailed.trySuccess(()) + throw new Exception("boom") + }, ) recoveringQueue.firstSuccessfulConsumerInitialization.isCompleted shouldBe false firstFail.trySuccess(()) @@ -1040,8 +1042,7 @@ class PekkoUtilTest retryAttemptErrorThreshold = 200, uncommittedWarnTreshold = 100, recoveringQueueMetrics = RecoveringQueueMetrics.NoOp, - consumerFactory = _ => consumerPromise.future, - initializationKillSwitch = None, + consumerFactory = _ => _ => consumerPromise.future.map(Future.successful(_)), ) Threading.sleep(10) recoveringQueue.firstSuccessfulConsumerInitialization.isCompleted shouldBe false @@ -1051,6 +1052,52 @@ class PekkoUtilTest recoveringQueue.firstSuccessfulConsumerInitialization.failed.futureValue } + "two-step initialization: firstSuccessfulConsumerInitialization is set after outer but before inner completes cleanly" in assertAllStagesStopped { + val outerPromise = Promise[Future[FutureQueueConsumer[Int]]]() + val innerPromise = Promise[FutureQueueConsumer[Int]]() + val recoveringQueue = new RecoveringFutureQueueImpl[Int]( + maxBlockedOffer = 1, + bufferSize = 20, + loggerFactory = loggerFactory, + retryStategy = PekkoUtil.exponentialRetryWithCap( + minWait = 2, + multiplier = 2, + cap = 10, + ), + retryAttemptWarnThreshold = 100, + retryAttemptErrorThreshold = 200, + uncommittedWarnTreshold = 100, + recoveringQueueMetrics = RecoveringQueueMetrics.NoOp, + consumerFactory = _ => _ => outerPromise.future, + ) + // Initiate shutdown while the outer Future is still pending. Because + // initialization is in progress, the queue is not yet considered done. + recoveringQueue.shutdown() + Threading.sleep(500) + recoveringQueue.firstSuccessfulConsumerInitialization.isCompleted shouldBe false + recoveringQueue.done.isCompleted shouldBe false + + // Resolve outer so firstSuccessfulConsumerInitialization fires. + outerPromise.success(innerPromise.future) + // give some time for the firstSuccessfulConsumerInitialization to react to the outer promise completion + Threading.sleep(500) + recoveringQueue.firstSuccessfulConsumerInitialization.futureValue + // The inner Future is still pending, so the queue is still not done. + recoveringQueue.done.isCompleted shouldBe false + + innerPromise.success( + FutureQueueConsumer( + futureQueue = new FutureQueue[(Long, Int)] { + override def offer(elem: (Long, Int)): Future[Done] = Future.successful(Done) + override def shutdown(): Unit = () + override def done: Future[Done] = Future.successful(Done) + }, + fromExclusive = 0, + ) + ) + recoveringQueue.done.futureValue + } + "block offer if buffer is full" in assertAllStagesStopped { val received = new AtomicReference[Vector[(Long, Int)]](Vector.empty) val offerGated = Promise[Unit]() @@ -1068,25 +1115,25 @@ class PekkoUtilTest uncommittedWarnTreshold = 100, recoveringQueueMetrics = RecoveringQueueMetrics.NoOp, consumerFactory = _ => - Future { - FutureQueueConsumer( - futureQueue = new FutureQueue[(Long, Int)] { - private val shutdownPromise = Promise[Unit]() - - override def offer(elem: (Long, Int)): Future[Done] = - offerGated.future.map { _ => - discard(received.accumulateAndGet(Vector(elem), _ ++ _)) - Done - } + _ => + Future.successful(Future { + FutureQueueConsumer( + futureQueue = new FutureQueue[(Long, Int)] { + private val shutdownPromise = Promise[Unit]() - override def shutdown(): Unit = shutdownPromise.trySuccess(()) + override def offer(elem: (Long, Int)): Future[Done] = + offerGated.future.map { _ => + discard(received.accumulateAndGet(Vector(elem), _ ++ _)) + Done + } - override def done: Future[Done] = shutdownPromise.future.map(_ => Done) - }, - fromExclusive = 0, - ) - }, - initializationKillSwitch = None, + override def shutdown(): Unit = shutdownPromise.trySuccess(()) + + override def done: Future[Done] = shutdownPromise.future.map(_ => Done) + }, + fromExclusive = 0, + ) + }), ) recoveringQueue.offer(1).futureValue recoveringQueue.offer(2).futureValue @@ -1129,8 +1176,7 @@ class PekkoUtilTest retryAttemptErrorThreshold = 200, uncommittedWarnTreshold = 100, recoveringQueueMetrics = RecoveringQueueMetrics.NoOp, - consumerFactory = _ => consumerPromise.future, - initializationKillSwitch = None, + consumerFactory = _ => _ => consumerPromise.future.map(Future.successful(_)), ) recoveringQueue.offer(1).futureValue recoveringQueue.offer(2).futureValue @@ -1179,20 +1225,20 @@ class PekkoUtilTest uncommittedWarnTreshold = 100, recoveringQueueMetrics = RecoveringQueueMetrics.NoOp, consumerFactory = _ => - Future { - FutureQueueConsumer( - futureQueue = new FutureQueue[(Long, Int)] { - override def offer(elem: (Long, Int)): Future[Done] = - Future.successful(Done) + _ => + Future.successful(Future { + FutureQueueConsumer( + futureQueue = new FutureQueue[(Long, Int)] { + override def offer(elem: (Long, Int)): Future[Done] = + Future.successful(Done) - override def shutdown(): Unit = shutdownPromise.trySuccess(()) + override def shutdown(): Unit = shutdownPromise.trySuccess(()) - override def done: Future[Done] = donePromise.future.map(_ => Done) - }, - fromExclusive = 0, - ) - }, - initializationKillSwitch = None, + override def done: Future[Done] = donePromise.future.map(_ => Done) + }, + fromExclusive = 0, + ) + }), ) recoveringQueue.firstSuccessfulConsumerInitialization.futureValue shutdownPromise.isCompleted shouldBe false @@ -1225,20 +1271,22 @@ class PekkoUtilTest uncommittedWarnTreshold = 100, recoveringQueueMetrics = RecoveringQueueMetrics.NoOp, consumerFactory = _ => - initializedPromise.future.map { _ => - FutureQueueConsumer( - futureQueue = new FutureQueue[(Long, Int)] { - override def offer(elem: (Long, Int)): Future[Done] = - Future.successful(Done) + _ => + initializedPromise.future.map { _ => + Future.successful( + FutureQueueConsumer( + futureQueue = new FutureQueue[(Long, Int)] { + override def offer(elem: (Long, Int)): Future[Done] = + Future.successful(Done) - override def shutdown(): Unit = shutdownPromise.trySuccess(()) + override def shutdown(): Unit = shutdownPromise.trySuccess(()) - override def done: Future[Done] = donePromise.future.map(_ => Done) - }, - fromExclusive = 0, - ) - }, - initializationKillSwitch = None, + override def done: Future[Done] = donePromise.future.map(_ => Done) + }, + fromExclusive = 0, + ) + ) + }, ) recoveringQueue.firstSuccessfulConsumerInitialization.isCompleted shouldBe false shutdownPromise.isCompleted shouldBe false @@ -1259,7 +1307,7 @@ class PekkoUtilTest "Shutdown initiated" ) logEntries(2).debugMessage should include( - "Consumer initialization is in progress, delaying shutdown" + "Consumer initialization is in progress, shutdown signal will be propagated to consumer" ) }, ) @@ -1280,13 +1328,79 @@ class PekkoUtilTest shutdownPromise.isCompleted shouldBe false initializedPromise.trySuccess(()) shutdownPromise.future.futureValue - recoveringQueue.firstSuccessfulConsumerInitialization.isCompleted shouldBe false + recoveringQueue.firstSuccessfulConsumerInitialization.isCompleted shouldBe true recoveringQueue.done.isCompleted shouldBe false Threading.sleep(10) - recoveringQueue.firstSuccessfulConsumerInitialization.isCompleted shouldBe false + recoveringQueue.firstSuccessfulConsumerInitialization.isCompleted shouldBe true recoveringQueue.done.isCompleted shouldBe false donePromise.trySuccess(()) - recoveringQueue.firstSuccessfulConsumerInitialization.failed.futureValue + recoveringQueue.firstSuccessfulConsumerInitialization.isCompleted shouldBe true + recoveringQueue.done.futureValue + } + + "propagate shutdown signal while initialization is in progress" in assertAllStagesStopped { + val initializedPromise = Promise[Unit]() + val shutdownPromise = Promise[Unit]() + val donePromise = Promise[Unit]() + val isShuttingDownObserved = Promise[Unit]() + val recoveringQueue = new RecoveringFutureQueueImpl[Int]( + maxBlockedOffer = 2, + bufferSize = 2, + loggerFactory = loggerFactory, + retryStategy = PekkoUtil.exponentialRetryWithCap( + minWait = 2, + multiplier = 2, + cap = 10, + ), + retryAttemptWarnThreshold = 100, + retryAttemptErrorThreshold = 200, + uncommittedWarnTreshold = 100, + recoveringQueueMetrics = RecoveringQueueMetrics.NoOp, + consumerFactory = _ => + isShuttingDown => + initializedPromise.future.map { _ => + if (isShuttingDown()) isShuttingDownObserved.trySuccess(()) + Future.successful( + FutureQueueConsumer( + futureQueue = new FutureQueue[(Long, Int)] { + override def offer(elem: (Long, Int)): Future[Done] = + Future.successful(Done) + + override def shutdown(): Unit = shutdownPromise.trySuccess(()) + + override def done: Future[Done] = donePromise.future.map(_ => Done) + }, + fromExclusive = 0, + ) + ) + }, + ) + recoveringQueue.firstSuccessfulConsumerInitialization.isCompleted shouldBe false + isShuttingDownObserved.isCompleted shouldBe false + // shutdown while consumer initialization is still in progress + loggerFactory.assertEventuallyLogsSeq( + SuppressionRule.LoggerNameContains("RecoveringFutureQueueImpl") && + SuppressionRule.LevelAndAbove(org.slf4j.event.Level.DEBUG) + )( + recoveringQueue.shutdown(), + logEntries => { + logEntries should have size (3) + logEntries.head.infoMessage should include( + "Before shutting down, preventing further initialization retries" + ) + logEntries(1).infoMessage should include( + "Shutdown initiated" + ) + logEntries(2).debugMessage should include( + "Consumer initialization is in progress, shutdown signal will be propagated to consumer" + ) + }, + ) + initializedPromise.trySuccess(()) + isShuttingDownObserved.future.futureValue + shutdownPromise.future.futureValue + donePromise.trySuccess(()) + recoveringQueue.firstSuccessfulConsumerInitialization.futureValue recoveringQueue.done.futureValue } @@ -1305,11 +1419,11 @@ class PekkoUtilTest retryAttemptErrorThreshold = 200, uncommittedWarnTreshold = 100, recoveringQueueMetrics = RecoveringQueueMetrics.NoOp, - consumerFactory = _ => { - firstConsumerInitializationFailedPromise.trySuccess(()) - Future.failed(new Exception("boom")) - }, - initializationKillSwitch = None, + consumerFactory = _ => + _ => { + firstConsumerInitializationFailedPromise.trySuccess(()) + Future.failed(new Exception("boom")) + }, ) firstConsumerInitializationFailedPromise.future.futureValue Threading.sleep(10) @@ -1367,14 +1481,13 @@ class PekkoUtilTest retryAttemptErrorThreshold = 6, uncommittedWarnTreshold = 100, recoveringQueueMetrics = RecoveringQueueMetrics.NoOp, - consumerFactory = { _ => + consumerFactory = { _ => _ => val f = initializationContinuePromise.get().future.map { _ => throw new Exception("initialization fails") } initializationStartedPromise.get().trySuccess(()) f }, - initializationKillSwitch = None, ) // info 1 initializationStartedPromise.get().future.futureValue @@ -1454,7 +1567,7 @@ class PekkoUtilTest "Shutdown initiated" ) logEntries(2).debugMessage should include( - "Consumer initialization is in progress, delaying shutdown" + "Consumer initialization is in progress, shutdown signal will be propagated to consumer" ) }, ) @@ -1483,36 +1596,36 @@ class PekkoUtilTest uncommittedWarnTreshold = 100, recoveringQueueMetrics = RecoveringQueueMetrics.NoOp, consumerFactory = commit => - Future { - val recoveryIndex = received.get().lastOption.map(_._1).getOrElse(0L) - if (firstConsumer.get()) { - firstConsumer.set(false) - recoveryIndexRef.set(recoveryIndex) - } else { - firstConsumer.set(true) - } - FutureQueueConsumer( - futureQueue = new FutureQueue[(Long, Int)] { - private val shutdownPromise = Promise[Unit]() - - override def offer(elem: (Long, Int)): Future[Done] = - offerGated.future.map { _ => - if (elem._2 == 4 && firstConsumer.get()) throw new Exception("boom") - else { - discard(received.accumulateAndGet(Vector(elem), _ ++ _)) - commit(elem._1) - Done + _ => + Future.successful(Future { + val recoveryIndex = received.get().lastOption.map(_._1).getOrElse(0L) + if (firstConsumer.get()) { + firstConsumer.set(false) + recoveryIndexRef.set(recoveryIndex) + } else { + firstConsumer.set(true) + } + FutureQueueConsumer( + futureQueue = new FutureQueue[(Long, Int)] { + private val shutdownPromise = Promise[Unit]() + + override def offer(elem: (Long, Int)): Future[Done] = + offerGated.future.map { _ => + if (elem._2 == 4 && firstConsumer.get()) throw new Exception("boom") + else { + discard(received.accumulateAndGet(Vector(elem), _ ++ _)) + commit(elem._1) + Done + } } - } - override def shutdown(): Unit = shutdownPromise.trySuccess(()) + override def shutdown(): Unit = shutdownPromise.trySuccess(()) - override def done: Future[Done] = shutdownPromise.future.map(_ => Done) - }, - fromExclusive = recoveryIndex, - ) - }, - initializationKillSwitch = None, + override def done: Future[Done] = shutdownPromise.future.map(_ => Done) + }, + fromExclusive = recoveryIndex, + ) + }), ) recoveringQueue.offer(1).futureValue recoveringQueue.offer(2).futureValue @@ -1559,38 +1672,38 @@ class PekkoUtilTest uncommittedWarnTreshold = 100, recoveringQueueMetrics = RecoveringQueueMetrics.NoOp, consumerFactory = commit => - Future { - val recoveryIndex = received.get().lastOption.map(_._1).getOrElse(0L) - if (firstConsumer.get()) { - firstConsumer.set(false) - recoveryIndexRef.set(recoveryIndex) - } else { - firstConsumer.set(true) - } - FutureQueueConsumer( - futureQueue = new FutureQueue[(Long, Int)] { - private val shutdownPromise = Promise[Unit]() - - override def offer(elem: (Long, Int)): Future[Done] = - offerGated.future.flatMap { _ => - if (elem._2 == 4 && firstConsumer.get()) { - shutdownPromise.tryFailure(new Exception("delegate boom")) - Future.never - } else { - discard(received.accumulateAndGet(Vector(elem), _ ++ _)) - commit(elem._1) - Future.successful(Done) + _ => + Future.successful(Future { + val recoveryIndex = received.get().lastOption.map(_._1).getOrElse(0L) + if (firstConsumer.get()) { + firstConsumer.set(false) + recoveryIndexRef.set(recoveryIndex) + } else { + firstConsumer.set(true) + } + FutureQueueConsumer( + futureQueue = new FutureQueue[(Long, Int)] { + private val shutdownPromise = Promise[Unit]() + + override def offer(elem: (Long, Int)): Future[Done] = + offerGated.future.flatMap { _ => + if (elem._2 == 4 && firstConsumer.get()) { + shutdownPromise.tryFailure(new Exception("delegate boom")) + Future.never + } else { + discard(received.accumulateAndGet(Vector(elem), _ ++ _)) + commit(elem._1) + Future.successful(Done) + } } - } - override def shutdown(): Unit = shutdownPromise.trySuccess(()) + override def shutdown(): Unit = shutdownPromise.trySuccess(()) - override def done: Future[Done] = shutdownPromise.future.map(_ => Done) - }, - fromExclusive = recoveryIndex, - ) - }, - initializationKillSwitch = None, + override def done: Future[Done] = shutdownPromise.future.map(_ => Done) + }, + fromExclusive = recoveryIndex, + ) + }), ) recoveringQueue.offer(1).futureValue recoveringQueue.offer(2).futureValue @@ -1632,45 +1745,45 @@ class PekkoUtilTest uncommittedWarnTreshold = 100, recoveringQueueMetrics = RecoveringQueueMetrics.NoOp, consumerFactory = commit => - Future { - val recoveryIndex = received.get().lastOption.map(_._1).getOrElse(0L) - if (firstConsumer.get()) { - firstConsumer.set(false) - recoveryIndexRef.set(recoveryIndex) - } else { - firstConsumer.set(true) - } - FutureQueueConsumer( - futureQueue = new FutureQueue[(Long, Int)] { - private val shutdownPromise = Promise[Unit]() - - override def offer(elem: (Long, Int)): Future[Done] = - offerGated.future.flatMap { _ => - if (elem._2 == 4 && firstConsumer.get()) { - shutdownPromise.tryFailure(new Exception("delegate boom")) - Future.never - } else if (elem._2 >= 3 && firstConsumer.get()) { - // forget, not commit - Future.successful(Done) - } else if (elem._2 >= 2 && firstConsumer.get()) { - // not commit - discard(received.accumulateAndGet(Vector(elem), _ ++ _)) - Future.successful(Done) - } else { - commit(elem._1) - discard(received.accumulateAndGet(Vector(elem), _ ++ _)) - Future.successful(Done) + _ => + Future.successful(Future { + val recoveryIndex = received.get().lastOption.map(_._1).getOrElse(0L) + if (firstConsumer.get()) { + firstConsumer.set(false) + recoveryIndexRef.set(recoveryIndex) + } else { + firstConsumer.set(true) + } + FutureQueueConsumer( + futureQueue = new FutureQueue[(Long, Int)] { + private val shutdownPromise = Promise[Unit]() + + override def offer(elem: (Long, Int)): Future[Done] = + offerGated.future.flatMap { _ => + if (elem._2 == 4 && firstConsumer.get()) { + shutdownPromise.tryFailure(new Exception("delegate boom")) + Future.never + } else if (elem._2 >= 3 && firstConsumer.get()) { + // forget, not commit + Future.successful(Done) + } else if (elem._2 >= 2 && firstConsumer.get()) { + // not commit + discard(received.accumulateAndGet(Vector(elem), _ ++ _)) + Future.successful(Done) + } else { + commit(elem._1) + discard(received.accumulateAndGet(Vector(elem), _ ++ _)) + Future.successful(Done) + } } - } - override def shutdown(): Unit = shutdownPromise.trySuccess(()) + override def shutdown(): Unit = shutdownPromise.trySuccess(()) - override def done: Future[Done] = shutdownPromise.future.map(_ => Done) - }, - fromExclusive = recoveryIndex, - ) - }, - initializationKillSwitch = None, + override def done: Future[Done] = shutdownPromise.future.map(_ => Done) + }, + fromExclusive = recoveryIndex, + ) + }), ) recoveringQueue.offer(1).futureValue recoveringQueue.offer(2).futureValue @@ -1712,41 +1825,41 @@ class PekkoUtilTest uncommittedWarnTreshold = 100, recoveringQueueMetrics = RecoveringQueueMetrics.NoOp, consumerFactory = commit => - Future { - val recoveryIndex = received.get().lastOption.map(_._1).getOrElse(0L) - if (firstConsumer.get()) { - firstConsumer.set(false) - recoveryIndexRef.set(recoveryIndex) - } else { - firstConsumer.set(true) - } - FutureQueueConsumer( - futureQueue = new FutureQueue[(Long, Int)] { - private val shutdownPromise = Promise[Unit]() - - override def offer(elem: (Long, Int)): Future[Done] = - offerGated.future.flatMap { _ => - if (elem._2 == 4 && firstConsumer.get()) { - shutdownPromise.tryFailure(new Exception("delegate boom")) - Future.never - } else if (elem._2 >= 2 && firstConsumer.get()) { - // forget, not commit - Future.successful(Done) - } else { - commit(elem._1) - discard(received.accumulateAndGet(Vector(elem), _ ++ _)) - Future.successful(Done) + _ => + Future.successful(Future { + val recoveryIndex = received.get().lastOption.map(_._1).getOrElse(0L) + if (firstConsumer.get()) { + firstConsumer.set(false) + recoveryIndexRef.set(recoveryIndex) + } else { + firstConsumer.set(true) + } + FutureQueueConsumer( + futureQueue = new FutureQueue[(Long, Int)] { + private val shutdownPromise = Promise[Unit]() + + override def offer(elem: (Long, Int)): Future[Done] = + offerGated.future.flatMap { _ => + if (elem._2 == 4 && firstConsumer.get()) { + shutdownPromise.tryFailure(new Exception("delegate boom")) + Future.never + } else if (elem._2 >= 2 && firstConsumer.get()) { + // forget, not commit + Future.successful(Done) + } else { + commit(elem._1) + discard(received.accumulateAndGet(Vector(elem), _ ++ _)) + Future.successful(Done) + } } - } - override def shutdown(): Unit = shutdownPromise.trySuccess(()) + override def shutdown(): Unit = shutdownPromise.trySuccess(()) - override def done: Future[Done] = shutdownPromise.future.map(_ => Done) - }, - fromExclusive = recoveryIndex, - ) - }, - initializationKillSwitch = None, + override def done: Future[Done] = shutdownPromise.future.map(_ => Done) + }, + fromExclusive = recoveryIndex, + ) + }), ) recoveringQueue.offer(1).futureValue recoveringQueue.offer(2).futureValue @@ -1788,42 +1901,42 @@ class PekkoUtilTest uncommittedWarnTreshold = 100, recoveringQueueMetrics = RecoveringQueueMetrics.NoOp, consumerFactory = commit => - Future { - val recoveryIndex = received.get().lastOption.map(_._1).getOrElse(0L) - if (firstConsumer.get()) { - firstConsumer.set(false) - recoveryIndexRef.set(recoveryIndex) - } else { - firstConsumer.set(true) - } - FutureQueueConsumer( - futureQueue = new FutureQueue[(Long, Int)] { - private val shutdownPromise = Promise[Unit]() - - override def offer(elem: (Long, Int)): Future[Done] = - offerGated.future.flatMap { _ => - if (elem._2 == 4 && firstConsumer.get()) { - shutdownPromise.tryFailure(new Exception("delegate boom")) - Future.never - } else if (elem._2 >= 2 && firstConsumer.get()) { - // forget, but commit: very wrong - commit(elem._1) - Future.successful(Done) - } else { - commit(elem._1) - discard(received.accumulateAndGet(Vector(elem), _ ++ _)) - Future.successful(Done) + _ => + Future.successful(Future { + val recoveryIndex = received.get().lastOption.map(_._1).getOrElse(0L) + if (firstConsumer.get()) { + firstConsumer.set(false) + recoveryIndexRef.set(recoveryIndex) + } else { + firstConsumer.set(true) + } + FutureQueueConsumer( + futureQueue = new FutureQueue[(Long, Int)] { + private val shutdownPromise = Promise[Unit]() + + override def offer(elem: (Long, Int)): Future[Done] = + offerGated.future.flatMap { _ => + if (elem._2 == 4 && firstConsumer.get()) { + shutdownPromise.tryFailure(new Exception("delegate boom")) + Future.never + } else if (elem._2 >= 2 && firstConsumer.get()) { + // forget, but commit: very wrong + commit(elem._1) + Future.successful(Done) + } else { + commit(elem._1) + discard(received.accumulateAndGet(Vector(elem), _ ++ _)) + Future.successful(Done) + } } - } - override def shutdown(): Unit = shutdownPromise.trySuccess(()) + override def shutdown(): Unit = shutdownPromise.trySuccess(()) - override def done: Future[Done] = shutdownPromise.future.map(_ => Done) - }, - fromExclusive = recoveryIndex, - ) - }, - initializationKillSwitch = None, + override def done: Future[Done] = shutdownPromise.future.map(_ => Done) + }, + fromExclusive = recoveryIndex, + ) + }), ) recoveringQueue.offer(1).futureValue recoveringQueue.offer(2).futureValue @@ -1862,38 +1975,38 @@ class PekkoUtilTest uncommittedWarnTreshold = 100, recoveringQueueMetrics = RecoveringQueueMetrics.NoOp, consumerFactory = commit => - Future { - if (sleepy) Threading.sleep(Random.nextLong(2)) - if (Random.nextLong(6) > 0) throw new Exception("initialization boom") - val (sourceQueue, sourceDone) = Source - .queue[(Long, Int)](20, OverflowStrategy.backpressure, 1) - .via(BatchN(5, 3)) - .mapAsync(3) { batch => - Future { - if (sleepy) Threading.sleep(Random.nextLong(4) / 4) - if (Random.nextLong(10) == 0) { - boomCount.incrementAndGet() - throw new Exception("boom") + _ => + Future.successful(Future[FutureQueueConsumer[Int]] { + if (sleepy) Threading.sleep(Random.nextLong(2)) + if (Random.nextLong(6) > 0) throw new Exception("initialization boom") + val (sourceQueue, sourceDone) = Source + .queue[(Long, Int)](20, OverflowStrategy.backpressure, 1) + .via(BatchN(5, 3)) + .mapAsync(3) { batch => + Future { + if (sleepy) Threading.sleep(Random.nextLong(4) / 4) + if (Random.nextLong(10) == 0) { + boomCount.incrementAndGet() + throw new Exception("boom") + } + batch } - batch } - } - .map { batch => - batch.foreach(elem => sink.getAndUpdate(elem :: _)) - commit(batch.last._1) - } - .toMat(Sink.ignore)(Keep.both) - .run() - FutureQueueConsumer( - futureQueue = new PekkoSourceQueueToFutureQueue( - sourceQueue = sourceQueue, - sourceDone = sourceDone, - loggerFactory = loggerFactory, - ), - fromExclusive = sink.get().headOption.map(_._1).getOrElse(0), - ) - }, - initializationKillSwitch = None, + .map { batch => + batch.foreach(elem => sink.getAndUpdate(elem :: _)) + commit(batch.last._1) + } + .toMat(Sink.ignore)(Keep.both) + .run() + FutureQueueConsumer( + futureQueue = new PekkoSourceQueueToFutureQueue( + sourceQueue = sourceQueue, + sourceDone = sourceDone, + loggerFactory = loggerFactory, + ), + fromExclusive = sink.get().headOption.map(_._1).getOrElse(0), + ) + }), ) val testF = Future { val inputFixture = Iterator.iterate(1)(_ + 1).take(inputSize).toList @@ -1970,30 +2083,30 @@ class PekkoUtilTest uncommittedWarnTreshold = 100, recoveringQueueMetrics = RecoveringQueueMetrics.NoOp, consumerFactory = commit => - Future { - val (sourceQueue, sourceDone) = Source - .queue[(Long, Int)](20, OverflowStrategy.backpressure, 1) - .via(BatchN(5, 3)) - .mapAsync(3) { batch => - Future { - batch + _ => + Future.successful(Future { + val (sourceQueue, sourceDone) = Source + .queue[(Long, Int)](20, OverflowStrategy.backpressure, 1) + .via(BatchN(5, 3)) + .mapAsync(3) { batch => + Future { + batch + } } - } - .map { batch => - commit(batch.last._1) - } - .toMat(Sink.ignore)(Keep.both) - .run() - FutureQueueConsumer( - futureQueue = new PekkoSourceQueueToFutureQueue( - sourceQueue = sourceQueue, - sourceDone = sourceDone, - loggerFactory = loggerFactory, - ), - fromExclusive = 0, - ) - }, - initializationKillSwitch = None, + .map { batch => + commit(batch.last._1) + } + .toMat(Sink.ignore)(Keep.both) + .run() + FutureQueueConsumer( + futureQueue = new PekkoSourceQueueToFutureQueue( + sourceQueue = sourceQueue, + sourceDone = sourceDone, + loggerFactory = loggerFactory, + ), + fromExclusive = 0, + ) + }), ) val start = System.nanoTime() Iterator diff --git a/canton/community/common/src/test/scala/com/digitalasset/canton/util/SimpleExecutionQueueTest.scala b/canton/community/common/src/test/scala/com/digitalasset/canton/util/SimpleExecutionQueueTest.scala index f25848a7ef..e5f5d165f4 100644 --- a/canton/community/common/src/test/scala/com/digitalasset/canton/util/SimpleExecutionQueueTest.scala +++ b/canton/community/common/src/test/scala/com/digitalasset/canton/util/SimpleExecutionQueueTest.scala @@ -6,6 +6,7 @@ package com.digitalasset.canton.util import com.digitalasset.canton.lifecycle.UnlessShutdown.{AbortedDueToShutdown, Outcome} import com.digitalasset.canton.lifecycle.{FutureUnlessShutdown, UnlessShutdown} import com.digitalasset.canton.logging.{LogEntry, SuppressionRule} +import com.digitalasset.canton.util.FailureMode.{ContinueAfterFailure, StopAfterFailure} import com.digitalasset.canton.{BaseTest, HasExecutionContext, config} import org.scalatest.BeforeAndAfterEach import org.scalatest.wordspec.AsyncWordSpec diff --git a/canton/community/conformance-testing/src/test/scala/com/digitalasset/canton/integration/tests/continuity/ProtocolContinuityConformanceTest.scala b/canton/community/conformance-testing/src/test/scala/com/digitalasset/canton/integration/tests/continuity/ProtocolContinuityConformanceTest.scala index 066cc92a03..792dff6f90 100644 --- a/canton/community/conformance-testing/src/test/scala/com/digitalasset/canton/integration/tests/continuity/ProtocolContinuityConformanceTest.scala +++ b/canton/community/conformance-testing/src/test/scala/com/digitalasset/canton/integration/tests/continuity/ProtocolContinuityConformanceTest.scala @@ -36,8 +36,6 @@ import com.digitalasset.canton.version.{ProtocolVersion, ReleaseVersion} import monocle.macros.syntax.lens.* import org.scalatest.concurrent.PatienceConfiguration -import scala.concurrent.duration.DurationInt - trait MultiVersionLedgerApiConformanceBase extends LedgerApiConformanceBase { protected def testedReleases: List[TestedRelease] @@ -96,7 +94,7 @@ trait MultiVersionLedgerApiConformanceBase extends LedgerApiConformanceBase { "ExplicitDisclosureIT:EDDuplicates", ) else Seq.empty - LedgerApiConformanceBase.excludedTests ++ perReleaseExclusions + perReleaseExclusions ++ LedgerApiConformanceBase.excludedTests(testedProtocolVersion) } } @@ -169,7 +167,7 @@ trait ProtocolContinuityConformanceTestSynchronizer extends ProtocolContinuityCo testedReleases.foreach { case TestedRelease(release, protocolVersions) => lazy val binDir = ReleaseUtils .retrieve(release) - .futureValue(timeout = PatienceConfiguration.Timeout(2.minutes)) + .futureValue(PatienceConfiguration.Timeout(ReleaseUtils.DefaultReleaseDownloadTimeout)) lazy val pv = protocolVersions.max1 s"run conformance tests of shard $shard with release $release and protocol $pv" in { @@ -245,7 +243,7 @@ trait ProtocolContinuityConformanceTestParticipant extends ProtocolContinuityCon testedReleases.foreach { case TestedRelease(release, protocolVersions) => lazy val binDir = ReleaseUtils .retrieve(release) - .futureValue(timeout = PatienceConfiguration.Timeout(2.minutes)) + .futureValue(PatienceConfiguration.Timeout(ReleaseUtils.DefaultReleaseDownloadTimeout)) lazy val pv = protocolVersions.max1 s"run conformance tests of shard $shard with release $release and protocol $pv" in { @@ -316,7 +314,7 @@ trait ProtocolContinuityConformanceTestPing extends ProtocolContinuityConformanc testedReleases.foreach { case TestedRelease(release, protocolVersions) => lazy val binDir = ReleaseUtils .retrieve(release) - .futureValue(timeout = PatienceConfiguration.Timeout(2.minutes)) + .futureValue(PatienceConfiguration.Timeout(ReleaseUtils.DefaultReleaseDownloadTimeout)) lazy val pv = protocolVersions.max1 s"ping between current-branch participant and release $release participant (pv=$pv)" in { @@ -436,11 +434,11 @@ private[continuity] object ProtocolContinuityConformanceTest { s"$base.ledger-api.topology-aware-package-selection.max-passes-default", s"$base.ledger-api.topology-aware-package-selection.max-passes-limit", s"$base.ledger-api.update-service", - s"$base.parameters.alpha-multi-synchronizer-support", s"$base.parameters.caching.bft-ordering-batch-cache", s"$base.parameters.caching.sequencer-catchup-payload-cache", s"$base.parameters.commit-after-failed-activeness-check", s"$base.parameters.commitment-use-db-snapshot-for-participant-lookup", + s"$base.parameters.enable-all-ledger-api-reassignments", s"$base.parameters.validate-legacy-contracts-v-11", s"$base.parameters.ledger-api-server.indexer.achs-config", s"$base.parameters.ledger-api-server.indexer.postgres-data-source", @@ -452,6 +450,8 @@ private[continuity] object ProtocolContinuityConformanceTest { s"$base.sequencer-client.channel-max-inbound-message-size", s"$base.sequencer-client.keep-alive-client.idle-timeout", s"$base.sequencer-client.keep-alive-client.keep-alive-without-calls", + s"$base.parameters.connect-to-synchronizers-on-startup", + s"$base.traffic-enforcement", ) } val perMediator = { @@ -464,6 +464,7 @@ private[continuity] object ProtocolContinuityConformanceTest { s"$base.crypto.session-signing-keys", s"$base.parameters.caching.bft-ordering-batch-cache", s"$base.parameters.caching.sequencer-catchup-payload-cache", + s"$base.parameters.delayed-verdict-sender", s"$base.sequencer-client.amplify-on-max-sequencing-time-too-far", s"$base.sequencer-client.channel-flow-control-window", s"$base.sequencer-client.channel-max-inbound-message-size", @@ -488,6 +489,8 @@ private[continuity] object ProtocolContinuityConformanceTest { s"$base.parameters.unsafe-sequencer-channel-support", // Once we remove PV34, we can remove this exception s"$base.parameters.disable-release-version-handshake-check", + s"$base.parameters.enable-prevalidation", + s"$base.parameters.enable-reject-delivered-aggregations-on-pv-35", s"$base.public-api.max-concurrent-calls-per-connection", s"$base.sequencer-client.amplify-on-max-sequencing-time-too-far", s"$base.sequencer-client.channel-flow-control-window", diff --git a/canton/community/conformance-testing/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/ExcludedTests.scala b/canton/community/conformance-testing/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/ExcludedTests.scala index 1c2e50a53c..7c4859c3b5 100644 --- a/canton/community/conformance-testing/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/ExcludedTests.scala +++ b/canton/community/conformance-testing/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/ExcludedTests.scala @@ -12,16 +12,21 @@ import com.daml.ledger.api.testtool.runner.AvailableTests object ExcludedTests { /** Suites excluded when running via JSON API (service-level unsupported operations) */ - val jsonApiExcludedSuites: Seq[String] = Seq( + val jsonApiExcludedTests: Seq[String] = Seq( // health service not available in JSON API "HealthServiceIT", // updatePartyIdentityProviderId not available; getParties fails with empty party list "PartyManagementServiceIT", // PERMISSION_DENIED due to user-management auth mismatch in JSON API "UserManagementServiceIT", + // TODO(#33111): Testing wrongly typed contract keys is not supported for JSON API + "PrefetchContractKeysIT:CSprefetchContractKeysPrepareWronglyTyped", + "PrefetchContractKeysIT:CSprefetchContractKeysWronglyTyped", + // TODO(#27501): Remove the exclusion once JSON API supports Commands.prefetchContractKeys + "PrefetchContractKeysIT", ) - lazy val grpcOnlyTestNames: Seq[String] = AvailableTests.v2_2 + lazy val grpcOnlyTestNames: Seq[String] = AvailableTests.latestStableLf .defaultTests(timeoutScaleFactor = 1.0) .flatMap(_.tests) .collect { @@ -35,5 +40,5 @@ object ExcludedTests { // On the other hand we might occasionally have tests only in previous release lines, that current main does not know about // So ideally we need to have exclude using current code plus test tools def findExcludedTests(useJson: Boolean): Seq[String] = - if (useJson) grpcOnlyTestNames ++ jsonApiExcludedSuites else Seq.empty + if (useJson) grpcOnlyTestNames ++ jsonApiExcludedTests else Seq.empty } diff --git a/canton/community/conformance-testing/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/JsonApiConformanceIntegrationTest.scala b/canton/community/conformance-testing/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/JsonApiConformanceIntegrationTest.scala index a5e77c5e97..3be6bcc066 100644 --- a/canton/community/conformance-testing/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/JsonApiConformanceIntegrationTest.scala +++ b/canton/community/conformance-testing/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/JsonApiConformanceIntegrationTest.scala @@ -65,11 +65,12 @@ sealed trait JsonApiConformanceBase concurrentTestRuns = 4, // these tests run together with all other tests connectedSynchronizers = env.environment.config.sequencers.size, ) - val availableTests = AvailableTests.v2_2 + + val availableTests = AvailableTests.testsForProtocol(testedProtocolVersion) val envArgInclusion = envArgTestsInclusion.getOrElse(TestInclusions.AllIncluded) val testsToRun = - new ConfiguredTests(availableTests, config).defaultTests.view + ConfiguredTests(availableTests, config).defaultTests.view .flatMap(_.tests) .filter { testCase => testCase.limitation match { @@ -182,7 +183,7 @@ sealed abstract class JsonApiConformanceIntegrationShardedTest( override def environmentDefinition: EnvironmentDefinition = EnvironmentDefinition.P3_S1M1_S1M1 .prependConfigTransform(ConfigTransforms.enableHttpLedgerApi) - .addConfigTransforms(ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag) + .addConfigTransforms(ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag) .withSetup { implicit env => import env.* participants.all.synchronizers.connect_local(sequencer1, alias = daName) @@ -191,8 +192,10 @@ sealed abstract class JsonApiConformanceIntegrationShardedTest( .withTrafficControl(TestUtils.waitForTargetTimeOnSynchronizerNode(wallClock.now, logger)) protected def inclusions: TestInclusions = TestInclusions.AllIncluded - override protected def exclusions: Set[String] = LedgerApiConformanceBase.excludedTests.toSet ++ - ExcludedTests.jsonApiExcludedSuites.toSet + override protected def exclusions: Set[String] = + ExcludedTests.jsonApiExcludedTests.toSet ++ LedgerApiConformanceBase.excludedTests( + testedProtocolVersion + ) protected def testCaseName = "pass the Ledger API conformance tests" } diff --git a/canton/community/conformance-testing/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/LedgerApiConformanceTest.scala b/canton/community/conformance-testing/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/LedgerApiConformanceTest.scala index 914e710e22..1e59ba1996 100644 --- a/canton/community/conformance-testing/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/LedgerApiConformanceTest.scala +++ b/canton/community/conformance-testing/src/test/scala/com/digitalasset/canton/integration/tests/ledgerapi/LedgerApiConformanceTest.scala @@ -3,6 +3,7 @@ package com.digitalasset.canton.integration.tests.ledgerapi +import com.daml.ledger.api.testtool.runner.AvailableTests import com.digitalasset.canton.config import com.digitalasset.canton.config.* import com.digitalasset.canton.config.CantonRequireTypes.InstanceName @@ -11,7 +12,6 @@ import com.digitalasset.canton.integration.ConfigTransforms.updateAllParticipant import com.digitalasset.canton.integration.plugins.* import com.digitalasset.canton.integration.plugins.UseLedgerApiTestTool.LAPITTVersion import com.digitalasset.canton.integration.plugins.UseReferenceBlockSequencer.MultiSynchronizer -import com.digitalasset.canton.integration.tests.ledgerapi.LedgerApiConformanceBase.excludedTests import com.digitalasset.canton.integration.tests.ledgerapi.SuppressionRules.ApiUserManagementServiceSuppressionRule import com.digitalasset.canton.integration.util.TestUtils import com.digitalasset.canton.integration.{ @@ -29,7 +29,8 @@ import monocle.macros.syntax.lens.* import org.slf4j.event trait SingleVersionLedgerApiConformanceBase extends LedgerApiConformanceBase { - protected def lfVersion: LanguageVersion = LanguageVersion.v2_2 + protected def lfVersion: LanguageVersion = + AvailableTests.testsForProtocol(testedProtocolVersion).lfVersion protected def lapittVersion: LAPITTVersion = LAPITTVersion.Local @@ -48,7 +49,7 @@ trait SingleVersionLedgerApiConformanceBase extends LedgerApiConformanceBase { ledgerApiTestToolPlugin.runShardedSuites( shard, numShards, - exclude = excludedTests, + exclude = LedgerApiConformanceBase.excludedTests(testedProtocolVersion), useJson = false, )(env) } @@ -109,7 +110,7 @@ class LedgerApiConformanceMultiSynchronizerTest override lazy val environmentDefinition: EnvironmentDefinition = EnvironmentDefinition.P2_S1M1_S1M1 - .addConfigTransforms(ConfigTransforms.enableAlphaMultiSynchronizerTopologyFeatureFlag) + .addConfigTransforms(ConfigTransforms.enableMultiSynchronizerTopologyFeatureFlag) .withSetup(setupLedgerApiConformanceEnvironment) // ensure ledger api conformance tests have less noisy neighbours @@ -129,7 +130,7 @@ class LedgerApiConformanceMultiSynchronizerTest new UseLedgerApiTestTool( loggerFactory, connectedSynchronizersCount = connectedSynchronizersCount, - lfVersion = LanguageVersion.v2_2, + lfVersion = AvailableTests.testsForProtocol(testedProtocolVersion).lfVersion, version = LAPITTVersion.Local, ) registerPlugin(new UsePostgres(loggerFactory)) @@ -167,7 +168,7 @@ object LedgerApiConformanceBase { "VettingIT:PVListVettedPackagesMultiSynchronizer", "VettingIT:PVListVettedPackagesPagination", ) - val excludedTests = Seq( + private val disabledTests = Seq( // Exclude tests which are run separately below "ParticipantPruningIT", "TLSOnePointThreeIT", @@ -193,7 +194,36 @@ object LedgerApiConformanceBase { "CommandServiceIT:CSRefuseBadParameter", // TODO(i31186): enable this once the issue is fixed "TransactionServiceVisibilityIT:TXLedgerEffectsHideCommandIdToNonSubmittingStakeholders", + // TODO(#33111): Test is disabled because it generates multiple command root nodes for the prepare endpoint, which is currently not supported + "PrefetchContractKeysIT:CSprefetchContractPrepareKeysMany", + // TODO(#33111): The tests below were written with UCK semantics in mind and are generally considered broken now. + // They should be checked up one by one and either fixed or removed. For now, they are excluded to unblock general testing of LF 2.3 + // tests with divulged/disclosed contracts fail on Canton as does scoping by maintainer unless we're on a UCK synchronizer (see below) + "ContractKeysIT:CKFetchOrLookup", + "ContractKeysIT:CKMaintainerScoped", + "ContractKeysIT:CKNoFetchUndisclosed", + // tests with unique contract key assumption fail as does RWArchiveVsFailedLookupByKey (finding a lookup failure after contract creation) + "RaceConditionIT:RWArchiveVsFailedLookupByKey", + "RaceConditionIT:WWArchiveVsNonTransientCreate", + "RaceConditionIT:WWDoubleNonTransientCreate", + "RaceConditionIT:RWTransientCreateVsNonTransientCreate", ) + + private val excludedTestsForPV34 = Seq( + // Package dependency unvetting becomes supported starting with PV35 + "VettingIT:PVUnvettedDependenciesSupported" + ) + + private val excludedTestsForPV35AndAbove = Seq( + // Test disabled due to package dependency unvetting becoming supported starting with PV35 + "VettingIT:PVCheckUnvettedPackagesExceptWithForceFlag" + ) + + def excludedTests(testedProtocolVersion: ProtocolVersion): Seq[String] = + disabledTests ++ { + if (testedProtocolVersion <= ProtocolVersion.v34) excludedTestsForPV34 + else excludedTestsForPV35AndAbove + } } abstract class LedgerApiShardedConformanceBase(shard: Int) diff --git a/canton/community/conformance-testing/src/test/scala/com/digitalasset/canton/integration/tests/variations/LedgerApiVariationsConformanceTest.scala b/canton/community/conformance-testing/src/test/scala/com/digitalasset/canton/integration/tests/variations/LedgerApiVariationsConformanceTest.scala index e787769d05..69a73f8c28 100644 --- a/canton/community/conformance-testing/src/test/scala/com/digitalasset/canton/integration/tests/variations/LedgerApiVariationsConformanceTest.scala +++ b/canton/community/conformance-testing/src/test/scala/com/digitalasset/canton/integration/tests/variations/LedgerApiVariationsConformanceTest.scala @@ -7,9 +7,11 @@ import com.daml.tls.{TlsServerConfig, TlsVersion} import com.digitalasset.canton.config.* import com.digitalasset.canton.config.RequireTypes.ExistingFile import com.digitalasset.canton.integration.plugins.* -import com.digitalasset.canton.integration.tests.ledgerapi.LedgerApiConformanceBase.excludedTests -import com.digitalasset.canton.integration.tests.ledgerapi.SingleVersionLedgerApiConformanceBase import com.digitalasset.canton.integration.tests.ledgerapi.SuppressionRules.ApiUserManagementServiceSuppressionRule +import com.digitalasset.canton.integration.tests.ledgerapi.{ + LedgerApiConformanceBase, + SingleVersionLedgerApiConformanceBase, +} import com.digitalasset.canton.integration.util.TestUtils import com.digitalasset.canton.integration.{ConfigTransforms, EnvironmentDefinition} import com.digitalasset.canton.logging.SuppressionRule @@ -52,7 +54,7 @@ sealed abstract class LedgerApiInMemoryFanOutConformanceTestShardedPostgres(shar ledgerApiTestToolPlugin.runShardedSuites( shard = shard, numShards = numShards, - exclude = excludedTests, + exclude = LedgerApiConformanceBase.excludedTests(testedProtocolVersion), concurrentTestRuns = VariationsConformanceTestUtils.ConcurrentTestRuns, useJson = false, ) @@ -120,7 +122,7 @@ sealed abstract class LedgerApiTinyBuffersConformanceShardedTestPostgres(shard: ledgerApiTestToolPlugin.runShardedSuites( shard = shard, numShards = numShards, - exclude = excludedTests, + exclude = LedgerApiConformanceBase.excludedTests(testedProtocolVersion), concurrentTestRuns = VariationsConformanceTestUtils.ConcurrentTestRuns, useJson = false, ) @@ -256,7 +258,7 @@ trait LedgerApiStaticTimeConformanceTest extends SingleVersionLedgerApiConforman ledgerApiTestToolPlugin.runShardedSuites( shard = 0, numShards = 1, - exclude = excludedTests ++ exclusions, + exclude = LedgerApiConformanceBase.excludedTests(testedProtocolVersion) ++ exclusions, concurrentTestRuns = VariationsConformanceTestUtils.ConcurrentTestRuns, useJson = false, ) diff --git a/canton/community/daml-lf/engine/src/main/scala/com/digitalasset/daml/lf/engine/Engine.scala b/canton/community/daml-lf/engine/src/main/scala/com/digitalasset/daml/lf/engine/Engine.scala index 20fce5166d..f5199ee091 100644 --- a/canton/community/daml-lf/engine/src/main/scala/com/digitalasset/daml/lf/engine/Engine.scala +++ b/canton/community/daml-lf/engine/src/main/scala/com/digitalasset/daml/lf/engine/Engine.scala @@ -706,7 +706,7 @@ class Engine( // token's buffer before going back to the caller. def wrapHasStarted( - overflow: Vector[FatContractInstance], + overflow: Vector[ResultNeedKey.Response.ContractEntry], callerProgression: NeedKeyProgression.HasStarted, ): NeedKeyProgression.HasStarted = if (overflow.nonEmpty) @@ -723,19 +723,34 @@ class Engine( NeedKeyProgression.Finished } + def resumeWithNeededEntries( + entries: Vector[ResultNeedKey.Response.ContractEntry], + callerHasStarted: NeedKeyProgression.HasStarted, + ) = { + val (enginePage, engineRest) = entries.splitAt(n) + val callerFcis = enginePage.map { + case ResultNeedKey.Response.AuthenticableFatContractInstance( + contractInstance, + _, + _, + ) => + contractInstance + case ResultNeedKey.Response.UnsupportedContractIdVersion(_) => + throw new NotImplementedError( + "UnsupportedContractIdVersion is not yet supported." + ) + } + callback(callerFcis, wrapHasStarted(engineRest, callerHasStarted)) + interpretLoop(machine, time, submissionInfo) + } + def askCaller(callerToken: NeedKeyProgression.CanContinue) = ResultNeedKey( gk, n, callerToken, - { - ( - callerContracts: Vector[FatContractInstance], - callerHasStarted: NeedKeyProgression.HasStarted, - ) => - val (enginePage, engineRest) = callerContracts.splitAt(n) - callback(enginePage, wrapHasStarted(engineRest, callerHasStarted)) - interpretLoop(machine, time, submissionInfo) + { case ResultNeedKey.Response(callerContracts, callerHasStarted) => + resumeWithNeededEntries(callerContracts, callerHasStarted) }, ) @@ -746,9 +761,7 @@ class Engine( if (overflow.nonEmpty) { // We have buffered contracts from a previous caller response. // Serve from the buffer without asking the caller. - val (page, rest) = overflow.splitAt(n) - callback(page, wrapHasStarted(rest, callerProgression)) - interpretLoop(machine, time, submissionInfo) + resumeWithNeededEntries(overflow, callerProgression) } else { // Empty buffer — unwrap the caller progression. callerProgression match { @@ -1110,7 +1123,7 @@ object Engine { Error.Interpretation(Error.Interpretation.DamlException(error), None) private final case class BufferedKeyContracts( - overflow: Vector[FatContractInstance], + overflow: Vector[ResultNeedKey.Response.ContractEntry], callerProgression: NeedKeyProgression.HasStarted, ) extends NeedKeyProgression.Token diff --git a/canton/community/daml-lf/engine/src/main/scala/com/digitalasset/daml/lf/engine/Result.scala b/canton/community/daml-lf/engine/src/main/scala/com/digitalasset/daml/lf/engine/Result.scala index 641a3e1cff..ce8dbe13e7 100644 --- a/canton/community/daml-lf/engine/src/main/scala/com/digitalasset/daml/lf/engine/Result.scala +++ b/canton/community/daml-lf/engine/src/main/scala/com/digitalasset/daml/lf/engine/Result.scala @@ -6,12 +6,13 @@ package engine import cats.Applicative import com.digitalasset.daml.lf.crypto.Hash -import com.digitalasset.daml.lf.data.Ref._ +import com.digitalasset.daml.lf.data.Ref.* import com.digitalasset.daml.lf.data.{BackStack, FrontStack, ImmArray} import com.digitalasset.daml.lf.engine.ResultNeedContract.Response -import com.digitalasset.daml.lf.language.Ast._ +import com.digitalasset.daml.lf.engine.ResultNeedKey.Response.AuthenticableFatContractInstance +import com.digitalasset.daml.lf.language.Ast.* import com.digitalasset.daml.lf.transaction.{FatContractInstance, GlobalKey, NeedKeyProgression} -import com.digitalasset.daml.lf.value.Value._ +import com.digitalasset.daml.lf.value.Value.* import scalaz.Monad import scala.annotation.tailrec @@ -31,7 +32,7 @@ sealed trait Result[+A] extends Product with Serializable { case ResultNeedPackage(pkgId, resume) => ResultNeedPackage(pkgId, mbPkg => resume(mbPkg).map(f)) case ResultNeedKey(gk, limit, token, resume) => - ResultNeedKey(gk, limit, token, (cids, token) => resume(cids, token).map(f)) + ResultNeedKey(gk, limit, token, response => resume(response).map(f)) case ResultPrefetch(contractIds, keys, resume) => ResultPrefetch(contractIds, keys, () => resume().map(f)) } @@ -50,7 +51,7 @@ sealed trait Result[+A] extends Product with Serializable { gk, limit, token, - (mbAcoid, nextToken) => resume(mbAcoid, nextToken).flatMap(f), + response => resume(response).flatMap(f), ) case ResultPrefetch(contractIds, keys, resume) => ResultPrefetch(contractIds, keys, () => resume().flatMap(f)) @@ -78,7 +79,24 @@ sealed trait Result[+A] extends Product with Serializable { })) case ResultNeedPackage(pkgId, resume) => go(resume(pkgs.lift(pkgId))) case ResultNeedKey(key, _, _, resume) => - go(resume(keys.lift(key).getOrElse(Vector.empty), NeedKeyProgression.Finished)) + go( + resume( + ResultNeedKey.Response( + keys + .lift(key) + .getOrElse(Vector.empty) + .map(fci => + AuthenticableFatContractInstance( + fci, + hashingMethod(fci.contractId), + hash => idValidator(fci.contractId, hash), + ) + ), + NeedKeyProgression.Finished, + ) + ) + ) + case ResultPrefetch(_, _, result) => go(result()) } go(this) @@ -165,9 +183,9 @@ final case class ResultNeedPackage[A](packageId: PackageId, resume: Option[Packa extends Result[A] /** Intermediate result indicating that contracts matching a key are required to complete the computation. - * To resume the computation, the caller must invoke `resume` with the following arguments: + * To resume the computation, the caller must invoke `resume` with a page containting the following information: *