diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..2a48a7e --- /dev/null +++ b/.air.toml @@ -0,0 +1,29 @@ +#:schema https://json.schemastore.org/any.json + +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go run github.com/a-h/templ/cmd/templ@v0.3.1020 generate && go build -o ./tmp/gl ./cmd/gl && go build -o ./tmp/dashboard ./cmd/dashboard" +entrypoint = ["bash", "./scripts/air-run.sh"] +include_ext = ["go", "templ", "toml"] +exclude_dir = ["tmp", "vendor", ".git"] +exclude_regex = ["_test\\.go$", "_templ\\.go$"] +exclude_unchanged = true +delay = 300 +stop_on_error = true +send_interrupt = true +kill_delay = 1000 + +[log] +time = true + +[misc] +clean_on_exit = true + +[screen] +clear_on_rebuild = false +keep_scroll = true + +[proxy] +enabled = false diff --git a/.dashboard.air.toml b/.dashboard.air.toml new file mode 100644 index 0000000..d38ee93 --- /dev/null +++ b/.dashboard.air.toml @@ -0,0 +1,39 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] +args_bin = ["-conf", "./dashboard.cfg.toml"] +bin = "./tmp/dashboard" +cmd = "go build -o ./tmp/dashboard ./cmd/dashboard" +delay = 1000 +exclude_dir = ["assets", "tmp", "vendor", "testdata", "gen"] +exclude_file = [] +exclude_regex = ["_test.go", ".pb.go"] +exclude_unchanged = false +follow_symlink = false +full_bin = "" +include_dir = [] +include_ext = ["go", "toml"] +include_file = [] +kill_delay = "0s" +log = "build-errors.log" +poll = false +poll_interval = 0 +post_cmd = [] +pre_cmd = [] +rerun = false +rerun_delay = 500 +send_interrupt = true +stop_on_error = true + +[log] +main_only = false +time = true + +[misc] +clean_on_exit = false + +[screen] +clear_on_rebuild = true +keep_scroll = true diff --git a/.gitea/actions/deploy-ssh/action.yml b/.gitea/actions/deploy-ssh/action.yml new file mode 100644 index 0000000..f257c91 --- /dev/null +++ b/.gitea/actions/deploy-ssh/action.yml @@ -0,0 +1,62 @@ +name: Deploy over SSH +description: SSH into server and update compose services +inputs: + ssh_key: + description: Private SSH key content + required: true + user: + description: SSH username + required: true + host: + description: SSH hostname or IP + required: true + compose_file: + description: Path to compose file on remote server + required: true + compose_project_name: + description: Compose project name on remote server + required: true + services: + description: Space-separated compose services + required: true + registry: + description: OCI registry hostname + required: false + default: oci.reg.darano.ir + registry_username: + description: OCI registry username + required: false + default: admin + registry_password: + description: OCI registry password + required: true +runs: + using: composite + steps: + - name: Deploy over SSH + shell: bash + env: + SSH_KEY: ${{ inputs.ssh_key }} + REMOTE_USER: ${{ inputs.user }} + REMOTE_HOST: ${{ inputs.host }} + COMPOSE_FILE: ${{ inputs.compose_file }} + COMPOSE_PROJECT_NAME: ${{ inputs.compose_project_name }} + SERVICES: ${{ inputs.services }} + REGISTRY: ${{ inputs.registry }} + REGISTRY_USERNAME: ${{ inputs.registry_username }} + REGISTRY_PASSWORD: ${{ inputs.registry_password }} + run: | + mkdir -p ~/.ssh + chmod 700 ~/.ssh + echo "$SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + printf '%s' "$REGISTRY_PASSWORD" | ssh -i ~/.ssh/deploy_key \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + "$REMOTE_USER@$REMOTE_HOST" \ + "docker login '$REGISTRY' -u '$REGISTRY_USERNAME' --password-stdin && \ + export COMPOSE_PROJECT_NAME='$COMPOSE_PROJECT_NAME' && \ + docker compose -f '$COMPOSE_FILE' up $SERVICES -d --pull=always; \ + rc=\$?; \ + docker logout '$REGISTRY'; \ + exit \$rc" diff --git a/.gitea/actions/docker-build-push/action.yml b/.gitea/actions/docker-build-push/action.yml new file mode 100644 index 0000000..6f5e5f5 --- /dev/null +++ b/.gitea/actions/docker-build-push/action.yml @@ -0,0 +1,31 @@ +name: Docker Build +description: Build image and load it into the local Docker daemon +inputs: + image: + description: Full image name without a tag + required: true + branch: + description: Branch tag + required: true + commit_sha: + description: Commit SHA tag + required: true +runs: + using: composite + steps: + - name: Build + shell: bash + env: + IMAGE: ${{ inputs.image }} + BRANCH: ${{ inputs.branch }} + COMMIT_SHA: ${{ inputs.commit_sha }} + run: | + set -euo pipefail + + docker buildx build \ + -f deployment/Dockerfile \ + --build-arg "GO_PROXY=https://go.reg.darano.ir" \ + --load \ + -t "$IMAGE:$BRANCH" \ + -t "$IMAGE:$COMMIT_SHA" \ + . diff --git a/.gitea/actions/docker-login/action.yml b/.gitea/actions/docker-login/action.yml new file mode 100644 index 0000000..1cc16f1 --- /dev/null +++ b/.gitea/actions/docker-login/action.yml @@ -0,0 +1,25 @@ +name: Docker Login +description: Login to OCI registry +inputs: + registry: + description: Registry hostname + required: false + default: oci.reg.darano.ir + username: + description: Registry username + required: false + default: admin + password: + description: Registry password + required: true +runs: + using: composite + steps: + - name: Login to registry + shell: bash + env: + REG_USER: ${{ inputs.username }} + REG_PASS: ${{ inputs.password }} + REGISTRY: ${{ inputs.registry }} + run: | + echo "$REG_PASS" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin diff --git a/.gitea/actions/notify-telegram/action.yml b/.gitea/actions/notify-telegram/action.yml new file mode 100644 index 0000000..82f5c2f --- /dev/null +++ b/.gitea/actions/notify-telegram/action.yml @@ -0,0 +1,38 @@ +name: Notify Telegram +description: Send CI/CD status message via Bale bot +inputs: + bot_token: + description: Bale bot token + required: true + chat_id: + description: Target chat ID + required: true + status: + description: Job status + required: true + repository: + description: Repository name + required: true + sha: + description: Commit SHA + required: true +runs: + using: composite + steps: + - name: Send notification + shell: bash + env: + BOT_TOKEN: ${{ inputs.bot_token }} + CHAT_ID: ${{ inputs.chat_id }} + STATUS: ${{ inputs.status }} + REPO: ${{ inputs.repository }} + SHA: ${{ inputs.sha }} + run: | + if [ "$STATUS" = "success" ]; then + MSG="✅ CI/CD passed: ${REPO}@${SHA}" + else + MSG="❌ CI/CD failed: ${REPO}@${SHA}" + fi + curl -s -X POST "https://tapi.bale.ai/bot${BOT_TOKEN}/sendMessage" \ + -d chat_id="${CHAT_ID}" \ + -d text="$MSG" diff --git a/.gitea/workflows/ci-dev.yaml b/.gitea/workflows/ci-dev.yaml new file mode 100644 index 0000000..66fcfe3 --- /dev/null +++ b/.gitea/workflows/ci-dev.yaml @@ -0,0 +1,49 @@ +--- +name: CI/CD Dev +on: + push: + branches: + - dev +jobs: + dev: + runs-on: ubuntu-latest + steps: + - name: Checkout Git + uses: https://git.darano.ir/actions/checkout@v5 + with: + token: ${{ gitea.token }} + path: ./ + submodules: recursive + - name: Login to registry + uses: ./.gitea/actions/docker-login + with: + password: ${{ secrets.REG_PASS }} + - name: Build + uses: ./.gitea/actions/docker-build-push + with: + image: oci.reg.darano.ir/gl/app + branch: dev + commit_sha: ${{ gitea.sha }} + - name: Push + run: | + docker push oci.reg.darano.ir/gl/app:dev + docker push oci.reg.darano.ir/gl/app:${{ gitea.sha }} + - name: Deploy + uses: ./.gitea/actions/deploy-ssh + with: + ssh_key: ${{ secrets.DEV_SERVER_KEY }} + user: ${{ secrets.DEV_SERVER_USER }} + host: ${{ secrets.DEV_SERVER_HOST }} + compose_file: /services/srv-dev/compose.yml + compose_project_name: srv-dev + services: gl + registry_password: ${{ secrets.REG_PASS }} + - name: Notify Telegram + if: always() + uses: ./.gitea/actions/notify-telegram + with: + bot_token: ${{ secrets.TELEGRAM_BOT_TOKEN }} + chat_id: ${{ secrets.TELEGRAM_CHAT_ID }} + status: ${{ job.status }} + repository: ${{ gitea.repository }} + sha: ${{ gitea.sha }} diff --git a/.gitea/workflows/ci-stage.yaml b/.gitea/workflows/ci-stage.yaml new file mode 100644 index 0000000..3ffc818 --- /dev/null +++ b/.gitea/workflows/ci-stage.yaml @@ -0,0 +1,49 @@ +--- +name: CI/CD Stage +on: + push: + branches: + - stage +jobs: + stage: + runs-on: ubuntu-latest + steps: + - name: Checkout Git + uses: https://git.darano.ir/actions/checkout@v5 + with: + token: ${{ gitea.token }} + path: ./ + submodules: recursive + - name: Login to registry + uses: ./.gitea/actions/docker-login + with: + password: ${{ secrets.REG_PASS }} + - name: Build + uses: ./.gitea/actions/docker-build-push + with: + image: oci.reg.darano.ir/gl/app + branch: stage + commit_sha: ${{ gitea.sha }} + - name: Push + run: | + docker push oci.reg.darano.ir/gl/app:stage + docker push oci.reg.darano.ir/gl/app:${{ gitea.sha }} + - name: Deploy + uses: ./.gitea/actions/deploy-ssh + with: + ssh_key: ${{ secrets.STAGE_SERVER_KEY }} + user: ${{ secrets.STAGE_SERVER_USER }} + host: ${{ secrets.STAGE_SERVER_HOST }} + compose_file: /services/srv-stage/compose.yml + compose_project_name: srv-stage + services: gl + registry_password: ${{ secrets.REG_PASS }} + - name: Notify Telegram + if: always() + uses: ./.gitea/actions/notify-telegram + with: + bot_token: ${{ secrets.TELEGRAM_BOT_TOKEN }} + chat_id: ${{ secrets.TELEGRAM_CHAT_ID }} + status: ${{ job.status }} + repository: ${{ gitea.repository }} + sha: ${{ gitea.sha }} diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..ba17ec1 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,49 @@ +--- +name: CI/CD +on: + push: + branches: + - main +jobs: + main: + runs-on: ubuntu-latest + steps: + - name: Checkout Git + uses: https://git.darano.ir/actions/checkout@v5 + with: + token: ${{ gitea.token }} + path: ./ + submodules: recursive + - name: Login to registry + uses: ./.gitea/actions/docker-login + with: + password: ${{ secrets.REG_PASS }} + - name: Build + uses: ./.gitea/actions/docker-build-push + with: + image: oci.reg.darano.ir/gl/app + branch: main + commit_sha: ${{ gitea.sha }} + - name: Push + run: | + docker push oci.reg.darano.ir/gl/app:main + docker push oci.reg.darano.ir/gl/app:${{ gitea.sha }} + - name: Deploy + uses: ./.gitea/actions/deploy-ssh + with: + ssh_key: ${{ secrets.PROD_SERVER_KEY }} + user: ${{ secrets.PROD_SERVER_USER }} + host: ${{ secrets.PROD_SERVER_HOST }} + compose_file: /services/srv-main/compose.yml + compose_project_name: srv-main + services: gl + registry_password: ${{ secrets.REG_PASS }} + - name: Notify Telegram + if: always() + uses: ./.gitea/actions/notify-telegram + with: + bot_token: ${{ secrets.TELEGRAM_BOT_TOKEN }} + chat_id: ${{ secrets.TELEGRAM_CHAT_ID }} + status: ${{ job.status }} + repository: ${{ gitea.repository }} + sha: ${{ gitea.sha }} diff --git a/.gitignore b/.gitignore index c8969f7..cde82a1 100644 --- a/.gitignore +++ b/.gitignore @@ -22,9 +22,15 @@ go.work go.work.sum +# Air live-reload build artifacts +/tmp/ + # env file .env +# macOS metadata +.DS_Store + # ---> JupyterNotebooks # gitignore template for Jupyter Notebooks # website: http://jupyter.org/ @@ -240,4 +246,4 @@ cython_debug/ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* - +.tmp-go-cache \ No newline at end of file diff --git a/.gl.air.toml b/.gl.air.toml new file mode 100644 index 0000000..71bc718 --- /dev/null +++ b/.gl.air.toml @@ -0,0 +1,39 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] +args_bin = ["-conf", "./gl.cfg.toml"] +bin = "./tmp/gl" +cmd = "go build -o ./tmp/gl ./cmd/gl" +delay = 1000 +exclude_dir = ["assets", "tmp", "vendor", "testdata", "gen"] +exclude_file = [] +exclude_regex = ["_test.go", ".pb.go"] +exclude_unchanged = false +follow_symlink = false +full_bin = "" +include_dir = [] +include_ext = ["go", "toml"] +include_file = [] +kill_delay = "0s" +log = "build-errors.log" +poll = false +poll_interval = 0 +post_cmd = [] +pre_cmd = [] +rerun = false +rerun_delay = 500 +send_interrupt = true +stop_on_error = true + +[log] +main_only = false +time = true + +[misc] +clean_on_exit = false + +[screen] +clear_on_rebuild = true +keep_scroll = true diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..69e399c --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,170 @@ +# General Ledger service design + +Status: append-only model accepted on 2026-08-14; authority and availability policy revised on 2026-08-28. + +## Purpose and ownership + +`GL` is Darano's primary financial source of truth. It records every wallet +value movement as an append-only journal. Kuknos, through its Stellar-compatible +interface, is the secondary settlement and verification source of truth. + +GL owns its PostgreSQL database and exposes an internal gRPC API. Wallet does +not write GL tables directly, and GL does not write wallet tables. In normal +operation a transaction requires both GL and Kuknos. If GL is unhealthy, all +value-changing transaction admission and processing halt until it recovers. If +Kuknos is unavailable, an authorized operator may explicitly enable GL-only +operation through an AdminPanel toggle or configuration. Kuknos must never be +disabled automatically. + +## Non-negotiable invariants + +1. Every monetary journal is double entry. Signed entries sum to zero for each + asset in a journal. +2. Journals and entries are append-only. Corrections append a full reversal and + a replacement; update and delete operations are not exposed. +3. Amounts cross the API as canonical decimal strings and are stored as + `numeric(38,18)`. Floating-point values are rejected at the GL boundary. +4. A source idempotency key identifies one immutable payload. Replaying the + same key and payload succeeds with the original result; reusing the key for + different content fails. +5. Account, asset, source transaction, correlation, actor, occurrence time, and + recording time are retained on every applicable record. +6. A journal commits atomically with all its entries or not at all. +7. A financial effect is journaled only when Wallet has committed that effect. + Pending and failed lifecycle events may be retained as audit events but do + not create monetary entries. +8. Derived balances are rebuildable solely by ordering and summing immutable + entries. Cached balances are disposable projections, never source data. + +## Data model + +### `ledger_accounts` + +A stable account is scoped to an asset and optional owner. Initial account +classes are `USER_AVAILABLE`, `USER_FROZEN`, `EXTERNAL_BLOCKCHAIN`, +`TREASURY`, `MARKET_CLEARING`, `IPG_CLEARING`, and `COMMISSION_REVENUE`. +The natural identity `(class, owner_type, owner_id, asset_id)` is unique. + +### `journals` + +Each journal contains a generated UUID, source service, unique idempotency key, +source transaction ID and tracking code, effect kind and version, optional +reversal target, occurred/recorded timestamps, correlation and actor IDs, +blockchain network/hash/sequence references, and JSON metadata. A canonical +payload hash detects conflicting reuse of an idempotency key. + +### `entries` + +Each entry contains its journal ID, line number, ledger account, asset ID, +signed decimal amount, and optional description. `(journal_id, line_number)` is +unique. Positive means credit and negative means debit. Zero entries are +invalid. Database constraints and the application transaction jointly enforce +precision, immutability, account/asset agreement, and per-asset balance. + +### `transaction_events` + +Lifecycle observations such as `CREATED`, `PENDING_TRX`, `PENDING_ADMIN`, +`FAILED`, and `SUCCESSFUL` are append-only events keyed idempotently by source +transaction and event version. They preserve error and blockchain metadata but +are separate from monetary journals. + +## Wallet-to-ledger mapping + +Every row below balances independently per asset. Where one business operation +moves two assets, the entries belong to one correlated journal containing two +balanced asset groups. + +| Wallet effect | Debit | Credit | +|---|---|---| +| Internal transfer | sender `USER_AVAILABLE` | recipient `USER_AVAILABLE` | +| External deposit | `EXTERNAL_BLOCKCHAIN` | recipient `USER_AVAILABLE` | +| External withdrawal | sender `USER_AVAILABLE` | `EXTERNAL_BLOCKCHAIN` | +| Freeze/lock | user `USER_AVAILABLE` | user `USER_FROZEN` | +| Release/unlock | user `USER_FROZEN` | user `USER_AVAILABLE` | +| IRT/IPG deposit | `IPG_CLEARING` | user `USER_AVAILABLE` | +| IRT withdrawal | user `USER_AVAILABLE` | `IPG_CLEARING` | +| Commission | payer or originating clearing account | `COMMISSION_REVENUE` | +| Buy/ICO | buyer IRT; treasury asset inventory | `TREASURY` IRT; buyer asset | +| Sell/redeem | seller asset; `TREASURY` IRT | treasury asset inventory; seller IRT | +| Market trade | buyer quote and seller base | seller quote and buyer base | + +Trustline creation is non-monetary and is recorded only as a transaction event. +The adapter must derive accounts from stable IDs, not display names or public +keys. Before integration, each existing Wallet call site must confirm its exact +counterparty and fee legs; missing information is a validation failure, not an +implicit suspense posting. + +## Delivery and ordering + +Wallet owns a durable `ledger_outbox` table. The wallet state change, wallet +transaction update, and outbox insert occur in the same PostgreSQL transaction. +A dispatcher delivers events to GL with at-least-once semantics. It retries with +backoff and never drops a record; poison records are quarantined and alerted but +remain replayable. + +The idempotency key format is +`wallet:::v`. A transaction hash is +metadata, not an identity, because it can be absent or replaced. GL serializes +posts that affect the same accounts, records both source and receipt ordering, +and returns the existing journal for identical duplicates. Together, the +transactional outbox and GL idempotency provide effectively-once posting. + +Wallet must not synchronously dual-write its database and GL. GL/network +unavailability therefore cannot lose an already committed wallet effect and +does not hold a wallet database transaction open. + +## Operating modes and failure semantics + +- `NORMAL`: GL and Kuknos must both be healthy. A transaction is successful + only after its required GL journal and Kuknos settlement evidence exist. +- `KUKNOS_DISABLED`: explicitly enabled and disabled by an authorized operator + through AdminPanel or configuration. GL remains mandatory and authoritative; + eligible transactions may proceed without Kuknos. Every mode transition must + be immutable, attributable, time-bounded where configured, and emitted via + OpenTelemetry. This mode is not implemented yet. +- `RECONCILE`: transaction processing is paused or restricted while tooling + compares GL and Kuknos and appends approved reversals/corrections. Existing + ledger records are never edited or deleted. + +GL failure is always fail-closed. Public readiness reports a critical state and +the incident and recovery are emitted through OpenTelemetry. Kuknos failure is +also fail-closed unless `KUKNOS_DISABLED` has been explicitly authorized. + +GL rejects unbalanced journals, invalid precision, unknown account/asset +combinations, duplicate line numbers, missing source identity, conflicting +idempotency payloads, and invalid reversals. Identical retries are successful. +If Wallet cannot write its outbox in the local transaction, that wallet +transaction rolls back. If dispatch fails, Wallet records retry state without +changing the committed financial result. + +## Internal service boundary + +The first `ledger.v1.GeneralLedgerService` contract provides: + +- `AppendJournal` for one atomic, idempotent balanced journal; +- `AppendTransactionEvent` for non-monetary lifecycle history; +- `GetJournal` and `ListEntries` for audit and replay inspection; +- `GetBalance` for a rebuildable account/asset projection; +- `Health` for readiness and database health. + +Wallet depends on its own `Ledger` port. Its gRPC implementation translates +wallet domain values to this contract; Wallet never imports GL persistence +types. Deadlines, retry classification, authentication, and correlation metadata +are adapter concerns. + +## Implementation sequence + +1. Add backward-compatible `ledger/v1` protobuf messages and service methods. +2. Scaffold GL with domain, application, infrastructure, and interface layers. +3. Add explicit SQL migrations and a transactional PostgreSQL repository. +4. Implement invariant validation, idempotency, queries, and reversals. +5. Add Wallet's ledger port, gRPC adapter, transactional outbox, and dispatcher. +6. Map each Wallet transaction path and add outage/replay/reconciliation tests. + +## Initial non-goals + +- Disabling Kuknos automatically on health-check failure. +- Editing or deleting posted journals. +- Storing binary floats or using Wallet's mutable transaction table as GL. +- Sharing a database schema between Wallet and GL. +- Migrating UI, documentation-site, or DevOps repositories. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1072051 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +.PHONY: build generate gl-load-test gl-redistribution-load-test load-test test + +generate: + go run github.com/a-h/templ/cmd/templ@v0.3.1020 generate + buf generate ../proto --template ./buf.gen.yaml --path ../proto/base/v1 --path ../proto/ledger/v1 + +build: generate + go build ./... + +test: + go test ./... + +load-test: + go run ./cmd/dashboard-loadtest $(LOADTEST_ARGS) + +gl-load-test: + go run ./cmd/gl-loadtest $(GL_LOADTEST_ARGS) + +gl-redistribution-load-test: + go run ./cmd/gl-redistribution-loadtest $(GL_REDISTRIBUTION_LOADTEST_ARGS) diff --git a/README.md b/README.md index a6a9bdf..61e7b24 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,60 @@ # GL +Darano's independent, append-only general-ledger service. It preserves every +committed wallet value movement so financial state can be reconstructed when a +blockchain or provider is unavailable. + +The implementation contract and invariants are defined in [DESIGN.md](DESIGN.md). + +Generate protobufs, test, and build with: + +```bash +make generate +make test +make build +``` + +Run the ledger API and dashboard as separate processes: + +```bash +go run ./cmd/gl -conf ./gl.cfg.toml +go run ./cmd/dashboard -conf ./dashboard.cfg.toml +``` + +For live reload, install [Air](https://github.com/air-verse/air) and run `air` +from the repository root. The checked-in `.air.toml` rebuilds and restarts both +processes together. + +The read-only network explorer is served on `http://localhost:8080` by default: + +The header includes English/Persian localization and a persistent light/dark +theme toggle that initially follows the operating-system preference. + +- `/` shows network totals and recent committed journals; +- `/assets` ranks the top positive user holders for recently active assets; +- `/holders` combines a user's available and frozen balance and activity for one asset; +- `/transactions` shows paginated recent transactions, filters them by user/wallet and effect type, and finds journals by exact blockchain transaction hash or internal journal ID; +- `/accounts` rebuilds a ledger account balance and activity history. + +The gRPC service owns schema migrations. The dashboard is read-only, uses its +own `dashboard.cfg.toml`, and does not run migrations. + +Run the dashboard load test against a started dashboard with: + +```bash +make load-test LOADTEST_ARGS='-duration 30s -concurrency 50' +``` + +Use `-paths` to exercise known explorer records as well as the overview, for +example `-paths '/,/transactions?q=KNOWN_HASH,/accounts?class=USER_AVAILABLE&owner_type=user&owner_id=17&asset_id=9'`. + +Run the fixed 10-person, 10,000,000-transfer GL conservation scenario with: + +```bash +make gl-redistribution-load-test +Asset number: 7 +``` + +It starts with one person holding 10,000, uses random transfer values while +protecting a minimum of 45 per person, and verifies that the final ten balances +still sum to exactly 10,000. diff --git a/application/explorer/service.go b/application/explorer/service.go new file mode 100644 index 0000000..4420f30 --- /dev/null +++ b/application/explorer/service.go @@ -0,0 +1,282 @@ +// Package explorer implements the read-only queries used by GL's web explorer. +package explorer + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + "gl/domain/ledger" +) + +const ( + recentJournalLimit = 12 + recentSettlementLimit = 12 + transactionPageSize = 20 + topAssetLimit = 5 + topHoldersPerAsset = 5 +) + +type Stats struct { + JournalCount int64 + EntryCount int64 + AccountCount int64 + LastRecordedAt time.Time +} + +type SettlementStats struct { + Pending int64 + Retryable int64 + Confirmed int64 + ManualReview int64 +} + +type Settlement struct { + ID string + SourceService string + SourceTransactionID string + Status ledger.SettlementStatus + Network string + TransactionHash string + Attempts int + LastError string + UpdatedAt time.Time +} + +type Holder struct { + Rank int64 + OwnerType string + OwnerID string + AssetID int64 + Balance ledger.Amount +} + +type HolderReference struct { + OwnerType string + OwnerID string + AssetID int64 +} + +type Repository interface { + Stats(context.Context) (Stats, error) + List(context.Context, ledger.JournalFilter) ([]ledger.Journal, error) + GetByTransactionHash(context.Context, string) ([]ledger.Journal, error) + GetByID(context.Context, string) (ledger.Journal, error) + Balance(context.Context, ledger.AccountReference, time.Time) (ledger.Amount, error) + TopHolders(context.Context, int, int) ([]Holder, error) + SettlementDashboard(context.Context, int) (SettlementStats, []Settlement, error) +} + +type Service struct { + repository Repository +} + +type Dashboard struct { + Stats Stats + Journals []ledger.Journal + SettlementStats SettlementStats + Settlements []Settlement +} + +type Assets struct { + TopHolders []Holder +} + +type TransactionListing struct { + Journals []ledger.Journal + Filter TransactionFilter + HasPrevious bool + HasNext bool +} + +type TransactionFilter struct { + Page int + Wallet string + EffectKind string +} + +type Account struct { + Reference ledger.AccountReference + Balance ledger.Amount + Journals []ledger.Journal +} + +type HolderAccount struct { + Reference HolderReference + Balance ledger.Amount + Journals []ledger.Journal +} + +func NewService(repository Repository) *Service { + return &Service{repository: repository} +} + +func (s *Service) Dashboard(ctx context.Context) (Dashboard, error) { + stats, err := s.repository.Stats(ctx) + if err != nil { + return Dashboard{}, fmt.Errorf("read explorer stats: %w", err) + } + journals, err := s.repository.List(ctx, ledger.JournalFilter{Limit: recentJournalLimit}) + if err != nil { + return Dashboard{}, fmt.Errorf("read recent journals: %w", err) + } + settlementStats, settlements, err := s.repository.SettlementDashboard(ctx, recentSettlementLimit) + if err != nil { + return Dashboard{}, fmt.Errorf("read settlement dashboard: %w", err) + } + return Dashboard{Stats: stats, Journals: journals, SettlementStats: settlementStats, Settlements: settlements}, nil +} + +func (s *Service) Assets(ctx context.Context) (Assets, error) { + holders, err := s.repository.TopHolders(ctx, topAssetLimit, topHoldersPerAsset) + if err != nil { + return Assets{}, fmt.Errorf("read top asset holders: %w", err) + } + return Assets{TopHolders: holders}, nil +} + +func (s *Service) Transactions(ctx context.Context, filter TransactionFilter) (TransactionListing, error) { + filter.Wallet = strings.TrimSpace(filter.Wallet) + filter.EffectKind = strings.TrimSpace(filter.EffectKind) + if filter.Page < 1 { + filter.Page = 1 + } + if filter.Page > 1_000_000 { + return TransactionListing{}, fmt.Errorf("transaction page is too large") + } + if len(filter.Wallet) > 256 || len(filter.EffectKind) > 128 { + return TransactionListing{}, fmt.Errorf("transaction filter is too long") + } + repositoryFilter := ledger.JournalFilter{ + Limit: transactionPageSize + 1, + Offset: (filter.Page - 1) * transactionPageSize, + } + if filter.Wallet != "" { + repositoryFilter.OwnerID = &filter.Wallet + } + if filter.EffectKind != "" { + repositoryFilter.EffectKind = &filter.EffectKind + } + journals, err := s.repository.List(ctx, repositoryFilter) + if err != nil { + return TransactionListing{}, fmt.Errorf("read transaction page: %w", err) + } + listing := TransactionListing{Journals: journals, Filter: filter, HasPrevious: filter.Page > 1} + if len(listing.Journals) > transactionPageSize { + listing.HasNext = true + listing.Journals = listing.Journals[:transactionPageSize] + } + return listing, nil +} + +func (s *Service) Transaction(ctx context.Context, reference string) ([]ledger.Journal, error) { + reference = strings.TrimSpace(reference) + if reference == "" { + return nil, fmt.Errorf("transaction reference is required") + } + if len(reference) > 256 { + return nil, fmt.Errorf("transaction reference is too long") + } + journals, err := s.repository.GetByTransactionHash(ctx, reference) + if err != nil { + return nil, fmt.Errorf("read transaction: %w", err) + } + if len(journals) != 0 { + return journals, nil + } + if !isJournalID(reference) { + return nil, ledger.ErrNotFound + } + journal, err := s.repository.GetByID(ctx, reference) + if err != nil { + if errors.Is(err, ledger.ErrNotFound) { + return nil, ledger.ErrNotFound + } + return nil, fmt.Errorf("read internal transaction: %w", err) + } + return []ledger.Journal{journal}, nil +} + +func isJournalID(value string) bool { + if len(value) != 36 { + return false + } + for index, character := range value { + switch index { + case 8, 13, 18, 23: + if character != '-' { + return false + } + default: + if !((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f') || (character >= 'A' && character <= 'F')) { + return false + } + } + } + return true +} + +func (s *Service) Account(ctx context.Context, reference ledger.AccountReference) (Account, error) { + if err := reference.Validate(); err != nil { + return Account{}, fmt.Errorf("invalid account: %w", err) + } + balance, err := s.repository.Balance(ctx, reference, time.Time{}) + if err != nil { + return Account{}, fmt.Errorf("read account balance: %w", err) + } + journals, err := s.repository.List(ctx, ledger.JournalFilter{ + Account: &reference, + AssetID: &reference.AssetID, + Limit: 50, + }) + if err != nil { + return Account{}, fmt.Errorf("read account journals: %w", err) + } + return Account{Reference: reference, Balance: balance, Journals: journals}, nil +} + +func (s *Service) Holder(ctx context.Context, reference HolderReference) (HolderAccount, error) { + if strings.TrimSpace(reference.OwnerType) == "" || strings.TrimSpace(reference.OwnerID) == "" || reference.AssetID <= 0 { + return HolderAccount{}, fmt.Errorf("invalid holder reference") + } + + result := HolderAccount{Reference: reference} + seen := make(map[string]struct{}) + for _, class := range []ledger.AccountClass{ledger.AccountClassUserAvailable, ledger.AccountClassUserFrozen} { + account := ledger.AccountReference{Class: class, OwnerType: reference.OwnerType, OwnerID: reference.OwnerID, AssetID: reference.AssetID} + balance, err := s.repository.Balance(ctx, account, time.Time{}) + if err != nil { + return HolderAccount{}, fmt.Errorf("read holder balance: %w", err) + } + result.Balance = result.Balance.Add(balance) + + journals, err := s.repository.List(ctx, ledger.JournalFilter{Account: &account, AssetID: &reference.AssetID, Limit: 50}) + if err != nil { + return HolderAccount{}, fmt.Errorf("read holder journals: %w", err) + } + for _, journal := range journals { + if _, exists := seen[journal.ID]; exists { + continue + } + seen[journal.ID] = struct{}{} + result.Journals = append(result.Journals, journal) + } + } + sort.Slice(result.Journals, func(left, right int) bool { + if result.Journals[left].RecordedAt.Equal(result.Journals[right].RecordedAt) { + return result.Journals[left].ID > result.Journals[right].ID + } + return result.Journals[left].RecordedAt.After(result.Journals[right].RecordedAt) + }) + if len(result.Journals) > 50 { + result.Journals = result.Journals[:50] + } + return result, nil +} + +func IsNotFound(err error) bool { + return errors.Is(err, ledger.ErrNotFound) +} diff --git a/application/explorer/service_test.go b/application/explorer/service_test.go new file mode 100644 index 0000000..adec6da --- /dev/null +++ b/application/explorer/service_test.go @@ -0,0 +1,150 @@ +package explorer + +import ( + "context" + "fmt" + "testing" + "time" + + "gl/domain/ledger" +) + +type repositoryStub struct { + stats Stats + journals []ledger.Journal + transactions []ledger.Journal + journal ledger.Journal + journalErr error + holders []Holder + balance ledger.Amount + settlementStats SettlementStats + settlements []Settlement + lastFilter *ledger.JournalFilter +} + +func (r repositoryStub) Stats(context.Context) (Stats, error) { return r.stats, nil } +func (r repositoryStub) List(_ context.Context, filter ledger.JournalFilter) ([]ledger.Journal, error) { + if r.lastFilter != nil { + *r.lastFilter = filter + } + return r.journals, nil +} +func (r repositoryStub) GetByTransactionHash(context.Context, string) ([]ledger.Journal, error) { + return r.transactions, nil +} +func (r repositoryStub) GetByID(context.Context, string) (ledger.Journal, error) { + return r.journal, r.journalErr +} +func (r repositoryStub) Balance(context.Context, ledger.AccountReference, time.Time) (ledger.Amount, error) { + return r.balance, nil +} +func (r repositoryStub) TopHolders(context.Context, int, int) ([]Holder, error) { + return r.holders, nil +} +func (r repositoryStub) SettlementDashboard(context.Context, int) (SettlementStats, []Settlement, error) { + return r.settlementStats, r.settlements, nil +} + +func TestDashboardIncludesBlockchainSettlements(t *testing.T) { + service := NewService(repositoryStub{ + settlementStats: SettlementStats{Confirmed: 7, Pending: 2}, + settlements: []Settlement{{ID: "settlement-1", Status: ledger.SettlementConfirmed, TransactionHash: "chain-hash"}}, + }) + result, err := service.Dashboard(context.Background()) + if err != nil { + t.Fatal(err) + } + if result.SettlementStats.Confirmed != 7 || len(result.Settlements) != 1 || result.Settlements[0].TransactionHash != "chain-hash" { + t.Fatalf("unexpected settlement dashboard: %+v", result) + } +} + +func TestAssetsIncludesTopHolders(t *testing.T) { + balance, err := ledger.ParseAmount("125.5") + if err != nil { + t.Fatal(err) + } + service := NewService(repositoryStub{holders: []Holder{{Rank: 1, OwnerType: "user", OwnerID: "42", AssetID: 7, Balance: balance}}}) + + result, err := service.Assets(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(result.TopHolders) != 1 || result.TopHolders[0].Balance.String() != "125.5" { + t.Fatalf("unexpected top holders: %+v", result.TopHolders) + } +} + +func TestTransactionsPaginatesWithLookahead(t *testing.T) { + journals := make([]ledger.Journal, transactionPageSize+1) + for index := range journals { + journals[index].ID = fmt.Sprintf("journal-%d", index) + } + var repositoryFilter ledger.JournalFilter + service := NewService(repositoryStub{journals: journals, lastFilter: &repositoryFilter}) + + result, err := service.Transactions(context.Background(), TransactionFilter{Page: 2, Wallet: " wallet-42 ", EffectKind: " transfer "}) + if err != nil { + t.Fatal(err) + } + if result.Filter.Page != 2 || result.Filter.Wallet != "wallet-42" || result.Filter.EffectKind != "transfer" || !result.HasPrevious || !result.HasNext || len(result.Journals) != transactionPageSize { + t.Fatalf("unexpected transaction page: %+v", result) + } + if repositoryFilter.OwnerID == nil || *repositoryFilter.OwnerID != "wallet-42" || repositoryFilter.EffectKind == nil || *repositoryFilter.EffectKind != "transfer" || repositoryFilter.Offset != transactionPageSize { + t.Fatalf("unexpected repository filter: %+v", repositoryFilter) + } +} + +func TestHolderCombinesAvailableAndFrozenBalances(t *testing.T) { + balance, err := ledger.ParseAmount("10.25") + if err != nil { + t.Fatal(err) + } + service := NewService(repositoryStub{balance: balance, journals: []ledger.Journal{{ID: "journal-1"}}}) + + result, err := service.Holder(context.Background(), HolderReference{OwnerType: "user", OwnerID: "42", AssetID: 7}) + if err != nil { + t.Fatal(err) + } + if result.Balance.String() != "20.5" || len(result.Journals) != 1 { + t.Fatalf("unexpected aggregate holder account: %+v", result) + } +} + +func TestTransactionRequiresAResult(t *testing.T) { + service := NewService(repositoryStub{}) + if _, err := service.Transaction(context.Background(), "hash"); !IsNotFound(err) { + t.Fatalf("expected not found, got %v", err) + } +} + +func TestTransactionFallsBackToInternalJournalID(t *testing.T) { + const journalID = "5c1e31b0-0000-4000-8000-0000084accc8" + service := NewService(repositoryStub{journal: ledger.Journal{ID: journalID}}) + + result, err := service.Transaction(context.Background(), journalID) + if err != nil { + t.Fatal(err) + } + if len(result) != 1 || result[0].ID != journalID { + t.Fatalf("unexpected internal transaction result: %+v", result) + } +} + +func TestAccountReturnsBalanceAndHistory(t *testing.T) { + balance, err := ledger.ParseAmount("12.5") + if err != nil { + t.Fatal(err) + } + reference := ledger.AccountReference{ + Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "42", AssetID: 7, + } + service := NewService(repositoryStub{balance: balance, journals: []ledger.Journal{{ID: "journal-1"}}}) + result, err := service.Account(context.Background(), reference) + if err != nil { + t.Fatal(err) + } + if result.Balance.String() != "12.5" || len(result.Journals) != 1 { + t.Fatalf("unexpected account result: %+v", result) + } +} diff --git a/application/health/service.go b/application/health/service.go new file mode 100644 index 0000000..ff710b8 --- /dev/null +++ b/application/health/service.go @@ -0,0 +1,56 @@ +// Package health provides the service readiness use case. +package health + +import ( + "context" + + "gl/domain/ledger" +) + +type Database interface { + Ping(context.Context) error +} + +type SettlementMonitor interface { + Stats(context.Context) (ledger.SettlementStats, error) +} + +type Status struct { + Serving bool + DatabaseReady bool + SettlementEnabled bool + SettlementReady bool + SettlementStats ledger.SettlementStats +} + +type Service struct { + database Database + settlement SettlementMonitor + settlementEnabled bool +} + +func NewService(database Database, settlement ...SettlementMonitor) *Service { + service := &Service{database: database} + if len(settlement) > 0 && settlement[0] != nil { + service.settlement = settlement[0] + service.settlementEnabled = true + } + return service +} + +func (s *Service) Check(ctx context.Context) Status { + status := Status{} + if s.database != nil { + status.DatabaseReady = s.database.Ping(ctx) == nil + } + status.Serving = status.DatabaseReady + status.SettlementEnabled = s.settlementEnabled + if status.DatabaseReady && s.settlementEnabled { + stats, err := s.settlement.Stats(ctx) + if err == nil { + status.SettlementStats = stats + status.SettlementReady = true + } + } + return status +} diff --git a/application/health/service_test.go b/application/health/service_test.go new file mode 100644 index 0000000..ff0d3e1 --- /dev/null +++ b/application/health/service_test.go @@ -0,0 +1,63 @@ +package health + +import ( + "context" + "errors" + "testing" + + "gl/domain/ledger" +) + +type databaseStub struct{ err error } + +func (s databaseStub) Ping(context.Context) error { return s.err } + +type settlementStub struct { + stats ledger.SettlementStats + err error +} + +func (s settlementStub) Stats(context.Context) (ledger.SettlementStats, error) { + return s.stats, s.err +} + +func TestCheckReportsDatabaseReadiness(t *testing.T) { + for _, tc := range []struct { + name string + db Database + ready bool + }{ + {name: "not connected", db: nil, ready: false}, + {name: "ready", db: databaseStub{}, ready: true}, + {name: "unavailable", db: databaseStub{err: errors.New("down")}, ready: false}, + } { + t.Run(tc.name, func(t *testing.T) { + got := NewService(tc.db).Check(context.Background()) + if got.Serving != tc.ready || got.DatabaseReady != tc.ready { + t.Fatalf("unexpected status: %+v", got) + } + }) + } +} + +func TestCheckReportsSettlementBacklog(t *testing.T) { + monitor := settlementStub{stats: ledger.SettlementStats{Pending: 2, Retryable: 3, OldestPendingSeconds: 60}} + got := NewService(databaseStub{}, monitor).Check(context.Background()) + if !got.Serving || !got.DatabaseReady || !got.SettlementEnabled || !got.SettlementReady || got.SettlementStats != monitor.stats { + t.Fatalf("unexpected status: %+v", got) + } +} + +func TestCheckExposesManualReviewWithoutStoppingSettlementQueue(t *testing.T) { + got := NewService(databaseStub{}, settlementStub{stats: ledger.SettlementStats{ManualReview: 1}}).Check(context.Background()) + if !got.Serving || !got.SettlementReady || got.SettlementStats.ManualReview != 1 { + t.Fatalf("unexpected status: %+v", got) + } +} + +func TestCheckReportsSettlementQueryFailure(t *testing.T) { + got := NewService(databaseStub{}, settlementStub{err: errors.New("query failed")}).Check(context.Background()) + if !got.Serving || got.SettlementReady { + t.Fatalf("unexpected status: %+v", got) + } +} diff --git a/application/ledger/service.go b/application/ledger/service.go new file mode 100644 index 0000000..22fd19e --- /dev/null +++ b/application/ledger/service.go @@ -0,0 +1,384 @@ +// Package ledger implements GL's transport-independent use cases. +package ledger + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "time" + + domain "gl/domain/ledger" +) + +var ErrInvalidArgument = errors.New("invalid ledger request") + +type Repository interface { + Append(context.Context, domain.Journal) (domain.AppendResult, error) + AppendEvent(context.Context, domain.TransactionEvent) (domain.TransactionEvent, bool, error) + GetByID(context.Context, string) (domain.Journal, error) + GetByIdempotencyKey(context.Context, string) (domain.Journal, error) + List(context.Context, domain.JournalFilter) ([]domain.Journal, error) + Balance(context.Context, domain.AccountReference, time.Time) (domain.Amount, error) +} + +type IDGenerator func() (string, error) + +type Service struct { + repository Repository + newID IDGenerator +} + +func NewService(repository Repository, newID IDGenerator) *Service { + if newID == nil { + newID = newUUID + } + return &Service{repository: repository, newID: newID} +} + +type EntryCommand struct { + LineNumber uint32 + Account domain.AccountReference + Amount string + Description string +} + +type AppendJournalCommand struct { + SourceService string + IdempotencyKey string + SourceTransactionID string + TrackingCode string + EffectKind string + EventVersion uint32 + Entries []EntryCommand + ReversalOfJournalID string + OccurredAt time.Time + CorrelationID string + ActorID string + Blockchain domain.BlockchainReference + Metadata map[string]string +} + +type AppendJournalResult struct { + Journal domain.Journal + AlreadyExisted bool +} + +func (s *Service) AppendJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) { + journalID, err := s.newID() + if err != nil { + return AppendJournalResult{}, fmt.Errorf("generate journal id: %w", err) + } + journal := domain.Journal{ + ID: journalID, + SourceService: command.SourceService, + IdempotencyKey: command.IdempotencyKey, + SourceTransactionID: command.SourceTransactionID, + TrackingCode: command.TrackingCode, + EffectKind: command.EffectKind, + EventVersion: command.EventVersion, + ReversalOfJournalID: command.ReversalOfJournalID, + OccurredAt: command.OccurredAt, + CorrelationID: command.CorrelationID, + ActorID: command.ActorID, + Blockchain: command.Blockchain, + Metadata: cloneMetadata(command.Metadata), + Entries: make([]domain.Entry, 0, len(command.Entries)), + } + for _, input := range command.Entries { + amount, parseErr := domain.ParseAmount(input.Amount) + if parseErr != nil { + return AppendJournalResult{}, invalid("entry %d amount: %v", input.LineNumber, parseErr) + } + journal.Entries = append(journal.Entries, domain.Entry{ + LineNumber: input.LineNumber, + Account: input.Account, + Amount: amount, + Description: input.Description, + }) + } + sort.Slice(journal.Entries, func(i, j int) bool { + return journal.Entries[i].LineNumber < journal.Entries[j].LineNumber + }) + + journal.PayloadHash, err = journalHash(journal) + if err != nil { + return AppendJournalResult{}, fmt.Errorf("hash journal: %w", err) + } + if err := journal.Validate(); err != nil { + return AppendJournalResult{}, invalid("%v", err) + } + if journal.ReversalOfJournalID != "" { + original, getErr := s.repository.GetByID(ctx, journal.ReversalOfJournalID) + if getErr != nil { + return AppendJournalResult{}, fmt.Errorf("read reversal target: %w", getErr) + } + if err := validateFullReversal(original, journal); err != nil { + return AppendJournalResult{}, invalid("%v", err) + } + } + + appendResult, err := s.repository.Append(ctx, journal) + if err != nil { + return AppendJournalResult{}, err + } + stored, err := s.repository.GetByID(ctx, appendResult.JournalID) + if err != nil { + return AppendJournalResult{}, fmt.Errorf("read appended journal: %w", err) + } + return AppendJournalResult{Journal: stored, AlreadyExisted: appendResult.AlreadyExists}, nil +} + +type AppendEventCommand struct { + SourceService string + IdempotencyKey string + SourceTransactionID string + TrackingCode string + EventVersion uint32 + State domain.TransactionState + ErrorCode string + ErrorMessage string + OccurredAt time.Time + CorrelationID string + ActorID string + Blockchain domain.BlockchainReference + Metadata map[string]string +} + +func (s *Service) AppendEvent(ctx context.Context, command AppendEventCommand) (domain.TransactionEvent, bool, error) { + eventID, err := s.newID() + if err != nil { + return domain.TransactionEvent{}, false, fmt.Errorf("generate event id: %w", err) + } + event := domain.TransactionEvent{ + ID: eventID, + SourceService: command.SourceService, + IdempotencyKey: command.IdempotencyKey, + SourceTransactionID: command.SourceTransactionID, + TrackingCode: command.TrackingCode, + EventVersion: command.EventVersion, + State: command.State, + ErrorCode: command.ErrorCode, + ErrorMessage: command.ErrorMessage, + OccurredAt: command.OccurredAt, + CorrelationID: command.CorrelationID, + ActorID: command.ActorID, + Blockchain: command.Blockchain, + Metadata: cloneMetadata(command.Metadata), + } + event.PayloadHash, err = eventHash(event) + if err != nil { + return domain.TransactionEvent{}, false, fmt.Errorf("hash transaction event: %w", err) + } + if err := event.Validate(); err != nil { + return domain.TransactionEvent{}, false, invalid("%v", err) + } + return s.repository.AppendEvent(ctx, event) +} + +func (s *Service) GetJournal(ctx context.Context, journalID, idempotencyKey string) (domain.Journal, error) { + if (journalID == "") == (idempotencyKey == "") { + return domain.Journal{}, invalid("exactly one journal lookup is required") + } + if journalID != "" { + return s.repository.GetByID(ctx, journalID) + } + return s.repository.GetByIdempotencyKey(ctx, idempotencyKey) +} + +type ListCommand struct { + Account *domain.AccountReference + AssetID *int64 + RecordedFrom *time.Time + RecordedTo *time.Time + PageSize uint32 + PageToken string +} + +func (s *Service) List(ctx context.Context, command ListCommand) ([]domain.Journal, string, error) { + if command.Account != nil { + if err := command.Account.Validate(); err != nil { + return nil, "", invalid("account: %v", err) + } + } + if command.AssetID != nil && *command.AssetID <= 0 { + return nil, "", invalid("asset id must be positive") + } + pageSize := int(command.PageSize) + if pageSize == 0 { + pageSize = 50 + } + if pageSize > 200 { + return nil, "", invalid("page size must not exceed 200") + } + offset, err := decodePageToken(command.PageToken) + if err != nil { + return nil, "", invalid("page token: %v", err) + } + journals, err := s.repository.List(ctx, domain.JournalFilter{ + Account: command.Account, + AssetID: command.AssetID, + RecordedFrom: command.RecordedFrom, + RecordedTo: command.RecordedTo, + Limit: pageSize + 1, + Offset: offset, + }) + if err != nil { + return nil, "", err + } + next := "" + if len(journals) > pageSize { + journals = journals[:pageSize] + next = encodePageToken(offset + pageSize) + } + return journals, next, nil +} + +func (s *Service) Balance(ctx context.Context, account domain.AccountReference, asOf time.Time) (domain.Amount, error) { + if err := account.Validate(); err != nil { + return domain.Amount{}, invalid("account: %v", err) + } + return s.repository.Balance(ctx, account, asOf) +} + +func (s *Service) Replay(ctx context.Context, commands []AppendJournalCommand) ([]AppendJournalResult, error) { + if len(commands) == 0 || len(commands) > 200 { + return nil, invalid("replay batch must contain between 1 and 200 journals") + } + results := make([]AppendJournalResult, 0, len(commands)) + for _, command := range commands { + result, err := s.AppendJournal(ctx, command) + if err != nil { + return nil, err + } + results = append(results, result) + } + return results, nil +} + +func validateFullReversal(original, reversal domain.Journal) error { + if len(original.Entries) != len(reversal.Entries) { + return fmt.Errorf("reversal must contain every original entry") + } + expected := make(map[string]int, len(original.Entries)) + for _, entry := range original.Entries { + expected[entryKey(entry.Account, entry.Amount.Negate())]++ + } + for _, entry := range reversal.Entries { + key := entryKey(entry.Account, entry.Amount) + if expected[key] == 0 { + return fmt.Errorf("reversal entries must exactly negate the original journal") + } + expected[key]-- + } + return nil +} + +func entryKey(account domain.AccountReference, amount domain.Amount) string { + return fmt.Sprintf("%s\x00%s\x00%s\x00%d\x00%s", account.Class, account.OwnerType, account.OwnerID, account.AssetID, amount.String()) +} + +func journalHash(journal domain.Journal) (string, error) { + entries := make([]map[string]any, 0, len(journal.Entries)) + for _, entry := range journal.Entries { + entries = append(entries, map[string]any{ + "line_number": entry.LineNumber, + "class": entry.Account.Class, + "owner_type": entry.Account.OwnerType, + "owner_id": entry.Account.OwnerID, + "asset_id": entry.Account.AssetID, + "amount": entry.Amount.String(), + "description": entry.Description, + }) + } + return hashPayload(map[string]any{ + "source_service": journal.SourceService, + "idempotency_key": journal.IdempotencyKey, + "source_transaction_id": journal.SourceTransactionID, + "tracking_code": journal.TrackingCode, + "effect_kind": journal.EffectKind, + "event_version": journal.EventVersion, + "entries": entries, + "reversal_of": journal.ReversalOfJournalID, + "occurred_at": journal.OccurredAt.UTC().Format(time.RFC3339Nano), + "correlation_id": journal.CorrelationID, + "actor_id": journal.ActorID, + "blockchain": journal.Blockchain, + "metadata": journal.Metadata, + }) +} + +func eventHash(event domain.TransactionEvent) (string, error) { + return hashPayload(map[string]any{ + "source_service": event.SourceService, + "idempotency_key": event.IdempotencyKey, + "source_transaction_id": event.SourceTransactionID, + "tracking_code": event.TrackingCode, + "event_version": event.EventVersion, + "state": event.State, + "error_code": event.ErrorCode, + "error_message": event.ErrorMessage, + "occurred_at": event.OccurredAt.UTC().Format(time.RFC3339Nano), + "correlation_id": event.CorrelationID, + "actor_id": event.ActorID, + "blockchain": event.Blockchain, + "metadata": event.Metadata, + }) +} + +func hashPayload(payload any) (string, error) { + encoded, err := json.Marshal(payload) + if err != nil { + return "", err + } + hash := sha256.Sum256(encoded) + return hex.EncodeToString(hash[:]), nil +} + +func newUUID() (string, error) { + value := make([]byte, 16) + if _, err := rand.Read(value); err != nil { + return "", err + } + value[6] = (value[6] & 0x0f) | 0x40 + value[8] = (value[8] & 0x3f) | 0x80 + encoded := hex.EncodeToString(value) + return encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:32], nil +} + +func cloneMetadata(metadata map[string]string) map[string]string { + clone := make(map[string]string, len(metadata)) + for key, value := range metadata { + clone[key] = value + } + return clone +} + +func invalid(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrInvalidArgument, fmt.Sprintf(format, args...)) +} + +func encodePageToken(offset int) string { + return base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(offset))) +} + +func decodePageToken(token string) (int, error) { + if token == "" { + return 0, nil + } + decoded, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return 0, err + } + offset, err := strconv.Atoi(string(decoded)) + if err != nil || offset < 0 { + return 0, fmt.Errorf("invalid offset") + } + return offset, nil +} diff --git a/application/ledger/service_test.go b/application/ledger/service_test.go new file mode 100644 index 0000000..91a6af4 --- /dev/null +++ b/application/ledger/service_test.go @@ -0,0 +1,223 @@ +package ledger + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + domain "gl/domain/ledger" +) + +type repositoryStub struct { + journals map[string]domain.Journal + byKey map[string]string + events map[string]domain.TransactionEvent + appendCalls int + list []domain.Journal + balance domain.Amount + lastAttempt domain.Journal +} + +func newRepositoryStub() *repositoryStub { + return &repositoryStub{ + journals: make(map[string]domain.Journal), + byKey: make(map[string]string), + events: make(map[string]domain.TransactionEvent), + } +} + +func (r *repositoryStub) Append(_ context.Context, journal domain.Journal) (domain.AppendResult, error) { + r.appendCalls++ + r.lastAttempt = journal + if existingID, ok := r.byKey[journal.IdempotencyKey]; ok { + return domain.AppendResult{JournalID: existingID, AlreadyExists: true}, nil + } + journal.RecordedAt = time.Unix(2, 0).UTC() + r.journals[journal.ID] = journal + r.byKey[journal.IdempotencyKey] = journal.ID + return domain.AppendResult{JournalID: journal.ID}, nil +} + +func (r *repositoryStub) AppendEvent(_ context.Context, event domain.TransactionEvent) (domain.TransactionEvent, bool, error) { + if existing, ok := r.events[event.IdempotencyKey]; ok { + return existing, true, nil + } + event.RecordedAt = time.Unix(2, 0).UTC() + r.events[event.IdempotencyKey] = event + return event, false, nil +} + +func (r *repositoryStub) GetByID(_ context.Context, id string) (domain.Journal, error) { + journal, ok := r.journals[id] + if !ok { + return domain.Journal{}, errors.New("not found") + } + return journal, nil +} + +func (r *repositoryStub) GetByIdempotencyKey(_ context.Context, key string) (domain.Journal, error) { + id, ok := r.byKey[key] + if !ok { + return domain.Journal{}, errors.New("not found") + } + return r.journals[id], nil +} + +func (r *repositoryStub) List(context.Context, domain.JournalFilter) ([]domain.Journal, error) { + return append([]domain.Journal(nil), r.list...), nil +} + +func (r *repositoryStub) Balance(context.Context, domain.AccountReference, time.Time) (domain.Amount, error) { + return r.balance, nil +} + +func TestAppendJournalIsBalancedCanonicalAndIdempotent(t *testing.T) { + repository := newRepositoryStub() + service := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111")) + command := validAppendCommand() + + first, err := service.AppendJournal(context.Background(), command) + if err != nil { + t.Fatal(err) + } + command.Entries[0], command.Entries[1] = command.Entries[1], command.Entries[0] + second, err := service.AppendJournal(context.Background(), command) + if err != nil { + t.Fatal(err) + } + if first.AlreadyExisted || !second.AlreadyExisted { + t.Fatalf("unexpected idempotency results: first=%+v second=%+v", first, second) + } + if first.Journal.PayloadHash == "" || first.Journal.PayloadHash != second.Journal.PayloadHash { + t.Fatalf("unexpected payload hashes: %q %q", first.Journal.PayloadHash, second.Journal.PayloadHash) + } + if repository.lastAttempt.PayloadHash != first.Journal.PayloadHash { + t.Fatal("entry order changed the canonical payload hash") + } + if got := first.Journal.Entries[0].Amount.String(); got != "-10.25" { + t.Fatalf("amount was not canonical: %s", got) + } +} + +func TestAppendJournalRejectsInvalidAmountBeforeRepository(t *testing.T) { + repository := newRepositoryStub() + service := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111")) + command := validAppendCommand() + command.Entries[0].Amount = "NaN" + + _, err := service.AppendJournal(context.Background(), command) + if !errors.Is(err, ErrInvalidArgument) { + t.Fatalf("expected invalid argument, got %v", err) + } + if repository.appendCalls != 0 { + t.Fatal("invalid journal reached repository") + } +} + +func TestAppendJournalRequiresExactFullReversal(t *testing.T) { + repository := newRepositoryStub() + service := NewService(repository, fixedID("22222222-2222-4222-8222-222222222222")) + originalCommand := validAppendCommand() + original, err := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111")).AppendJournal(context.Background(), originalCommand) + if err != nil { + t.Fatal(err) + } + + reversal := validAppendCommand() + reversal.IdempotencyKey = "wallet:1:internal-transfer-reversal:v1" + reversal.EffectKind = "internal-transfer-reversal" + reversal.ReversalOfJournalID = original.Journal.ID + reversal.Entries[0].Amount = "10.25" + reversal.Entries[1].Amount = "-10.25" + if _, err := service.AppendJournal(context.Background(), reversal); err != nil { + t.Fatalf("valid reversal failed: %v", err) + } + + reversal.IdempotencyKey = "wallet:1:bad-reversal:v1" + reversal.Entries[0].Account.OwnerID = "different-user" + if _, err := service.AppendJournal(context.Background(), reversal); !errors.Is(err, ErrInvalidArgument) { + t.Fatalf("expected invalid reversal, got %v", err) + } +} + +func TestAppendEventIsIdempotent(t *testing.T) { + repository := newRepositoryStub() + service := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111")) + command := AppendEventCommand{ + SourceService: "wallet", + IdempotencyKey: "wallet:1:status:v1", + SourceTransactionID: "1", + EventVersion: 1, + State: domain.TransactionStateCreated, + OccurredAt: time.Unix(1, 0).UTC(), + } + + first, existed, err := service.AppendEvent(context.Background(), command) + if err != nil || existed { + t.Fatalf("first append: existed=%v err=%v", existed, err) + } + second, existed, err := service.AppendEvent(context.Background(), command) + if err != nil || !existed || first.PayloadHash != second.PayloadHash { + t.Fatalf("second append: existed=%v err=%v first=%+v second=%+v", existed, err, first, second) + } +} + +func TestListUsesOpaquePageToken(t *testing.T) { + repository := newRepositoryStub() + repository.list = make([]domain.Journal, 3) + service := NewService(repository, nil) + + journals, next, err := service.List(context.Background(), ListCommand{PageSize: 2}) + if err != nil { + t.Fatal(err) + } + if len(journals) != 2 || next == "" { + t.Fatalf("unexpected page: len=%d next=%q", len(journals), next) + } + if _, err := decodePageToken(next); err != nil { + t.Fatalf("invalid generated page token: %v", err) + } +} + +func TestNewUUIDProducesRFC4122Shape(t *testing.T) { + id, err := newUUID() + if err != nil { + t.Fatal(err) + } + if len(id) != 36 || id[14] != '4' || !strings.Contains("89ab", string(id[19])) { + t.Fatalf("unexpected UUID: %q", id) + } +} + +func validAppendCommand() AppendJournalCommand { + return AppendJournalCommand{ + SourceService: "wallet", + IdempotencyKey: "wallet:1:internal-transfer:v1", + SourceTransactionID: "1", + EffectKind: "internal-transfer", + EventVersion: 1, + OccurredAt: time.Unix(1, 0).UTC(), + Entries: []EntryCommand{ + { + LineNumber: 1, + Account: domain.AccountReference{ + Class: domain.AccountClassUserAvailable, OwnerType: "user", OwnerID: "1", AssetID: 5, + }, + Amount: "-10.2500", + }, + { + LineNumber: 2, + Account: domain.AccountReference{ + Class: domain.AccountClassUserAvailable, OwnerType: "user", OwnerID: "2", AssetID: 5, + }, + Amount: "10.25", + }, + }, + } +} + +func fixedID(id string) IDGenerator { + return func() (string, error) { return id, nil } +} diff --git a/application/reconciliation/evidence.go b/application/reconciliation/evidence.go new file mode 100644 index 0000000..4192259 --- /dev/null +++ b/application/reconciliation/evidence.go @@ -0,0 +1,85 @@ +package reconciliation + +import ( + "context" + "fmt" + + "gl/domain/ledger" +) + +// Evidence is a normalized record supplied by Wallet, Kuknos, or another +// authoritative settlement source. Adapters remain outside the GL domain. +type Evidence struct { + SourceService string + SourceTransactionID string + EventVersion uint32 + BlockchainNetwork string + TransactionHash string +} + +type EvidenceReport struct { + Compared int + Missing []Evidence + Duplicates []Evidence + Mismatched []EvidenceMismatch +} + +type EvidenceMismatch struct { + Evidence Evidence + JournalID string + Field string + Expected string + Actual string +} + +// CompareEvidence compares normalized external evidence with immutable GL +// journals. It is read-only and safe to run repeatedly after outages. +func CompareEvidence(ctx context.Context, repository Repository, evidence []Evidence, pageSize int) (EvidenceReport, error) { + if repository == nil { + return EvidenceReport{}, fmt.Errorf("reconciliation repository is required") + } + if pageSize <= 0 || pageSize > 200 { + pageSize = 100 + } + journals := make(map[string]ledger.Journal) + for offset := 0; ; offset += pageSize { + page, err := repository.List(ctx, ledger.JournalFilter{Limit: pageSize, Offset: offset}) + if err != nil { + return EvidenceReport{}, fmt.Errorf("list journals at offset %d: %w", offset, err) + } + for _, journal := range page { + journals[evidenceKey(journal.SourceService, journal.SourceTransactionID, journal.EventVersion)] = journal + } + if len(page) < pageSize { + break + } + } + + report := EvidenceReport{Missing: make([]Evidence, 0), Duplicates: make([]Evidence, 0), Mismatched: make([]EvidenceMismatch, 0)} + seen := make(map[string]struct{}, len(evidence)) + for _, item := range evidence { + report.Compared++ + key := evidenceKey(item.SourceService, item.SourceTransactionID, item.EventVersion) + if _, exists := seen[key]; exists { + report.Duplicates = append(report.Duplicates, item) + continue + } + seen[key] = struct{}{} + journal, exists := journals[key] + if !exists { + report.Missing = append(report.Missing, item) + continue + } + if item.BlockchainNetwork != "" && item.BlockchainNetwork != journal.Blockchain.Network { + report.Mismatched = append(report.Mismatched, EvidenceMismatch{Evidence: item, JournalID: journal.ID, Field: "blockchain_network", Expected: item.BlockchainNetwork, Actual: journal.Blockchain.Network}) + } + if item.TransactionHash != "" && item.TransactionHash != journal.Blockchain.TransactionHash { + report.Mismatched = append(report.Mismatched, EvidenceMismatch{Evidence: item, JournalID: journal.ID, Field: "transaction_hash", Expected: item.TransactionHash, Actual: journal.Blockchain.TransactionHash}) + } + } + return report, nil +} + +func evidenceKey(sourceService, sourceTransactionID string, eventVersion uint32) string { + return sourceService + "\x00" + sourceTransactionID + "\x00" + fmt.Sprint(eventVersion) +} diff --git a/application/reconciliation/evidence_test.go b/application/reconciliation/evidence_test.go new file mode 100644 index 0000000..6884870 --- /dev/null +++ b/application/reconciliation/evidence_test.go @@ -0,0 +1,39 @@ +package reconciliation + +import ( + "context" + "testing" + + "gl/domain/ledger" +) + +func TestCompareEvidenceReportsMissingDuplicateAndBlockchainMismatch(t *testing.T) { + journal := validJournal() + journal.Blockchain = ledger.BlockchainReference{Network: "kuknos", TransactionHash: "hash-1"} + matching := Evidence{SourceService: "wallet", SourceTransactionID: "t1", EventVersion: 1, BlockchainNetwork: "kuknos", TransactionHash: "hash-1"} + evidence := []Evidence{ + matching, + matching, + {SourceService: "wallet", SourceTransactionID: "missing", EventVersion: 1}, + {SourceService: "wallet", SourceTransactionID: "t1", EventVersion: 1, BlockchainNetwork: "kuknos", TransactionHash: "wrong"}, + } + report, err := CompareEvidence(context.Background(), repositoryStub{pages: [][]ledger.Journal{{journal}}}, evidence, 10) + if err != nil { + t.Fatal(err) + } + if report.Compared != 4 || len(report.Missing) != 1 || len(report.Duplicates) != 2 || len(report.Mismatched) != 0 { + t.Fatalf("unexpected report: %+v", report) + } +} + +func TestCompareEvidenceReportsHashMismatch(t *testing.T) { + journal := validJournal() + journal.Blockchain.TransactionHash = "actual" + report, err := CompareEvidence(context.Background(), repositoryStub{pages: [][]ledger.Journal{{journal}}}, []Evidence{{SourceService: "wallet", SourceTransactionID: "t1", EventVersion: 1, TransactionHash: "expected"}}, 10) + if err != nil { + t.Fatal(err) + } + if len(report.Mismatched) != 1 || report.Mismatched[0].Field != "transaction_hash" { + t.Fatalf("unexpected report: %+v", report) + } +} diff --git a/application/reconciliation/service.go b/application/reconciliation/service.go new file mode 100644 index 0000000..7b635de --- /dev/null +++ b/application/reconciliation/service.go @@ -0,0 +1,60 @@ +// Package reconciliation provides read-only integrity checks for the GL. +package reconciliation + +import ( + "context" + "fmt" + + "gl/domain/ledger" +) + +type Repository interface { + List(context.Context, ledger.JournalFilter) ([]ledger.Journal, error) +} + +type Report struct { + Scanned int + Valid int + Invalid []Issue +} + +type Issue struct { + JournalID string + Error string +} + +// Run scans all posted journals without mutating the ledger. +func Run(ctx context.Context, repository Repository, pageSize int) (Report, error) { + if repository == nil { + return Report{}, fmt.Errorf("reconciliation repository is required") + } + if pageSize <= 0 || pageSize > 200 { + pageSize = 100 + } + report := Report{Invalid: make([]Issue, 0)} + seen := make(map[string]string) + for offset := 0; ; offset += pageSize { + journals, err := repository.List(ctx, ledger.JournalFilter{Limit: pageSize, Offset: offset}) + if err != nil { + return Report{}, fmt.Errorf("list journals at offset %d: %w", offset, err) + } + for _, journal := range journals { + report.Scanned++ + if err := journal.Validate(); err != nil { + report.Invalid = append(report.Invalid, Issue{JournalID: journal.ID, Error: err.Error()}) + continue + } + key := evidenceKey(journal.SourceService, journal.SourceTransactionID, journal.EventVersion) + if previous, exists := seen[key]; exists { + report.Invalid = append(report.Invalid, Issue{JournalID: journal.ID, Error: fmt.Sprintf("duplicate source transaction/version; first journal %s", previous)}) + continue + } + seen[key] = journal.ID + report.Valid++ + } + if len(journals) < pageSize { + break + } + } + return report, nil +} diff --git a/application/reconciliation/service_test.go b/application/reconciliation/service_test.go new file mode 100644 index 0000000..1085ce4 --- /dev/null +++ b/application/reconciliation/service_test.go @@ -0,0 +1,56 @@ +package reconciliation + +import ( + "context" + "testing" + "time" + + "gl/domain/ledger" +) + +type repositoryStub struct { + pages [][]ledger.Journal +} + +func (r repositoryStub) List(_ context.Context, filter ledger.JournalFilter) ([]ledger.Journal, error) { + index := filter.Offset / filter.Limit + if index >= len(r.pages) { + return nil, nil + } + return r.pages[index], nil +} + +func validJournal() ledger.Journal { + return ledger.Journal{ID: "j1", SourceService: "wallet", IdempotencyKey: "k1", SourceTransactionID: "t1", EffectKind: "transfer", EventVersion: 1, OccurredAt: ledgerTestTime(), PayloadHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Entries: []ledger.Entry{{LineNumber: 1, Account: ledger.AccountReference{Class: ledger.AccountClassTreasury, AssetID: 1}, Amount: mustAmount("-1")}, {LineNumber: 2, Account: ledger.AccountReference{Class: ledger.AccountClassTreasury, AssetID: 1}, Amount: mustAmount("1")}}} +} + +func ledgerTestTime() (t time.Time) { return time.Unix(1, 0).UTC() } +func mustAmount(value string) ledger.Amount { amount, _ := ledger.ParseAmount(value); return amount } + +func TestRunScansAndReportsInvalidJournals(t *testing.T) { + valid := validJournal() + invalid := valid + invalid.Entries = append([]ledger.Entry(nil), valid.Entries...) + invalid.ID = "bad" + invalid.Entries[1].Amount = mustAmount("2") + report, err := Run(context.Background(), repositoryStub{pages: [][]ledger.Journal{{valid}, {invalid}}}, 1) + if err != nil { + t.Fatal(err) + } + if report.Scanned != 2 || report.Valid != 1 || len(report.Invalid) != 1 || report.Invalid[0].JournalID != "bad" { + t.Fatalf("unexpected report: %+v", report) + } +} + +func TestRunReportsDuplicateTransactionVersions(t *testing.T) { + first := validJournal() + second := validJournal() + second.ID = "j2" + report, err := Run(context.Background(), repositoryStub{pages: [][]ledger.Journal{{first, second}}}, 10) + if err != nil { + t.Fatal(err) + } + if report.Valid != 1 || len(report.Invalid) != 1 { + t.Fatalf("unexpected report: %+v", report) + } +} diff --git a/application/settlement/service.go b/application/settlement/service.go new file mode 100644 index 0000000..169b204 --- /dev/null +++ b/application/settlement/service.go @@ -0,0 +1,62 @@ +package settlement + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "gl/domain/ledger" +) + +type EnqueueRepository interface { + Enqueue(context.Context, ledger.Settlement) (ledger.Settlement, bool, error) + Get(context.Context, string, string) (ledger.Settlement, error) + RetryByOperator(context.Context, string, string, string, time.Time) (ledger.Settlement, error) +} + +func (s *Service) RetryByOperator(ctx context.Context, settlementID, actorID, reason string) (ledger.Settlement, error) { + if settlementID == "" || actorID == "" || reason == "" { + return ledger.Settlement{}, fmt.Errorf("settlement id, actor id, and reason are required") + } + now := time.Now().UTC() + if s.now != nil { + now = s.now().UTC() + } + return s.repository.RetryByOperator(ctx, settlementID, actorID, reason, now) +} + +type Service struct { + repository EnqueueRepository + now func() time.Time +} + +func NewService(repository EnqueueRepository) *Service { return &Service{repository: repository} } + +func (s *Service) Enqueue(ctx context.Context, sourceService, sourceTxID, idempotencyKey, network, xdr string) (ledger.Settlement, bool, error) { + if sourceService == "" || sourceTxID == "" || idempotencyKey == "" || network == "" || xdr == "" { + return ledger.Settlement{}, false, fmt.Errorf("settlement identity, network, and signed transaction are required") + } + id := make([]byte, 16) + if _, err := rand.Read(id); err != nil { + return ledger.Settlement{}, false, fmt.Errorf("generate settlement id: %w", err) + } + now := time.Now().UTC() + if s.now != nil { + now = s.now().UTC() + } + encoded := hex.EncodeToString(id) + record := ledger.Settlement{ID: encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:], SourceService: sourceService, SourceTxID: sourceTxID, IdempotencyKey: idempotencyKey, Status: ledger.SettlementPending, Network: network, SignedTransactionXDR: xdr, AvailableAt: now} + if err := record.Validate(); err != nil { + return ledger.Settlement{}, false, err + } + return s.repository.Enqueue(ctx, record) +} + +func (s *Service) Get(ctx context.Context, settlementID, idempotencyKey string) (ledger.Settlement, error) { + if settlementID == "" && idempotencyKey == "" { + return ledger.Settlement{}, fmt.Errorf("settlement id or idempotency key is required") + } + return s.repository.Get(ctx, settlementID, idempotencyKey) +} diff --git a/application/settlement/service_test.go b/application/settlement/service_test.go new file mode 100644 index 0000000..f9f5b9f --- /dev/null +++ b/application/settlement/service_test.go @@ -0,0 +1,43 @@ +package settlement + +import ( + "context" + "testing" + "time" + + "gl/domain/ledger" +) + +type serviceRepository struct { + retriedID, actor, reason string +} + +func (*serviceRepository) Enqueue(context.Context, ledger.Settlement) (ledger.Settlement, bool, error) { + return ledger.Settlement{}, false, nil +} +func (*serviceRepository) Get(context.Context, string, string) (ledger.Settlement, error) { + return ledger.Settlement{}, nil +} +func (r *serviceRepository) RetryByOperator(_ context.Context, id, actor, reason string, _ time.Time) (ledger.Settlement, error) { + r.retriedID, r.actor, r.reason = id, actor, reason + return ledger.Settlement{ID: id, Status: ledger.SettlementRetryable}, nil +} + +func TestRetryByOperatorRequiresCompleteAuditIdentity(t *testing.T) { + service := NewService(&serviceRepository{}) + if _, err := service.RetryByOperator(context.Background(), "id", "actor", ""); err == nil { + t.Fatal("expected missing reason to be rejected") + } +} + +func TestRetryByOperatorPassesActorAndReasonToRepository(t *testing.T) { + repository := &serviceRepository{} + service := NewService(repository) + got, err := service.RetryByOperator(context.Background(), "id", "actor-7", "Horizon outage resolved") + if err != nil { + t.Fatal(err) + } + if got.Status != ledger.SettlementRetryable || repository.retriedID != "id" || repository.actor != "actor-7" || repository.reason != "Horizon outage resolved" { + t.Fatalf("unexpected retry: %+v repo=%+v", got, repository) + } +} diff --git a/application/settlement/worker.go b/application/settlement/worker.go new file mode 100644 index 0000000..1a6c50d --- /dev/null +++ b/application/settlement/worker.go @@ -0,0 +1,57 @@ +package settlement + +import ( + "context" + "fmt" + "time" + + "gl/domain/ledger" +) + +type Repository interface { + ClaimDue(context.Context, string, int, time.Time) ([]ledger.Settlement, error) + Save(context.Context, ledger.Settlement, string) error +} + +type Submitter interface { + Submit(context.Context, ledger.Settlement) (string, error) +} + +type Worker struct { + Repository Repository + Submitter Submitter + WorkerID string + MaxAttempts int + Now func() time.Time +} + +func (w Worker) RunOnce(ctx context.Context, limit int) error { + if w.Repository == nil || w.Submitter == nil || w.WorkerID == "" || limit <= 0 { + return fmt.Errorf("settlement worker is not configured") + } + now := time.Now().UTC() + if w.Now != nil { + now = w.Now().UTC() + } + records, err := w.Repository.ClaimDue(ctx, w.WorkerID, limit, now) + if err != nil { + return err + } + for _, record := range records { + record.Attempts++ + hash, submitErr := w.Submitter.Submit(ctx, record) + if submitErr != nil { + record = record.Retry(now, w.MaxAttempts, submitErr) + } else { + // Horizon returns success only after the transaction is accepted into + // a ledger, so a successful response is already confirmation. + record.Status = ledger.SettlementConfirmed + record.TransactionHash = hash + record.LastError = "" + } + if err := w.Repository.Save(ctx, record, w.WorkerID); err != nil { + return err + } + } + return nil +} diff --git a/application/settlement/worker_test.go b/application/settlement/worker_test.go new file mode 100644 index 0000000..4b277c1 --- /dev/null +++ b/application/settlement/worker_test.go @@ -0,0 +1,60 @@ +package settlement + +import ( + "context" + "errors" + "testing" + "time" + + "gl/domain/ledger" +) + +type memoryRepository struct{ record ledger.Settlement } + +func (m *memoryRepository) ClaimDue(context.Context, string, int, time.Time) ([]ledger.Settlement, error) { + return []ledger.Settlement{m.record}, nil +} +func (m *memoryRepository) Save(_ context.Context, record ledger.Settlement, _ string) error { + m.record = record + return nil +} + +type submitter struct { + hash string + err error +} + +func (s submitter) Submit(context.Context, ledger.Settlement) (string, error) { return s.hash, s.err } + +func TestWorkerPersistsSubmission(t *testing.T) { + repository := &memoryRepository{record: ledger.Settlement{ID: "s1", SourceService: "wallet", SourceTxID: "42", IdempotencyKey: "kuknos:42", Status: ledger.SettlementPending, AvailableAt: time.Unix(1, 0)}} + worker := Worker{Repository: repository, Submitter: submitter{hash: "hash-42"}, WorkerID: "worker-1", Now: func() time.Time { return time.Unix(2, 0) }} + if err := worker.RunOnce(context.Background(), 1); err != nil { + t.Fatal(err) + } + if repository.record.Status != ledger.SettlementConfirmed || repository.record.TransactionHash != "hash-42" || repository.record.Attempts != 1 { + t.Fatalf("unexpected settlement: %+v", repository.record) + } +} + +func TestWorkerSchedulesRetryAfterFirstFailedAttempt(t *testing.T) { + repository := &memoryRepository{record: ledger.Settlement{ID: "s1", SourceService: "wallet", SourceTxID: "42", IdempotencyKey: "kuknos:42", Status: ledger.SettlementPending, AvailableAt: time.Unix(1, 0)}} + worker := Worker{Repository: repository, Submitter: submitter{err: errors.New("timeout")}, WorkerID: "worker-1", MaxAttempts: 3, Now: func() time.Time { return time.Unix(2, 0) }} + if err := worker.RunOnce(context.Background(), 1); err != nil { + t.Fatal(err) + } + if repository.record.Status != ledger.SettlementRetryable || repository.record.Attempts != 1 || !repository.record.AvailableAt.Equal(time.Unix(3, 0)) { + t.Fatalf("unexpected settlement: %+v", repository.record) + } +} + +func TestWorkerMovesExhaustedSubmissionToManualReview(t *testing.T) { + repository := &memoryRepository{record: ledger.Settlement{ID: "s1", SourceService: "wallet", SourceTxID: "42", IdempotencyKey: "kuknos:42", Status: ledger.SettlementRetryable, Attempts: 2, AvailableAt: time.Unix(1, 0)}} + worker := Worker{Repository: repository, Submitter: submitter{err: errors.New("timeout")}, WorkerID: "worker-1", MaxAttempts: 3, Now: func() time.Time { return time.Unix(2, 0) }} + if err := worker.RunOnce(context.Background(), 1); err != nil { + t.Fatal(err) + } + if repository.record.Status != ledger.SettlementManualReview { + t.Fatalf("status=%s", repository.record.Status) + } +} diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..a2081fa --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,16 @@ +version: v2 +clean: true +managed: + enabled: true + override: + - file_option: go_package_prefix + value: gl/gen +plugins: + - local: protoc-gen-go + out: gen + opt: paths=source_relative + - local: protoc-gen-go-grpc + out: gen + opt: + - paths=source_relative + - require_unimplemented_servers=false diff --git a/cmd/dashboard-loadtest/main.go b/cmd/dashboard-loadtest/main.go new file mode 100644 index 0000000..c6fd75c --- /dev/null +++ b/cmd/dashboard-loadtest/main.go @@ -0,0 +1,165 @@ +// Command dashboard-loadtest runs a concurrent read-only HTTP load test against +// a deployed GL dashboard. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/signal" + "sort" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" +) + +type result struct { + Duration time.Duration + Requests int64 + Errors int64 + Non2xx int64 + Latency []time.Duration +} + +func main() { + baseURL := flag.String("url", "http://127.0.0.1:8080", "dashboard base URL") + paths := flag.String("paths", "/", "comma-separated request paths") + concurrency := flag.Int("concurrency", 20, "number of concurrent workers") + duration := flag.Duration("duration", 15*time.Second, "test duration") + requestTimeout := flag.Duration("request-timeout", 5*time.Second, "timeout for each request") + flag.Parse() + + if *concurrency < 1 || *duration <= 0 || *requestTimeout <= 0 { + fmt.Fprintln(os.Stderr, "concurrency and durations must be positive") + os.Exit(2) + } + targets, err := targets(*baseURL, *paths) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = *concurrency + transport.MaxIdleConnsPerHost = *concurrency + client := &http.Client{Transport: transport, Timeout: *requestTimeout} + defer transport.CloseIdleConnections() + + signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + ctx, cancel := context.WithTimeout(signalCtx, *duration) + defer cancel() + + fmt.Printf("Dashboard load test: %d workers for %s against %s\n", *concurrency, *duration, *baseURL) + started := time.Now() + result := run(ctx, client, targets, *concurrency) + result.Duration = time.Since(started) + printResult(result) + if result.Errors > 0 || result.Non2xx > 0 { + os.Exit(1) + } +} + +func targets(baseURL, pathList string) ([]string, error) { + base, err := url.Parse(baseURL) + if err != nil || base.Scheme == "" || base.Host == "" { + return nil, fmt.Errorf("invalid dashboard URL %q", baseURL) + } + paths := strings.Split(pathList, ",") + result := make([]string, 0, len(paths)) + for _, value := range paths { + value = strings.TrimSpace(value) + if value == "" { + continue + } + reference, parseErr := url.Parse(value) + if parseErr != nil || reference.IsAbs() || !strings.HasPrefix(reference.Path, "/") { + return nil, fmt.Errorf("invalid request path %q", value) + } + result = append(result, base.ResolveReference(reference).String()) + } + if len(result) == 0 { + return nil, errors.New("at least one request path is required") + } + return result, nil +} + +func run(ctx context.Context, client *http.Client, targets []string, concurrency int) result { + var ( + requests int64 + failures int64 + non2xx int64 + sequence uint64 + latency = make([]time.Duration, 0, concurrency*100) + lock sync.Mutex + workers sync.WaitGroup + ) + workers.Add(concurrency) + for range concurrency { + go func() { + defer workers.Done() + for ctx.Err() == nil { + target := targets[(atomic.AddUint64(&sequence, 1)-1)%uint64(len(targets))] + request, err := http.NewRequestWithContext(context.WithoutCancel(ctx), http.MethodGet, target, nil) + if err != nil { + atomic.AddInt64(&failures, 1) + continue + } + started := time.Now() + response, err := client.Do(request) + elapsed := time.Since(started) + if err != nil { + atomic.AddInt64(&requests, 1) + atomic.AddInt64(&failures, 1) + continue + } + _, copyErr := io.Copy(io.Discard, io.LimitReader(response.Body, 2<<20)) + closeErr := response.Body.Close() + atomic.AddInt64(&requests, 1) + if response.StatusCode < 200 || response.StatusCode >= 300 { + atomic.AddInt64(&non2xx, 1) + } + if copyErr != nil || closeErr != nil { + atomic.AddInt64(&failures, 1) + } + lock.Lock() + latency = append(latency, elapsed) + lock.Unlock() + } + }() + } + workers.Wait() + return result{Requests: requests, Errors: failures, Non2xx: non2xx, Latency: latency} +} + +func printResult(value result) { + seconds := value.Duration.Seconds() + requestsPerSecond := float64(value.Requests) + if seconds > 0 { + requestsPerSecond /= seconds + } + fmt.Printf("requests: %d\n", value.Requests) + fmt.Printf("throughput: %.1f req/s\n", requestsPerSecond) + fmt.Printf("latency: p50=%s p95=%s p99=%s\n", percentile(value.Latency, 50), percentile(value.Latency, 95), percentile(value.Latency, 99)) + fmt.Printf("errors: %d transport, %d non-2xx\n", value.Errors, value.Non2xx) +} + +func percentile(values []time.Duration, percent int) time.Duration { + if len(values) == 0 { + return 0 + } + ordered := append([]time.Duration(nil), values...) + sort.Slice(ordered, func(left, right int) bool { return ordered[left] < ordered[right] }) + index := (len(ordered)*percent + 99) / 100 + if index < 1 { + index = 1 + } + return ordered[index-1] +} diff --git a/cmd/dashboard-loadtest/main_test.go b/cmd/dashboard-loadtest/main_test.go new file mode 100644 index 0000000..98bc1b5 --- /dev/null +++ b/cmd/dashboard-loadtest/main_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +func TestRunSendsConcurrentRequestsAcrossTargets(t *testing.T) { + server := newIPv4TestServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusOK) + _, _ = response.Write([]byte("ok")) + })) + defer server.Close() + + requestTargets, err := targets(server.URL, "/,/transactions") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + result := run(ctx, server.Client(), requestTargets, 4) + + if result.Requests == 0 || result.Errors != 0 || result.Non2xx != 0 { + t.Fatalf("unexpected load result: %+v", result) + } + if len(result.Latency) != int(result.Requests) { + t.Fatalf("expected one latency per request, got %d for %d", len(result.Latency), result.Requests) + } +} + +func TestTargetsRejectsExternalAndRelativePaths(t *testing.T) { + for _, paths := range []string{"relative", "https://example.com/"} { + if _, err := targets("http://localhost:8080", paths); err == nil { + t.Fatalf("expected %q to be rejected", paths) + } + } +} + +func TestPercentileUsesNearestRank(t *testing.T) { + values := []time.Duration{time.Millisecond, 4 * time.Millisecond, 2 * time.Millisecond, 3 * time.Millisecond} + if got := percentile(values, 95); got != 4*time.Millisecond { + t.Fatalf("unexpected p95: %s", got) + } +} + +func TestRunDrainsInflightRequestAfterDuration(t *testing.T) { + var canceled atomic.Bool + server := newIPv4TestServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + time.Sleep(20 * time.Millisecond) + if request.Context().Err() != nil { + canceled.Store(true) + } + response.WriteHeader(http.StatusOK) + })) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) + defer cancel() + result := run(ctx, server.Client(), []string{server.URL}, 1) + + if result.Requests != 1 || result.Errors != 0 || canceled.Load() { + t.Fatalf("in-flight request was not drained cleanly: %+v", result) + } +} + +func newIPv4TestServer(handler http.Handler) *httptest.Server { + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + panic(err) + } + server := httptest.NewUnstartedServer(handler) + server.Listener = listener + server.Start() + return server +} diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go new file mode 100644 index 0000000..59f7c7b --- /dev/null +++ b/cmd/dashboard/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + "syscall" + + "gl/application/explorer" + "gl/infrastructure/config" + "gl/infrastructure/observability" + "gl/infrastructure/postgres" + webadapter "gl/interface/web" +) + +func main() { + observability.Configure("gl-dashboard", slog.LevelInfo) + configPath := flag.String("conf", "./dashboard.cfg.toml", "path to the dashboard TOML configuration file") + flag.Parse() + + cfg, err := config.LoadDashboard(*configPath) + if err != nil { + slog.Error("load dashboard configuration", "error", err) + os.Exit(1) + } + shutdownTelemetry := observability.Setup(context.Background(), cfg.Telemetry, "gl-dashboard", cfg.Environment) + defer shutdownTelemetry(context.Background()) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + database, err := postgres.Open(ctx, cfg.Database) + if err != nil { + slog.Error("open GL database", "error", err) + os.Exit(1) + } + defer database.Close() + if err := database.Ping(ctx); err != nil { + slog.Error("connect to GL database", "error", err) + os.Exit(1) + } + + repository := postgres.NewJournalRepository(database) + handler := webadapter.NewHandler(explorer.NewService(repository)) + serverConfig := webadapter.ServerConfig{ + Host: cfg.HTTP.Host, + Port: cfg.HTTP.Port, + ReadHeaderTimeout: cfg.HTTP.ReadHeaderTimeout, + ShutdownTimeout: cfg.HTTP.ShutdownTimeout, + } + slog.Info("starting GL explorer", "host", cfg.HTTP.Host, "port", cfg.HTTP.Port) + if err := webadapter.Run(ctx, serverConfig, handler); err != nil { + slog.Error("GL explorer stopped", "error", err) + os.Exit(1) + } +} diff --git a/cmd/gl-loadtest/README.md b/cmd/gl-loadtest/README.md new file mode 100644 index 0000000..371befb --- /dev/null +++ b/cmd/gl-loadtest/README.md @@ -0,0 +1,22 @@ +# GL load test + +This command exercises the write and read paths of the gRPC ledger service. +Each simulated transaction: + +1. appends a `CREATED` lifecycle event; +2. posts a balanced transfer between two generated user wallet accounts; +3. reads the committed journal; +4. rebuilds the receiving wallet balance; +5. appends a `SUCCESSFUL` lifecycle event. + +Ledger accounts are created implicitly by `AppendJournal`; GL does not expose a +separate wallet-creation RPC. + +Start GL, then run: + +```bash +go run ./cmd/gl-loadtest -duration 30s -concurrency 20 -wallets 1000 +``` + +Use a fresh `-run-id` for every dataset. The command generates one automatically +when the flag is omitted. diff --git a/cmd/gl-loadtest/main.go b/cmd/gl-loadtest/main.go new file mode 100644 index 0000000..b495625 --- /dev/null +++ b/cmd/gl-loadtest/main.go @@ -0,0 +1,323 @@ +// Command gl-loadtest runs a stateful gRPC load test against the GL service. +// Wallet accounts are created implicitly by the first journal that references +// each generated owner ID, matching normal GL behavior. +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "flag" + "fmt" + "os" + "os/signal" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + domain "gl/domain/ledger" + basev1 "gl/gen/base/v1" + ledgerv1 "gl/gen/ledger/v1" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type client interface { + AppendJournal(context.Context, *ledgerv1.AppendJournalRequest, ...grpc.CallOption) (*ledgerv1.AppendJournalResponse, error) + AppendTransactionEvent(context.Context, *ledgerv1.AppendTransactionEventRequest, ...grpc.CallOption) (*ledgerv1.AppendTransactionEventResponse, error) + GetJournal(context.Context, *ledgerv1.GetJournalRequest, ...grpc.CallOption) (*ledgerv1.Journal, error) + GetBalance(context.Context, *ledgerv1.GetBalanceRequest, ...grpc.CallOption) (*ledgerv1.GetBalanceResponse, error) +} + +type config struct { + RunID string + Wallets int + AssetID int64 + Amount string + RequestTimeout time.Duration +} + +type sample struct { + Operation string + Latency time.Duration + Err error +} + +type operationStats struct { + Requests int64 + Errors int64 + Latency []time.Duration +} + +func main() { + target := flag.String("target", "127.0.0.1:8600", "GL gRPC target") + concurrency := flag.Int("concurrency", 20, "number of concurrent transaction workers") + duration := flag.Duration("duration", 30*time.Second, "load duration") + wallets := flag.Int("wallets", 1000, "number of simulated wallet accounts") + assetID := flag.Int64("asset-id", 1, "asset ID used by transfers") + amount := flag.String("amount", "1", "canonical decimal transfer amount") + requestTimeout := flag.Duration("request-timeout", 5*time.Second, "timeout for each gRPC call") + runID := flag.String("run-id", "", "idempotency namespace; generated when empty") + flag.Parse() + + if *concurrency < 1 || *duration <= 0 || *wallets < 2 || *assetID <= 0 || *requestTimeout <= 0 || *amount == "" { + fmt.Fprintln(os.Stderr, "concurrency and durations must be positive; wallets must be at least 2; asset-id and amount are required") + os.Exit(2) + } + parsedAmount, err := domain.ParseAmount(*amount) + if err != nil || parsedAmount.IsZero() || strings.HasPrefix(*amount, "-") { + fmt.Fprintf(os.Stderr, "amount must be a positive canonical ledger decimal: %v\n", err) + os.Exit(2) + } + if *runID == "" { + *runID = "gl-load-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 36) + } + + connection, err := grpc.NewClient(*target, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + fmt.Fprintf(os.Stderr, "create gRPC client: %v\n", err) + os.Exit(2) + } + defer connection.Close() + ledger := ledgerv1.NewGeneralLedgerServiceClient(connection) + healthCtx, cancelHealth := context.WithTimeout(context.Background(), *requestTimeout) + health, err := ledger.Health(healthCtx, &basev1.Empty{}) + cancelHealth() + if err != nil || !health.GetServing() || !health.GetDatabaseReady() { + fmt.Fprintf(os.Stderr, "GL is not ready at %s: response=%v error=%v\n", *target, health, err) + os.Exit(1) + } + + testConfig := config{ + RunID: *runID, + Wallets: *wallets, + AssetID: *assetID, + Amount: *amount, + RequestTimeout: *requestTimeout, + } + signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + ctx, cancel := context.WithTimeout(signalCtx, *duration) + defer cancel() + + fmt.Printf("GL load test: target=%s workers=%d duration=%s wallets=%d asset=%d run=%s\n", *target, *concurrency, *duration, *wallets, *assetID, *runID) + started := time.Now() + samples, transactions := run(ctx, ledger, testConfig, *concurrency) + elapsed := time.Since(started) + failed := printSummary(samples, transactions, elapsed) + if failed { + os.Exit(1) + } +} + +func run(ctx context.Context, ledger client, cfg config, concurrency int) ([]sample, uint64) { + var sequence atomic.Uint64 + results := make(chan []sample, concurrency) + var workers sync.WaitGroup + workers.Add(concurrency) + for range concurrency { + go func() { + defer workers.Done() + workerSamples := make([]sample, 0, 256) + for ctx.Err() == nil { + transaction := sequence.Add(1) + workerSamples = append(workerSamples, runTransaction(ctx, ledger, cfg, transaction)...) + } + results <- workerSamples + }() + } + workers.Wait() + close(results) + + all := make([]sample, 0) + for workerSamples := range results { + all = append(all, workerSamples...) + } + return all, sequence.Load() +} + +func runTransaction(ctx context.Context, ledger client, cfg config, sequence uint64) []sample { + transactionID := fmt.Sprintf("%s-%d", cfg.RunID, sequence) + senderID := fmt.Sprintf("load-wallet-%d", sequence%uint64(cfg.Wallets)) + receiverID := fmt.Sprintf("load-wallet-%d", (sequence+1)%uint64(cfg.Wallets)) + correlationID := "load-correlation-" + transactionID + blockchainHash := transactionHash(transactionID) + occurredAt := timestamppb.Now() + + created := &ledgerv1.AppendTransactionEventRequest{ + SourceService: "gl-loadtest", + IdempotencyKey: "load:" + transactionID + ":event:v1", + SourceTransactionId: transactionID, + TrackingCode: "load-" + strconv.FormatUint(sequence, 10), + EventVersion: 1, + State: ledgerv1.TransactionState_TRANSACTION_STATE_CREATED, + OccurredAt: occurredAt, + CorrelationId: correlationID, + ActorId: senderID, + Metadata: map[string]string{"load_test": cfg.RunID}, + } + samples := []sample{measure(ctx, cfg.RequestTimeout, "append_event", func(callCtx context.Context) error { + _, err := ledger.AppendTransactionEvent(callCtx, created) + return err + })} + + journalRequest := &ledgerv1.AppendJournalRequest{ + SourceService: "gl-loadtest", + IdempotencyKey: "load:" + transactionID + ":transfer:v1", + SourceTransactionId: transactionID, + TrackingCode: created.TrackingCode, + EffectKind: "internal-transfer", + EventVersion: 1, + OccurredAt: occurredAt, + CorrelationId: correlationID, + ActorId: senderID, + Blockchain: &ledgerv1.BlockchainReference{ + Network: "load-test", + TransactionHash: blockchainHash, + LedgerSequence: strconv.FormatUint(sequence, 10), + }, + Metadata: map[string]string{"load_test": cfg.RunID}, + Entries: []*ledgerv1.JournalEntry{ + { + LineNumber: 1, + Account: walletAccount(senderID, cfg.AssetID), + Amount: "-" + cfg.Amount, + Description: "load-test transfer debit", + }, + { + LineNumber: 2, + Account: walletAccount(receiverID, cfg.AssetID), + Amount: cfg.Amount, + Description: "load-test transfer credit", + }, + }, + } + var journalID string + samples = append(samples, measure(ctx, cfg.RequestTimeout, "append_journal", func(callCtx context.Context) error { + response, err := ledger.AppendJournal(callCtx, journalRequest) + if err == nil && response.GetJournal() != nil { + journalID = response.GetJournal().GetJournalId() + } + return err + })) + + if journalID != "" { + samples = append(samples, measure(ctx, cfg.RequestTimeout, "get_journal", func(callCtx context.Context) error { + _, err := ledger.GetJournal(callCtx, &ledgerv1.GetJournalRequest{ + Lookup: &ledgerv1.GetJournalRequest_JournalId{JournalId: journalID}, + }) + return err + })) + } + + samples = append(samples, measure(ctx, cfg.RequestTimeout, "get_balance", func(callCtx context.Context) error { + _, err := ledger.GetBalance(callCtx, &ledgerv1.GetBalanceRequest{Account: walletAccount(receiverID, cfg.AssetID)}) + return err + })) + + successful := &ledgerv1.AppendTransactionEventRequest{ + SourceService: "gl-loadtest", + IdempotencyKey: "load:" + transactionID + ":event:v2", + SourceTransactionId: transactionID, + TrackingCode: created.TrackingCode, + EventVersion: 2, + State: ledgerv1.TransactionState_TRANSACTION_STATE_SUCCESSFUL, + OccurredAt: timestamppb.Now(), + CorrelationId: correlationID, + ActorId: senderID, + Blockchain: journalRequest.Blockchain, + Metadata: map[string]string{"load_test": cfg.RunID}, + } + samples = append(samples, measure(ctx, cfg.RequestTimeout, "append_event", func(callCtx context.Context) error { + _, err := ledger.AppendTransactionEvent(callCtx, successful) + return err + })) + return samples +} + +func walletAccount(ownerID string, assetID int64) *ledgerv1.AccountReference { + return &ledgerv1.AccountReference{ + AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE, + OwnerType: "user", + OwnerId: ownerID, + AssetId: assetID, + } +} + +func transactionHash(transactionID string) string { + value := sha256.Sum256([]byte(transactionID)) + return hex.EncodeToString(value[:]) +} + +func measure(ctx context.Context, timeout time.Duration, operation string, call func(context.Context) error) sample { + callCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout) + defer cancel() + started := time.Now() + err := call(callCtx) + return sample{Operation: operation, Latency: time.Since(started), Err: err} +} + +func printSummary(samples []sample, transactions uint64, elapsed time.Duration) bool { + stats := make(map[string]*operationStats) + var failed bool + for _, value := range samples { + operation := stats[value.Operation] + if operation == nil { + operation = &operationStats{} + stats[value.Operation] = operation + } + operation.Requests++ + operation.Latency = append(operation.Latency, value.Latency) + if value.Err != nil { + operation.Errors++ + failed = true + } + } + + names := make([]string, 0, len(stats)) + for name := range stats { + names = append(names, name) + } + sort.Strings(names) + fmt.Printf("transactions: %d (%.1f tx/s)\n", transactions, rate(float64(transactions), elapsed)) + fmt.Printf("%-18s %10s %8s %10s %12s %12s %12s\n", "operation", "requests", "errors", "req/s", "p50", "p95", "p99") + for _, name := range names { + value := stats[name] + fmt.Printf("%-18s %10d %8d %10.1f %12s %12s %12s\n", + name, + value.Requests, + value.Errors, + rate(float64(value.Requests), elapsed), + percentile(value.Latency, 50), + percentile(value.Latency, 95), + percentile(value.Latency, 99), + ) + } + return failed +} + +func rate(count float64, elapsed time.Duration) float64 { + if elapsed <= 0 { + return 0 + } + return count / elapsed.Seconds() +} + +func percentile(values []time.Duration, percent int) time.Duration { + if len(values) == 0 { + return 0 + } + ordered := append([]time.Duration(nil), values...) + sort.Slice(ordered, func(left, right int) bool { return ordered[left] < ordered[right] }) + index := (len(ordered)*percent + 99) / 100 + if index < 1 { + index = 1 + } + return ordered[index-1] +} diff --git a/cmd/gl-loadtest/main_test.go b/cmd/gl-loadtest/main_test.go new file mode 100644 index 0000000..faca7f5 --- /dev/null +++ b/cmd/gl-loadtest/main_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "testing" + "time" + + ledgerv1 "gl/gen/ledger/v1" + + "google.golang.org/grpc" +) + +type clientStub struct { + journal *ledgerv1.AppendJournalRequest + events []*ledgerv1.AppendTransactionEventRequest + balance *ledgerv1.GetBalanceRequest + lookup *ledgerv1.GetJournalRequest +} + +func (s *clientStub) AppendJournal(_ context.Context, request *ledgerv1.AppendJournalRequest, _ ...grpc.CallOption) (*ledgerv1.AppendJournalResponse, error) { + s.journal = request + return &ledgerv1.AppendJournalResponse{Journal: &ledgerv1.Journal{JournalId: "journal-1"}}, nil +} + +func (s *clientStub) AppendTransactionEvent(_ context.Context, request *ledgerv1.AppendTransactionEventRequest, _ ...grpc.CallOption) (*ledgerv1.AppendTransactionEventResponse, error) { + s.events = append(s.events, request) + return &ledgerv1.AppendTransactionEventResponse{}, nil +} + +func (s *clientStub) GetJournal(_ context.Context, request *ledgerv1.GetJournalRequest, _ ...grpc.CallOption) (*ledgerv1.Journal, error) { + s.lookup = request + return &ledgerv1.Journal{JournalId: "journal-1"}, nil +} + +func (s *clientStub) GetBalance(_ context.Context, request *ledgerv1.GetBalanceRequest, _ ...grpc.CallOption) (*ledgerv1.GetBalanceResponse, error) { + s.balance = request + return &ledgerv1.GetBalanceResponse{Balance: "1"}, nil +} + +func TestRunTransactionCreatesWalletAccountsAndFullLifecycle(t *testing.T) { + ledger := &clientStub{} + samples := runTransaction(context.Background(), ledger, config{ + RunID: "test-run", Wallets: 10, AssetID: 7, Amount: "2.5", RequestTimeout: time.Second, + }, 3) + + if len(samples) != 5 { + t.Fatalf("expected five RPC measurements, got %d", len(samples)) + } + if ledger.journal == nil || len(ledger.journal.Entries) != 2 { + t.Fatal("balanced journal was not appended") + } + debit, credit := ledger.journal.Entries[0], ledger.journal.Entries[1] + if debit.Amount != "-2.5" || credit.Amount != "2.5" { + t.Fatalf("unexpected journal amounts: %q and %q", debit.Amount, credit.Amount) + } + if debit.Account.OwnerId == credit.Account.OwnerId || debit.Account.AssetId != 7 || credit.Account.AssetId != 7 { + t.Fatalf("unexpected simulated wallets: debit=%+v credit=%+v", debit.Account, credit.Account) + } + if len(ledger.events) != 2 || ledger.events[0].State != ledgerv1.TransactionState_TRANSACTION_STATE_CREATED || ledger.events[1].State != ledgerv1.TransactionState_TRANSACTION_STATE_SUCCESSFUL { + t.Fatalf("unexpected transaction lifecycle: %+v", ledger.events) + } + if ledger.lookup.GetJournalId() != "journal-1" || ledger.balance.Account.OwnerId != credit.Account.OwnerId { + t.Fatal("journal and balance reads did not follow the write") + } +} + +func TestTransactionHashIsStableAndUnique(t *testing.T) { + first := transactionHash("run-1") + if len(first) != 64 || first != transactionHash("run-1") || first == transactionHash("run-2") { + t.Fatalf("unexpected transaction hashes: %q", first) + } +} + +func TestPercentileUsesNearestRank(t *testing.T) { + values := []time.Duration{time.Millisecond, 4 * time.Millisecond, 2 * time.Millisecond, 3 * time.Millisecond} + if got := percentile(values, 95); got != 4*time.Millisecond { + t.Fatalf("unexpected p95: %s", got) + } +} diff --git a/cmd/gl-redistribution-loadtest/README.md b/cmd/gl-redistribution-loadtest/README.md new file mode 100644 index 0000000..d57bc6f --- /dev/null +++ b/cmd/gl-redistribution-loadtest/README.md @@ -0,0 +1,28 @@ +# GL redistribution load test + +This fixed-count scenario creates ten run-scoped user accounts for an operator- +selected asset. It verifies that all ten start at zero, funds only the first +person with 10,000, and then submits 10,000,000 user-to-user transfers. + +The first nine transfers give every other person 45. The remaining transfers +use reproducible random senders, receivers, and positive integer amounts while +reserving a minimum balance of 45 for every person. The test finishes by reading +all ten balances from GL and fails unless every balance is at least 45 and their +sum is exactly 10,000. + +Start GL, then run: + +```bash +go run ./cmd/gl-redistribution-loadtest +Asset number: 7 +``` + +For automation, pass the asset without the prompt: + +```bash +go run ./cmd/gl-redistribution-loadtest -asset-id 7 -concurrency 32 +``` + +The defaults are 10,000,000 transfers against `127.0.0.1:8600`. Use a fresh +`-run-id` for every dataset; one is generated automatically. `-transactions` +can be reduced for a smoke test, and `-seed` makes the random sequence repeatable. diff --git a/cmd/gl-redistribution-loadtest/main.go b/cmd/gl-redistribution-loadtest/main.go new file mode 100644 index 0000000..9738f90 --- /dev/null +++ b/cmd/gl-redistribution-loadtest/main.go @@ -0,0 +1,458 @@ +// Command gl-redistribution-loadtest proves balance conservation while applying +// a fixed number of random transfers between ten isolated test users. +package main + +import ( + "context" + "flag" + "fmt" + "io" + mathrand "math/rand" + "os" + "os/signal" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + basev1 "gl/gen/base/v1" + ledgerv1 "gl/gen/ledger/v1" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + personCount = 10 + initialSupply = int64(10_000) + minimumBalance = int64(45) + defaultTransactions = uint64(10_000_000) + seedTransactions = uint64(personCount - 1) +) + +type client interface { + Health(context.Context, *basev1.Empty, ...grpc.CallOption) (*ledgerv1.HealthResponse, error) + AppendJournal(context.Context, *ledgerv1.AppendJournalRequest, ...grpc.CallOption) (*ledgerv1.AppendJournalResponse, error) + GetBalance(context.Context, *ledgerv1.GetBalanceRequest, ...grpc.CallOption) (*ledgerv1.GetBalanceResponse, error) +} + +type config struct { + RunID string + AssetID int64 + Transactions uint64 + Concurrency int + RequestTimeout time.Duration + RandomSeed int64 +} + +type transfer struct { + Sender int + Receiver int + Amount int64 +} + +type balanceState struct { + mu sync.Mutex + balances [personCount]int64 +} + +type metrics struct { + attempted atomic.Uint64 + succeeded atomic.Uint64 + errors atomic.Uint64 + latencyNanos atomic.Int64 + maxNanos atomic.Int64 +} + +func main() { + os.Exit(runCLI(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) +} + +func runCLI(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("gl-redistribution-loadtest", flag.ContinueOnError) + flags.SetOutput(stderr) + target := flags.String("target", "127.0.0.1:8600", "GL gRPC target") + assetID := flags.Int64("asset-id", 0, "asset number; prompted when omitted") + transactions := flags.Uint64("transactions", defaultTransactions, "total user-to-user transfers") + concurrency := flags.Int("concurrency", 32, "number of concurrent transfer workers") + requestTimeout := flags.Duration("request-timeout", 10*time.Second, "timeout for each gRPC call") + runID := flags.String("run-id", "", "isolated idempotency namespace; generated when empty") + randomSeed := flags.Int64("seed", 0, "random seed; generated when zero") + if err := flags.Parse(args); err != nil { + return 2 + } + if *transactions < seedTransactions || *concurrency < 1 || *requestTimeout <= 0 { + fmt.Fprintf(stderr, "transactions must be at least %d; concurrency and request-timeout must be positive\n", seedTransactions) + return 2 + } + + resolvedAssetID, err := resolveAssetID(*assetID, stdin, stdout) + if err != nil { + fmt.Fprintf(stderr, "asset number: %v\n", err) + return 2 + } + if *runID == "" { + *runID = "gl-redistribution-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 36) + } + if *randomSeed == 0 { + *randomSeed = time.Now().UnixNano() + } + + connection, err := grpc.NewClient(*target, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + fmt.Fprintf(stderr, "create gRPC client: %v\n", err) + return 2 + } + defer connection.Close() + ledger := ledgerv1.NewGeneralLedgerServiceClient(connection) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + cfg := config{ + RunID: *runID, + AssetID: resolvedAssetID, + Transactions: *transactions, + Concurrency: *concurrency, + RequestTimeout: *requestTimeout, + RandomSeed: *randomSeed, + } + if err := execute(ctx, ledger, cfg, stdout); err != nil { + fmt.Fprintf(stderr, "redistribution load test failed: %v\n", err) + return 1 + } + return 0 +} + +func resolveAssetID(value int64, input io.Reader, output io.Writer) (int64, error) { + if value > 0 { + return value, nil + } + if value < 0 { + return 0, fmt.Errorf("must be positive") + } + fmt.Fprint(output, "Asset number: ") + var text string + if _, err := fmt.Fscan(input, &text); err != nil { + return 0, fmt.Errorf("read input: %w", err) + } + assetID, err := strconv.ParseInt(strings.TrimSpace(text), 10, 64) + if err != nil || assetID <= 0 { + return 0, fmt.Errorf("must be a positive integer") + } + return assetID, nil +} + +func execute(ctx context.Context, ledger client, cfg config, output io.Writer) error { + callCtx, cancel := context.WithTimeout(ctx, cfg.RequestTimeout) + health, err := ledger.Health(callCtx, &basev1.Empty{}) + cancel() + if err != nil { + return fmt.Errorf("health check: %w", err) + } + if !health.GetServing() || !health.GetDatabaseReady() { + return fmt.Errorf("GL is not ready: %v", health) + } + + people := personIDs(cfg.RunID) + startingBalances, err := readBalances(ctx, ledger, cfg, people) + if err != nil { + return fmt.Errorf("read unfunded balances: %w", err) + } + if err := verifyUnfunded(startingBalances); err != nil { + return err + } + if err := fundFirstPerson(ctx, ledger, cfg, people[0]); err != nil { + return fmt.Errorf("fund first person: %w", err) + } + initialBalances, err := readBalances(ctx, ledger, cfg, people) + if err != nil { + return fmt.Errorf("read initial balances: %w", err) + } + if err := verifyInitial(initialBalances); err != nil { + return err + } + + fmt.Fprintf(output, "GL redistribution load test: target users=%d asset=%d transfers=%d workers=%d seed=%d run=%s\n", + personCount, cfg.AssetID, cfg.Transactions, cfg.Concurrency, cfg.RandomSeed, cfg.RunID) + fmt.Fprintln(output, "initial balances: [10000 0 0 0 0 0 0 0 0 0]") + + stats := &metrics{} + started := time.Now() + balances := initialBalances + for person := 1; person < personCount; person++ { + value := transfer{Sender: 0, Receiver: person, Amount: minimumBalance} + startedCall := time.Now() + err := appendTransfer(ctx, ledger, cfg, uint64(person), people, value) + stats.observe(time.Since(startedCall), err) + if err != nil { + return fmt.Errorf("seed person %d: %w", person+1, err) + } + balances[0] -= minimumBalance + balances[person] += minimumBalance + } + + state := &balanceState{balances: balances} + if err := runRandomTransfers(ctx, ledger, cfg, people, state, stats, output); err != nil { + printSummary(output, stats, time.Since(started)) + return err + } + elapsed := time.Since(started) + printSummary(output, stats, elapsed) + + finalBalances, err := readBalances(ctx, ledger, cfg, people) + if err != nil { + return fmt.Errorf("read final balances: %w", err) + } + if err := verifyFinal(finalBalances); err != nil { + return err + } + fmt.Fprintf(output, "final balances: %v\n", finalBalances) + fmt.Fprintf(output, "PASS: all %d people have at least %d; total=%d\n", personCount, minimumBalance, initialSupply) + return nil +} + +func personIDs(runID string) [personCount]string { + var people [personCount]string + for index := range people { + people[index] = fmt.Sprintf("%s-person-%02d", runID, index+1) + } + return people +} + +func readBalances(ctx context.Context, ledger client, cfg config, people [personCount]string) ([personCount]int64, error) { + var balances [personCount]int64 + for index, person := range people { + callCtx, cancel := context.WithTimeout(ctx, cfg.RequestTimeout) + response, err := ledger.GetBalance(callCtx, &ledgerv1.GetBalanceRequest{Account: personAccount(person, cfg.AssetID)}) + cancel() + if err != nil { + return balances, fmt.Errorf("person %d: %w", index+1, err) + } + if response == nil { + return balances, fmt.Errorf("person %d: empty response", index+1) + } + balance, err := strconv.ParseInt(response.GetBalance(), 10, 64) + if err != nil { + return balances, fmt.Errorf("person %d: invalid integer balance %q", index+1, response.GetBalance()) + } + balances[index] = balance + } + return balances, nil +} + +func verifyUnfunded(balances [personCount]int64) error { + for index, balance := range balances { + if balance != 0 { + return fmt.Errorf("person %d must start unfunded; got %d (use a fresh run-id)", index+1, balance) + } + } + return nil +} + +func verifyInitial(balances [personCount]int64) error { + if balances[0] != initialSupply { + return fmt.Errorf("person 1 initial balance: got %d, want %d", balances[0], initialSupply) + } + for index := 1; index < personCount; index++ { + if balances[index] != 0 { + return fmt.Errorf("person %d initial balance: got %d, want 0", index+1, balances[index]) + } + } + return nil +} + +func verifyFinal(balances [personCount]int64) error { + var total int64 + for index, balance := range balances { + if balance < minimumBalance { + return fmt.Errorf("person %d final balance %d is below %d", index+1, balance, minimumBalance) + } + total += balance + } + if total != initialSupply { + return fmt.Errorf("final balance sum: got %d, want %d", total, initialSupply) + } + return nil +} + +func fundFirstPerson(ctx context.Context, ledger client, cfg config, firstPerson string) error { + request := &ledgerv1.AppendJournalRequest{ + SourceService: "gl-redistribution-loadtest", + IdempotencyKey: "redistribution:" + cfg.RunID + ":fund:v1", + SourceTransactionId: cfg.RunID + "-fund", + TrackingCode: cfg.RunID + "-fund", + EffectKind: "load-test-funding", + EventVersion: 1, + OccurredAt: timestamppb.Now(), + ActorId: cfg.RunID, + Metadata: map[string]string{"load_test": cfg.RunID, "phase": "funding"}, + Entries: []*ledgerv1.JournalEntry{ + {LineNumber: 1, Account: treasuryAccount(cfg.RunID, cfg.AssetID), Amount: "-10000", Description: "load-test source"}, + {LineNumber: 2, Account: personAccount(firstPerson, cfg.AssetID), Amount: "10000", Description: "initial person balance"}, + }, + } + callCtx, cancel := context.WithTimeout(ctx, cfg.RequestTimeout) + defer cancel() + _, err := ledger.AppendJournal(callCtx, request) + return err +} + +func runRandomTransfers(ctx context.Context, ledger client, cfg config, people [personCount]string, state *balanceState, stats *metrics, output io.Writer) error { + if cfg.Transactions == seedTransactions { + return nil + } + workerCtx, cancel := context.WithCancel(ctx) + defer cancel() + + var sequence atomic.Uint64 + sequence.Store(seedTransactions) + var workers sync.WaitGroup + var firstErr error + var errorOnce sync.Once + workers.Add(cfg.Concurrency) + for worker := range cfg.Concurrency { + go func(worker int) { + defer workers.Done() + random := mathrand.New(mathrand.NewSource(cfg.RandomSeed + int64(worker+1)*7919)) + for workerCtx.Err() == nil { + number := sequence.Add(1) + if number > cfg.Transactions { + return + } + value := state.reserve(random) + started := time.Now() + err := appendTransfer(workerCtx, ledger, cfg, number, people, value) + stats.observe(time.Since(started), err) + if err != nil { + state.rollback(value) + errorOnce.Do(func() { + firstErr = fmt.Errorf("transfer %d: %w", number, err) + cancel() + }) + return + } + succeeded := stats.succeeded.Load() + if succeeded%1_000_000 == 0 { + fmt.Fprintf(output, "progress: %d/%d transfers\n", succeeded, cfg.Transactions) + } + } + }(worker) + } + workers.Wait() + if firstErr != nil { + return firstErr + } + if err := ctx.Err(); err != nil { + return err + } + if succeeded := stats.succeeded.Load(); succeeded != cfg.Transactions { + return fmt.Errorf("completed %d transfers, want %d", succeeded, cfg.Transactions) + } + return nil +} + +func (s *balanceState) reserve(random *mathrand.Rand) transfer { + s.mu.Lock() + defer s.mu.Unlock() + + eligible := make([]int, 0, personCount) + for index, balance := range s.balances { + if balance > minimumBalance { + eligible = append(eligible, index) + } + } + sender := eligible[random.Intn(len(eligible))] + receiver := random.Intn(personCount - 1) + if receiver >= sender { + receiver++ + } + spendable := s.balances[sender] - minimumBalance + amount := random.Int63n(spendable) + 1 + s.balances[sender] -= amount + s.balances[receiver] += amount + return transfer{Sender: sender, Receiver: receiver, Amount: amount} +} + +func (s *balanceState) rollback(value transfer) { + s.mu.Lock() + defer s.mu.Unlock() + s.balances[value.Sender] += value.Amount + s.balances[value.Receiver] -= value.Amount +} + +func appendTransfer(ctx context.Context, ledger client, cfg config, sequence uint64, people [personCount]string, value transfer) error { + number := strconv.FormatUint(sequence, 10) + amount := strconv.FormatInt(value.Amount, 10) + transactionID := cfg.RunID + "-transfer-" + number + request := &ledgerv1.AppendJournalRequest{ + SourceService: "gl-redistribution-loadtest", + IdempotencyKey: "redistribution:" + cfg.RunID + ":transfer:" + number + ":v1", + SourceTransactionId: transactionID, + TrackingCode: transactionID, + EffectKind: "random-internal-transfer", + EventVersion: 1, + OccurredAt: timestamppb.Now(), + CorrelationId: cfg.RunID, + ActorId: people[value.Sender], + Metadata: map[string]string{"load_test": cfg.RunID, "sequence": number}, + Entries: []*ledgerv1.JournalEntry{ + {LineNumber: 1, Account: personAccount(people[value.Sender], cfg.AssetID), Amount: "-" + amount, Description: "random transfer debit"}, + {LineNumber: 2, Account: personAccount(people[value.Receiver], cfg.AssetID), Amount: amount, Description: "random transfer credit"}, + }, + } + callCtx, cancel := context.WithTimeout(ctx, cfg.RequestTimeout) + defer cancel() + _, err := ledger.AppendJournal(callCtx, request) + return err +} + +func personAccount(ownerID string, assetID int64) *ledgerv1.AccountReference { + return &ledgerv1.AccountReference{ + AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE, + OwnerType: "user", + OwnerId: ownerID, + AssetId: assetID, + } +} + +func treasuryAccount(runID string, assetID int64) *ledgerv1.AccountReference { + return &ledgerv1.AccountReference{ + AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_TREASURY, + OwnerType: "load-test", + OwnerId: runID, + AssetId: assetID, + } +} + +func (m *metrics) observe(latency time.Duration, err error) { + m.attempted.Add(1) + m.latencyNanos.Add(latency.Nanoseconds()) + for { + current := m.maxNanos.Load() + if latency.Nanoseconds() <= current || m.maxNanos.CompareAndSwap(current, latency.Nanoseconds()) { + break + } + } + if err != nil { + m.errors.Add(1) + return + } + m.succeeded.Add(1) +} + +func printSummary(output io.Writer, stats *metrics, elapsed time.Duration) { + attempted := stats.attempted.Load() + average := time.Duration(0) + if attempted > 0 { + average = time.Duration(stats.latencyNanos.Load() / int64(attempted)) + } + rate := float64(0) + if elapsed > 0 { + rate = float64(stats.succeeded.Load()) / elapsed.Seconds() + } + fmt.Fprintf(output, "transfers: attempted=%d succeeded=%d errors=%d elapsed=%s rate=%.1f tx/s avg=%s max=%s\n", + attempted, stats.succeeded.Load(), stats.errors.Load(), elapsed.Round(time.Millisecond), rate, average, time.Duration(stats.maxNanos.Load())) +} diff --git a/cmd/gl-redistribution-loadtest/main_test.go b/cmd/gl-redistribution-loadtest/main_test.go new file mode 100644 index 0000000..5500693 --- /dev/null +++ b/cmd/gl-redistribution-loadtest/main_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "bytes" + "context" + "fmt" + mathrand "math/rand" + "strconv" + "strings" + "sync" + "testing" + "time" + + basev1 "gl/gen/base/v1" + ledgerv1 "gl/gen/ledger/v1" + + "google.golang.org/grpc" +) + +type memoryClient struct { + mu sync.Mutex + balances map[string]int64 + journals int +} + +func newMemoryClient() *memoryClient { + return &memoryClient{balances: make(map[string]int64)} +} + +func (c *memoryClient) Health(context.Context, *basev1.Empty, ...grpc.CallOption) (*ledgerv1.HealthResponse, error) { + return &ledgerv1.HealthResponse{Serving: true, DatabaseReady: true}, nil +} + +func (c *memoryClient) AppendJournal(_ context.Context, request *ledgerv1.AppendJournalRequest, _ ...grpc.CallOption) (*ledgerv1.AppendJournalResponse, error) { + c.mu.Lock() + defer c.mu.Unlock() + for _, entry := range request.GetEntries() { + amount, err := strconv.ParseInt(entry.GetAmount(), 10, 64) + if err != nil { + return nil, err + } + c.balances[accountKey(entry.GetAccount())] += amount + } + c.journals++ + return &ledgerv1.AppendJournalResponse{Journal: &ledgerv1.Journal{JournalId: fmt.Sprintf("journal-%d", c.journals)}}, nil +} + +func (c *memoryClient) GetBalance(_ context.Context, request *ledgerv1.GetBalanceRequest, _ ...grpc.CallOption) (*ledgerv1.GetBalanceResponse, error) { + c.mu.Lock() + defer c.mu.Unlock() + return &ledgerv1.GetBalanceResponse{Account: request.GetAccount(), Balance: strconv.FormatInt(c.balances[accountKey(request.GetAccount())], 10)}, nil +} + +func accountKey(account *ledgerv1.AccountReference) string { + return fmt.Sprintf("%d/%s/%s/%d", account.GetAccountClass(), account.GetOwnerType(), account.GetOwnerId(), account.GetAssetId()) +} + +func TestResolveAssetIDPromptsWhenMissing(t *testing.T) { + var output bytes.Buffer + assetID, err := resolveAssetID(0, strings.NewReader("42\n"), &output) + if err != nil { + t.Fatal(err) + } + if assetID != 42 || output.String() != "Asset number: " { + t.Fatalf("asset=%d prompt=%q", assetID, output.String()) + } +} + +func TestResolveAssetIDUsesFlagWithoutPrompt(t *testing.T) { + var output bytes.Buffer + assetID, err := resolveAssetID(7, strings.NewReader(""), &output) + if err != nil { + t.Fatal(err) + } + if assetID != 7 || output.Len() != 0 { + t.Fatalf("asset=%d prompt=%q", assetID, output.String()) + } +} + +func TestRandomReservationsPreserveSupplyAndMinimum(t *testing.T) { + state := &balanceState{balances: [personCount]int64{9595, 45, 45, 45, 45, 45, 45, 45, 45, 45}} + random := mathrand.New(mathrand.NewSource(1234)) + distinctAmounts := make(map[int64]struct{}) + for range 100_000 { + value := state.reserve(random) + distinctAmounts[value.Amount] = struct{}{} + if value.Sender == value.Receiver || value.Amount <= 0 { + t.Fatalf("invalid transfer: %+v", value) + } + } + + var total int64 + for index, balance := range state.balances { + if balance < minimumBalance { + t.Fatalf("person %d balance %d is below minimum", index+1, balance) + } + total += balance + } + if total != initialSupply { + t.Fatalf("total=%d, want %d", total, initialSupply) + } + if len(distinctAmounts) < 2 { + t.Fatalf("expected random values, got %v", distinctAmounts) + } +} + +func TestVerifyInitialAndFinalBalances(t *testing.T) { + initial := [personCount]int64{10_000} + if err := verifyInitial(initial); err != nil { + t.Fatal(err) + } + final := [personCount]int64{9595, 45, 45, 45, 45, 45, 45, 45, 45, 45} + if err := verifyFinal(final); err != nil { + t.Fatal(err) + } + final[9] = 44 + if err := verifyFinal(final); err == nil { + t.Fatal("expected minimum-balance failure") + } +} + +func TestPersonIDsAreIsolatedByRun(t *testing.T) { + first := personIDs("run-a") + second := personIDs("run-b") + if first[0] == second[0] || first[0] == first[1] { + t.Fatalf("IDs are not isolated: %q %q %q", first[0], second[0], first[1]) + } +} + +func TestExecuteFundsRedistributesAndVerifiesConservation(t *testing.T) { + ledger := newMemoryClient() + var output bytes.Buffer + err := execute(context.Background(), ledger, config{ + RunID: "test-run", + AssetID: 17, + Transactions: 1000, + Concurrency: 4, + RequestTimeout: time.Second, + RandomSeed: 99, + }, &output) + if err != nil { + t.Fatal(err) + } + if ledger.journals != 1001 { + t.Fatalf("journals=%d, want one funding journal and 1000 transfers", ledger.journals) + } + if !strings.Contains(output.String(), "PASS: all 10 people have at least 45; total=10000") { + t.Fatalf("missing successful verification in output:\n%s", output.String()) + } +} diff --git a/cmd/gl/main.go b/cmd/gl/main.go new file mode 100644 index 0000000..7712b08 --- /dev/null +++ b/cmd/gl/main.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "gl/application/health" + applicationledger "gl/application/ledger" + applicationsettlement "gl/application/settlement" + "gl/infrastructure/config" + "gl/infrastructure/kuknos" + "gl/infrastructure/observability" + "gl/infrastructure/postgres" + grpcadapter "gl/interface/grpc" +) + +func main() { + observability.Configure("gl", slog.LevelInfo) + configPath := flag.String("conf", "./gl.cfg.toml", "path to the TOML configuration file") + flag.Parse() + + cfg, err := config.Load(*configPath) + if err != nil { + slog.Error("load configuration", "error", err) + os.Exit(1) + } + shutdownTelemetry := observability.Setup(context.Background(), cfg.Telemetry, "gl", cfg.Environment) + defer shutdownTelemetry(context.Background()) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + database, err := postgres.Open(ctx, cfg.Database) + if err != nil { + slog.Error("open GL database", "error", err) + os.Exit(1) + } + defer database.Close() + if err := postgres.Migrate(ctx, database); err != nil { + slog.Error("migrate GL database", "error", err) + os.Exit(1) + } + + repository := postgres.NewJournalRepository(database) + settlementRepository := postgres.NewSettlementRepository(database) + settlementService := applicationsettlement.NewService(settlementRepository) + var healthService *health.Service + if cfg.Settlement.Enabled { + healthService = health.NewService(database, settlementRepository) + } else { + healthService = health.NewService(database) + } + handler := grpcadapter.NewHandler(healthService, applicationledger.NewService(repository, nil), settlementService).WithSettlementAdminToken(cfg.Settlement.AdminToken) + if cfg.Settlement.Enabled { + worker := applicationsettlement.Worker{Repository: settlementRepository, Submitter: kuknos.Submitter{Endpoint: cfg.Settlement.Endpoint}, WorkerID: cfg.Settlement.WorkerID, MaxAttempts: cfg.Settlement.MaxAttempts} + go runSettlementWorker(ctx, worker, cfg.Settlement.PollInterval, cfg.Settlement.BatchSize) + go runSettlementReconciliation(ctx, settlementRepository) + slog.Info("Kuknos settlement worker enabled", "endpoint", cfg.Settlement.Endpoint, "worker_id", cfg.Settlement.WorkerID) + } + slog.Info("starting GL gRPC service", "host", cfg.GRPC.Host, "port", cfg.GRPC.Port) + serverConfig := grpcadapter.ServerConfig{ + Host: cfg.GRPC.Host, + Port: cfg.GRPC.Port, + ShutdownTimeout: cfg.GRPC.ShutdownTimeout, + } + if err := grpcadapter.Run(ctx, serverConfig, handler); err != nil { + slog.Error("GL service stopped", "error", err) + os.Exit(1) + } +} + +func runSettlementReconciliation(ctx context.Context, repository *postgres.SettlementRepository) { + run := func() { + stats, err := repository.ReconcileConfirmationEvidence(ctx, 10*time.Minute) + if err != nil { + slog.ErrorContext(ctx, "settlement reconciliation failed", "error", err) + return + } + if stats.Detected > 0 || stats.Resolved > 0 || stats.Open > 0 { + slog.WarnContext(ctx, "settlement reconciliation completed", "detected", stats.Detected, "resolved", stats.Resolved, "open", stats.Open) + } + } + run() + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + run() + } + } +} + +func runSettlementWorker(ctx context.Context, worker applicationsettlement.Worker, interval time.Duration, batchSize int) { + if interval <= 0 { + interval = 5 * time.Second + } + if batchSize <= 0 { + batchSize = 20 + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := worker.RunOnce(ctx, batchSize); err != nil { + slog.ErrorContext(ctx, "settlement worker cycle failed", "error", err) + } + } + } +} diff --git a/cmd/healthcheck/main.go b/cmd/healthcheck/main.go new file mode 100644 index 0000000..ca703eb --- /dev/null +++ b/cmd/healthcheck/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "context" + "flag" + "os" + "time" + + "gl/infrastructure/config" + "gl/infrastructure/postgres" +) + +func main() { + configPath := flag.String("conf", "/app/config.toml", "path to the GL TOML configuration file") + flag.Parse() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + cfg, err := config.Load(*configPath) + if err != nil { + os.Exit(1) + } + database, err := postgres.Open(ctx, cfg.Database) + if err != nil { + os.Exit(1) + } + defer database.Close() + if err := database.Ping(ctx); err != nil { + os.Exit(1) + } +} diff --git a/dashboard.cfg.toml b/dashboard.cfg.toml new file mode 100644 index 0000000..6f13756 --- /dev/null +++ b/dashboard.cfg.toml @@ -0,0 +1,15 @@ +environment = "local" + +[http] +host = "0.0.0.0" +port = 8601 +read-header-timeout = "5s" +shutdown-timeout = "10s" + +[db] +host = "127.0.0.1" +port = 5432 +name = "gl_db" +user = "postgres" +password = "" +ssl-mode = "disable" diff --git a/deployment/Dockerfile b/deployment/Dockerfile new file mode 100644 index 0000000..7e2e055 --- /dev/null +++ b/deployment/Dockerfile @@ -0,0 +1,41 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.26-bookworm AS builder + +WORKDIR /src + +ARG GO_PROXY=https://go.reg.darano.ir +ENV GOPROXY="$GO_PROXY" +ENV CGO_ENABLED=0 + +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \ + go mod download + +COPY . . +RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \ + --mount=type=cache,target=/root/.cache/go-build,sharing=locked \ + go build -trimpath -ldflags="-s -w" -o /out/gl ./cmd/gl && \ + go build -trimpath -ldflags="-s -w" -o /out/dashboard ./cmd/dashboard && \ + go build -trimpath -ldflags="-s -w" -o /out/healthcheck ./cmd/healthcheck + +FROM alpine:3.24.1 + +RUN apk add --no-cache ca-certificates \ + && addgroup -S darano \ + && adduser -S -G darano darano + +WORKDIR /app + +COPY --from=builder /out/gl /app/gl +COPY --from=builder /out/dashboard /app/dashboard +COPY --from=builder /out/healthcheck /app/healthcheck +COPY gl.cfg.toml /app/gl.cfg.toml +COPY dashboard.cfg.toml /app/dashboard.cfg.toml + +USER darano + +EXPOSE 8600 8601 + +ENTRYPOINT ["/app/gl"] +CMD ["-conf", "/app/gl.cfg.toml"] diff --git a/domain/ledger/amount.go b/domain/ledger/amount.go new file mode 100644 index 0000000..c7bb464 --- /dev/null +++ b/domain/ledger/amount.go @@ -0,0 +1,115 @@ +// Package ledger contains GL's framework-independent financial model. +package ledger + +import ( + "fmt" + "math/big" + "strings" + "unicode" +) + +const ( + AmountPrecision = 38 + AmountScale = 18 +) + +var amountFactor = new(big.Int).Exp(big.NewInt(10), big.NewInt(AmountScale), nil) + +// Amount is an exact fixed-point decimal with PostgreSQL numeric(38,18) +// semantics. Its zero value is a valid zero amount. +type Amount struct { + units big.Int +} + +func ParseAmount(value string) (Amount, error) { + if value == "" { + return Amount{}, fmt.Errorf("amount is required") + } + + negative := value[0] == '-' + unsigned := value + if negative { + unsigned = value[1:] + } + if unsigned == "" || strings.HasPrefix(unsigned, "+") { + return Amount{}, fmt.Errorf("invalid amount %q", value) + } + + parts := strings.Split(unsigned, ".") + if len(parts) > 2 || parts[0] == "" { + return Amount{}, fmt.Errorf("invalid amount %q", value) + } + for _, part := range parts { + if part == "" || strings.IndexFunc(part, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 { + return Amount{}, fmt.Errorf("invalid amount %q", value) + } + } + if len(parts[0]) > 1 && parts[0][0] == '0' { + return Amount{}, fmt.Errorf("amount must not contain leading zeroes") + } + if len(strings.TrimLeft(parts[0], "0")) > AmountPrecision-AmountScale { + return Amount{}, fmt.Errorf("amount exceeds integer precision %d", AmountPrecision-AmountScale) + } + + fraction := "" + if len(parts) == 2 { + fraction = parts[1] + if len(fraction) > AmountScale { + return Amount{}, fmt.Errorf("amount exceeds scale %d", AmountScale) + } + } + digits := strings.TrimLeft(parts[0]+fraction, "0") + if len(digits) > AmountPrecision { + return Amount{}, fmt.Errorf("amount exceeds precision %d", AmountPrecision) + } + + whole := new(big.Int) + whole.SetString(parts[0], 10) + units := new(big.Int).Mul(whole, amountFactor) + if fraction != "" { + padded := fraction + strings.Repeat("0", AmountScale-len(fraction)) + fractional := new(big.Int) + fractional.SetString(padded, 10) + units.Add(units, fractional) + } + if negative { + units.Neg(units) + } + + return Amount{units: *units}, nil +} + +func (a Amount) IsZero() bool { + return a.units.Sign() == 0 +} + +func (a Amount) Add(other Amount) Amount { + var result big.Int + result.Add(&a.units, &other.units) + return Amount{units: result} +} + +func (a Amount) Negate() Amount { + var result big.Int + result.Neg(&a.units) + return Amount{units: result} +} + +func (a Amount) String() string { + if a.units.Sign() == 0 { + return "0" + } + + abs := new(big.Int).Abs(new(big.Int).Set(&a.units)) + whole, fraction := new(big.Int), new(big.Int) + whole.QuoRem(abs, amountFactor, fraction) + value := whole.String() + if fraction.Sign() != 0 { + fractionText := fmt.Sprintf("%018s", fraction.String()) + value += "." + strings.TrimRight(fractionText, "0") + } + if a.units.Sign() < 0 { + return "-" + value + } + return value +} diff --git a/domain/ledger/amount_test.go b/domain/ledger/amount_test.go new file mode 100644 index 0000000..08f56a5 --- /dev/null +++ b/domain/ledger/amount_test.go @@ -0,0 +1,38 @@ +package ledger + +import "testing" + +func TestParseAmountCanonicalizesExactValues(t *testing.T) { + for input, expected := range map[string]string{ + "0": "0", + "1": "1", + "1.2300": "1.23", + "-0.000000000000000001": "-0.000000000000000001", + "99999999999999999999": "99999999999999999999", + } { + amount, err := ParseAmount(input) + if err != nil { + t.Fatalf("ParseAmount(%q): %v", input, err) + } + if got := amount.String(); got != expected { + t.Fatalf("ParseAmount(%q) = %q, want %q", input, got, expected) + } + } +} + +func TestParseAmountRejectsInvalidValues(t *testing.T) { + for _, input := range []string{"", "+1", "01", ".1", "1.", "1e2", "1.0000000000000000001", "123456789012345678901234567890123456789"} { + if _, err := ParseAmount(input); err == nil { + t.Fatalf("ParseAmount(%q) succeeded", input) + } + } +} + +func TestAmountAdditionIsExact(t *testing.T) { + one, _ := ParseAmount("0.1") + two, _ := ParseAmount("0.2") + minusThree, _ := ParseAmount("-0.3") + if got := one.Add(two).Add(minusThree); !got.IsZero() { + t.Fatalf("expected exact zero, got %s", got.String()) + } +} diff --git a/domain/ledger/errors.go b/domain/ledger/errors.go new file mode 100644 index 0000000..6708e0a --- /dev/null +++ b/domain/ledger/errors.go @@ -0,0 +1,10 @@ +package ledger + +import "errors" + +var ( + ErrNotFound = errors.New("ledger record not found") + ErrIdempotencyConflict = errors.New("idempotency key belongs to a different payload") + ErrIncompleteJournal = errors.New("idempotency key belongs to an unsealed journal") + ErrAlreadyReversed = errors.New("journal already has a reversal") +) diff --git a/domain/ledger/event.go b/domain/ledger/event.go new file mode 100644 index 0000000..0f9084c --- /dev/null +++ b/domain/ledger/event.go @@ -0,0 +1,74 @@ +package ledger + +import ( + "encoding/hex" + "fmt" + "strings" + "time" +) + +type TransactionState string + +const ( + TransactionStateCreated TransactionState = "CREATED" + TransactionStatePendingTransaction TransactionState = "PENDING_TRANSACTION" + TransactionStatePendingAdmin TransactionState = "PENDING_ADMIN" + TransactionStateSuccessful TransactionState = "SUCCESSFUL" + TransactionStateFailed TransactionState = "FAILED" + TransactionStateSuspended TransactionState = "SUSPENDED" +) + +func (s TransactionState) Valid() bool { + switch s { + case TransactionStateCreated, + TransactionStatePendingTransaction, + TransactionStatePendingAdmin, + TransactionStateSuccessful, + TransactionStateFailed, + TransactionStateSuspended: + return true + default: + return false + } +} + +type TransactionEvent struct { + ID string + SourceService string + IdempotencyKey string + SourceTransactionID string + TrackingCode string + EventVersion uint32 + State TransactionState + ErrorCode string + ErrorMessage string + OccurredAt time.Time + RecordedAt time.Time + CorrelationID string + ActorID string + Blockchain BlockchainReference + Metadata map[string]string + PayloadHash string +} + +func (e TransactionEvent) Validate() error { + if e.ID == "" || e.SourceService == "" || e.IdempotencyKey == "" || e.SourceTransactionID == "" { + return fmt.Errorf("transaction event identity fields are required") + } + if e.EventVersion == 0 { + return fmt.Errorf("event version must be positive") + } + if !e.State.Valid() { + return fmt.Errorf("invalid transaction state %q", e.State) + } + if e.OccurredAt.IsZero() { + return fmt.Errorf("occurred time is required") + } + if len(e.PayloadHash) != 64 || e.PayloadHash != strings.ToLower(e.PayloadHash) { + return fmt.Errorf("payload hash must be a lowercase SHA-256 hex string") + } + if _, err := hex.DecodeString(e.PayloadHash); err != nil { + return fmt.Errorf("payload hash must be a lowercase SHA-256 hex string") + } + return nil +} diff --git a/domain/ledger/journal.go b/domain/ledger/journal.go new file mode 100644 index 0000000..505b19f --- /dev/null +++ b/domain/ledger/journal.go @@ -0,0 +1,154 @@ +package ledger + +import ( + "encoding/hex" + "fmt" + "strings" + "time" +) + +type AccountClass string + +const ( + AccountClassUserAvailable AccountClass = "USER_AVAILABLE" + AccountClassUserFrozen AccountClass = "USER_FROZEN" + AccountClassExternalBlockchain AccountClass = "EXTERNAL_BLOCKCHAIN" + AccountClassTreasury AccountClass = "TREASURY" + AccountClassMarketClearing AccountClass = "MARKET_CLEARING" + AccountClassIPGClearing AccountClass = "IPG_CLEARING" + AccountClassCommissionRevenue AccountClass = "COMMISSION_REVENUE" +) + +func (c AccountClass) Valid() bool { + switch c { + case AccountClassUserAvailable, + AccountClassUserFrozen, + AccountClassExternalBlockchain, + AccountClassTreasury, + AccountClassMarketClearing, + AccountClassIPGClearing, + AccountClassCommissionRevenue: + return true + default: + return false + } +} + +type AccountReference struct { + Class AccountClass + OwnerType string + OwnerID string + AssetID int64 +} + +func (a AccountReference) Validate() error { + if !a.Class.Valid() { + return fmt.Errorf("invalid account class %q", a.Class) + } + if a.AssetID <= 0 { + return fmt.Errorf("asset id must be positive") + } + if a.Class == AccountClassUserAvailable || a.Class == AccountClassUserFrozen { + if a.OwnerType == "" || a.OwnerID == "" { + return fmt.Errorf("user account requires owner type and id") + } + } + return nil +} + +type Entry struct { + LineNumber uint32 + Account AccountReference + Amount Amount + Description string +} + +type BlockchainReference struct { + Network string + TransactionHash string + LedgerSequence string +} + +type Journal struct { + ID string + SourceService string + IdempotencyKey string + SourceTransactionID string + TrackingCode string + EffectKind string + EventVersion uint32 + Entries []Entry + ReversalOfJournalID string + OccurredAt time.Time + RecordedAt time.Time + CorrelationID string + ActorID string + Blockchain BlockchainReference + Metadata map[string]string + PayloadHash string +} + +type AppendResult struct { + JournalID string + AlreadyExists bool +} + +type JournalFilter struct { + Account *AccountReference + AssetID *int64 + OwnerID *string + EffectKind *string + RecordedFrom *time.Time + RecordedTo *time.Time + Limit int + Offset int +} + +func (j Journal) Validate() error { + if j.ID == "" || j.SourceService == "" || j.IdempotencyKey == "" || j.SourceTransactionID == "" || j.EffectKind == "" { + return fmt.Errorf("journal identity fields are required") + } + if j.EventVersion == 0 { + return fmt.Errorf("event version must be positive") + } + if j.OccurredAt.IsZero() { + return fmt.Errorf("occurred time is required") + } + if len(j.PayloadHash) != 64 { + return fmt.Errorf("payload hash must be a SHA-256 hex string") + } + if _, err := hex.DecodeString(j.PayloadHash); err != nil { + return fmt.Errorf("payload hash must be a SHA-256 hex string") + } + if j.PayloadHash != strings.ToLower(j.PayloadHash) { + return fmt.Errorf("payload hash must use lowercase hexadecimal") + } + if len(j.Entries) < 2 { + return fmt.Errorf("journal requires at least two entries") + } + + lines := make(map[uint32]struct{}, len(j.Entries)) + balances := make(map[int64]Amount) + for _, entry := range j.Entries { + if entry.LineNumber == 0 { + return fmt.Errorf("entry line number must be positive") + } + if _, exists := lines[entry.LineNumber]; exists { + return fmt.Errorf("duplicate entry line number %d", entry.LineNumber) + } + lines[entry.LineNumber] = struct{}{} + if err := entry.Account.Validate(); err != nil { + return fmt.Errorf("entry %d: %w", entry.LineNumber, err) + } + if entry.Amount.IsZero() { + return fmt.Errorf("entry %d amount must not be zero", entry.LineNumber) + } + balances[entry.Account.AssetID] = balances[entry.Account.AssetID].Add(entry.Amount) + } + for assetID, balance := range balances { + if !balance.IsZero() { + return fmt.Errorf("asset %d entries are unbalanced by %s", assetID, balance.String()) + } + } + return nil +} diff --git a/domain/ledger/journal_test.go b/domain/ledger/journal_test.go new file mode 100644 index 0000000..68d6054 --- /dev/null +++ b/domain/ledger/journal_test.go @@ -0,0 +1,50 @@ +package ledger + +import ( + "strings" + "testing" + "time" +) + +func TestJournalValidationBalancesEachAsset(t *testing.T) { + journal := validJournal(t) + if err := journal.Validate(); err != nil { + t.Fatal(err) + } + + journal.Entries[1].Amount, _ = ParseAmount("9") + if err := journal.Validate(); err == nil || !strings.Contains(err.Error(), "unbalanced") { + t.Fatalf("expected unbalanced error, got %v", err) + } +} + +func TestJournalValidationRejectsDuplicateLines(t *testing.T) { + journal := validJournal(t) + journal.Entries[1].LineNumber = journal.Entries[0].LineNumber + if err := journal.Validate(); err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("expected duplicate line error, got %v", err) + } +} + +func validJournal(t *testing.T) Journal { + t.Helper() + debit, err := ParseAmount("-10") + if err != nil { + t.Fatal(err) + } + credit := debit.Negate() + return Journal{ + ID: "11111111-1111-4111-8111-111111111111", + SourceService: "wallet", + IdempotencyKey: "wallet:1:internal-transfer:v1", + SourceTransactionID: "1", + EffectKind: "internal-transfer", + EventVersion: 1, + OccurredAt: time.Unix(1, 0).UTC(), + PayloadHash: strings.Repeat("a", 64), + Entries: []Entry{ + {LineNumber: 1, Account: AccountReference{Class: AccountClassUserAvailable, OwnerType: "user", OwnerID: "1", AssetID: 5}, Amount: debit}, + {LineNumber: 2, Account: AccountReference{Class: AccountClassUserAvailable, OwnerType: "user", OwnerID: "2", AssetID: 5}, Amount: credit}, + }, + } +} diff --git a/domain/ledger/settlement.go b/domain/ledger/settlement.go new file mode 100644 index 0000000..0b9b586 --- /dev/null +++ b/domain/ledger/settlement.go @@ -0,0 +1,67 @@ +package ledger + +import ( + "fmt" + "time" +) + +type SettlementStatus string + +const ( + SettlementPending SettlementStatus = "PENDING" + SettlementSubmitted SettlementStatus = "SUBMITTED" + SettlementConfirmed SettlementStatus = "CONFIRMED" + SettlementRetryable SettlementStatus = "RETRYABLE" + SettlementManualReview SettlementStatus = "MANUAL_REVIEW" +) + +type Settlement struct { + ID string + SourceService string + SourceTxID string + IdempotencyKey string + Status SettlementStatus + Network string + TransactionHash string + Attempts int + AvailableAt time.Time + LastError string + SignedTransactionXDR string +} + +type SettlementStats struct { + Pending int64 + Retryable int64 + ManualReview int64 + OldestPendingSeconds int64 +} + +func (s Settlement) Validate() error { + if s.ID == "" || s.SourceService == "" || s.SourceTxID == "" || s.IdempotencyKey == "" { + return fmt.Errorf("settlement identity fields are required") + } + if s.Status != SettlementPending && s.Status != SettlementSubmitted && s.Status != SettlementConfirmed && s.Status != SettlementRetryable && s.Status != SettlementManualReview { + return fmt.Errorf("invalid settlement status %q", s.Status) + } + if s.Attempts < 0 || s.AvailableAt.IsZero() { + return fmt.Errorf("invalid settlement retry state") + } + return nil +} + +func (s Settlement) Retry(now time.Time, maxAttempts int, err error) Settlement { + s.LastError = err.Error() + s.Status = SettlementRetryable + if maxAttempts > 0 && s.Attempts >= maxAttempts { + s.Status = SettlementManualReview + } + delay := time.Second + for i := 1; i < s.Attempts && delay < 15*time.Minute; i++ { + delay *= 2 + } + if delay > 15*time.Minute { + delay = 15 * time.Minute + } + s.AvailableAt = now.UTC().Add(delay) + return s +} diff --git a/gen/base/v1/msg.pb.go b/gen/base/v1/msg.pb.go new file mode 100644 index 0000000..af9a4c7 --- /dev/null +++ b/gen/base/v1/msg.pb.go @@ -0,0 +1,368 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.6 +// protoc (unknown) +// source: base/v1/msg.proto + +package basev1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Empty struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Empty) Reset() { + *x = Empty{} + mi := &file_base_v1_msg_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Empty) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Empty) ProtoMessage() {} + +func (x *Empty) ProtoReflect() protoreflect.Message { + mi := &file_base_v1_msg_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Empty.ProtoReflect.Descriptor instead. +func (*Empty) Descriptor() ([]byte, []int) { + return file_base_v1_msg_proto_rawDescGZIP(), []int{0} +} + +type StatusRes struct { + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatusRes) Reset() { + *x = StatusRes{} + mi := &file_base_v1_msg_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatusRes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatusRes) ProtoMessage() {} + +func (x *StatusRes) ProtoReflect() protoreflect.Message { + mi := &file_base_v1_msg_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatusRes.ProtoReflect.Descriptor instead. +func (*StatusRes) Descriptor() ([]byte, []int) { + return file_base_v1_msg_proto_rawDescGZIP(), []int{1} +} + +func (x *StatusRes) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +type IdRes struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IdRes) Reset() { + *x = IdRes{} + mi := &file_base_v1_msg_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IdRes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IdRes) ProtoMessage() {} + +func (x *IdRes) ProtoReflect() protoreflect.Message { + mi := &file_base_v1_msg_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IdRes.ProtoReflect.Descriptor instead. +func (*IdRes) Descriptor() ([]byte, []int) { + return file_base_v1_msg_proto_rawDescGZIP(), []int{2} +} + +func (x *IdRes) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +type IdReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IdReq) Reset() { + *x = IdReq{} + mi := &file_base_v1_msg_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IdReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IdReq) ProtoMessage() {} + +func (x *IdReq) ProtoReflect() protoreflect.Message { + mi := &file_base_v1_msg_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IdReq.ProtoReflect.Descriptor instead. +func (*IdReq) Descriptor() ([]byte, []int) { + return file_base_v1_msg_proto_rawDescGZIP(), []int{3} +} + +func (x *IdReq) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +type YesNoRes struct { + state protoimpl.MessageState `protogen:"open.v1"` + Yes bool `protobuf:"varint,1,opt,name=yes,proto3" json:"yes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *YesNoRes) Reset() { + *x = YesNoRes{} + mi := &file_base_v1_msg_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *YesNoRes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*YesNoRes) ProtoMessage() {} + +func (x *YesNoRes) ProtoReflect() protoreflect.Message { + mi := &file_base_v1_msg_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use YesNoRes.ProtoReflect.Descriptor instead. +func (*YesNoRes) Descriptor() ([]byte, []int) { + return file_base_v1_msg_proto_rawDescGZIP(), []int{4} +} + +func (x *YesNoRes) GetYes() bool { + if x != nil { + return x.Yes + } + return false +} + +type PaginationRespSample struct { + state protoimpl.MessageState `protogen:"open.v1"` + PageNo uint32 `protobuf:"varint,2,opt,name=page_no,json=pageNo,proto3" json:"page_no,omitempty"` + PageSize uint32 `protobuf:"varint,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + TotalCount uint32 `protobuf:"varint,4,opt,name=total_count,json=totalCount,proto3" json:"total_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PaginationRespSample) Reset() { + *x = PaginationRespSample{} + mi := &file_base_v1_msg_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PaginationRespSample) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PaginationRespSample) ProtoMessage() {} + +func (x *PaginationRespSample) ProtoReflect() protoreflect.Message { + mi := &file_base_v1_msg_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PaginationRespSample.ProtoReflect.Descriptor instead. +func (*PaginationRespSample) Descriptor() ([]byte, []int) { + return file_base_v1_msg_proto_rawDescGZIP(), []int{5} +} + +func (x *PaginationRespSample) GetPageNo() uint32 { + if x != nil { + return x.PageNo + } + return 0 +} + +func (x *PaginationRespSample) GetPageSize() uint32 { + if x != nil { + return x.PageSize + } + return 0 +} + +func (x *PaginationRespSample) GetTotalCount() uint32 { + if x != nil { + return x.TotalCount + } + return 0 +} + +var File_base_v1_msg_proto protoreflect.FileDescriptor + +const file_base_v1_msg_proto_rawDesc = "" + + "\n" + + "\x11base/v1/msg.proto\x12\abase.v1\"\a\n" + + "\x05Empty\"%\n" + + "\tStatusRes\x12\x18\n" + + "\asuccess\x18\x01 \x01(\bR\asuccess\"\x17\n" + + "\x05IdRes\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x03R\x02id\"\x17\n" + + "\x05IdReq\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x03R\x02id\"\x1c\n" + + "\bYesNoRes\x12\x10\n" + + "\x03yes\x18\x01 \x01(\bR\x03yes\"m\n" + + "\x14PaginationRespSample\x12\x17\n" + + "\apage_no\x18\x02 \x01(\rR\x06pageNo\x12\x1b\n" + + "\tpage_size\x18\x03 \x01(\rR\bpageSize\x12\x1f\n" + + "\vtotal_count\x18\x04 \x01(\rR\n" + + "totalCountBk\n" + + "\vcom.base.v1B\bMsgProtoP\x01Z\x15gl/gen/base/v1;basev1\xa2\x02\x03BXX\xaa\x02\aBase.V1\xca\x02\aBase\\V1\xe2\x02\x13Base\\V1\\GPBMetadata\xea\x02\bBase::V1b\x06proto3" + +var ( + file_base_v1_msg_proto_rawDescOnce sync.Once + file_base_v1_msg_proto_rawDescData []byte +) + +func file_base_v1_msg_proto_rawDescGZIP() []byte { + file_base_v1_msg_proto_rawDescOnce.Do(func() { + file_base_v1_msg_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_base_v1_msg_proto_rawDesc), len(file_base_v1_msg_proto_rawDesc))) + }) + return file_base_v1_msg_proto_rawDescData +} + +var file_base_v1_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_base_v1_msg_proto_goTypes = []any{ + (*Empty)(nil), // 0: base.v1.Empty + (*StatusRes)(nil), // 1: base.v1.StatusRes + (*IdRes)(nil), // 2: base.v1.IdRes + (*IdReq)(nil), // 3: base.v1.IdReq + (*YesNoRes)(nil), // 4: base.v1.YesNoRes + (*PaginationRespSample)(nil), // 5: base.v1.PaginationRespSample +} +var file_base_v1_msg_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_base_v1_msg_proto_init() } +func file_base_v1_msg_proto_init() { + if File_base_v1_msg_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_base_v1_msg_proto_rawDesc), len(file_base_v1_msg_proto_rawDesc)), + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_base_v1_msg_proto_goTypes, + DependencyIndexes: file_base_v1_msg_proto_depIdxs, + MessageInfos: file_base_v1_msg_proto_msgTypes, + }.Build() + File_base_v1_msg_proto = out.File + file_base_v1_msg_proto_goTypes = nil + file_base_v1_msg_proto_depIdxs = nil +} diff --git a/gen/ledger/v1/msg.pb.go b/gen/ledger/v1/msg.pb.go new file mode 100644 index 0000000..0afadbc --- /dev/null +++ b/gen/ledger/v1/msg.pb.go @@ -0,0 +1,2297 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.6 +// protoc (unknown) +// source: ledger/v1/msg.proto + +package ledgerv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type AccountClass int32 + +const ( + AccountClass_ACCOUNT_CLASS_UNSPECIFIED AccountClass = 0 + AccountClass_ACCOUNT_CLASS_USER_AVAILABLE AccountClass = 1 + AccountClass_ACCOUNT_CLASS_USER_FROZEN AccountClass = 2 + AccountClass_ACCOUNT_CLASS_EXTERNAL_BLOCKCHAIN AccountClass = 3 + AccountClass_ACCOUNT_CLASS_TREASURY AccountClass = 4 + AccountClass_ACCOUNT_CLASS_MARKET_CLEARING AccountClass = 5 + AccountClass_ACCOUNT_CLASS_IPG_CLEARING AccountClass = 6 + AccountClass_ACCOUNT_CLASS_COMMISSION_REVENUE AccountClass = 7 +) + +// Enum value maps for AccountClass. +var ( + AccountClass_name = map[int32]string{ + 0: "ACCOUNT_CLASS_UNSPECIFIED", + 1: "ACCOUNT_CLASS_USER_AVAILABLE", + 2: "ACCOUNT_CLASS_USER_FROZEN", + 3: "ACCOUNT_CLASS_EXTERNAL_BLOCKCHAIN", + 4: "ACCOUNT_CLASS_TREASURY", + 5: "ACCOUNT_CLASS_MARKET_CLEARING", + 6: "ACCOUNT_CLASS_IPG_CLEARING", + 7: "ACCOUNT_CLASS_COMMISSION_REVENUE", + } + AccountClass_value = map[string]int32{ + "ACCOUNT_CLASS_UNSPECIFIED": 0, + "ACCOUNT_CLASS_USER_AVAILABLE": 1, + "ACCOUNT_CLASS_USER_FROZEN": 2, + "ACCOUNT_CLASS_EXTERNAL_BLOCKCHAIN": 3, + "ACCOUNT_CLASS_TREASURY": 4, + "ACCOUNT_CLASS_MARKET_CLEARING": 5, + "ACCOUNT_CLASS_IPG_CLEARING": 6, + "ACCOUNT_CLASS_COMMISSION_REVENUE": 7, + } +) + +func (x AccountClass) Enum() *AccountClass { + p := new(AccountClass) + *p = x + return p +} + +func (x AccountClass) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (AccountClass) Descriptor() protoreflect.EnumDescriptor { + return file_ledger_v1_msg_proto_enumTypes[0].Descriptor() +} + +func (AccountClass) Type() protoreflect.EnumType { + return &file_ledger_v1_msg_proto_enumTypes[0] +} + +func (x AccountClass) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use AccountClass.Descriptor instead. +func (AccountClass) EnumDescriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{0} +} + +type TransactionState int32 + +const ( + TransactionState_TRANSACTION_STATE_UNSPECIFIED TransactionState = 0 + TransactionState_TRANSACTION_STATE_CREATED TransactionState = 1 + TransactionState_TRANSACTION_STATE_PENDING_TRANSACTION TransactionState = 2 + TransactionState_TRANSACTION_STATE_PENDING_ADMIN TransactionState = 3 + TransactionState_TRANSACTION_STATE_SUCCESSFUL TransactionState = 4 + TransactionState_TRANSACTION_STATE_FAILED TransactionState = 5 + TransactionState_TRANSACTION_STATE_SUSPENDED TransactionState = 6 +) + +// Enum value maps for TransactionState. +var ( + TransactionState_name = map[int32]string{ + 0: "TRANSACTION_STATE_UNSPECIFIED", + 1: "TRANSACTION_STATE_CREATED", + 2: "TRANSACTION_STATE_PENDING_TRANSACTION", + 3: "TRANSACTION_STATE_PENDING_ADMIN", + 4: "TRANSACTION_STATE_SUCCESSFUL", + 5: "TRANSACTION_STATE_FAILED", + 6: "TRANSACTION_STATE_SUSPENDED", + } + TransactionState_value = map[string]int32{ + "TRANSACTION_STATE_UNSPECIFIED": 0, + "TRANSACTION_STATE_CREATED": 1, + "TRANSACTION_STATE_PENDING_TRANSACTION": 2, + "TRANSACTION_STATE_PENDING_ADMIN": 3, + "TRANSACTION_STATE_SUCCESSFUL": 4, + "TRANSACTION_STATE_FAILED": 5, + "TRANSACTION_STATE_SUSPENDED": 6, + } +) + +func (x TransactionState) Enum() *TransactionState { + p := new(TransactionState) + *p = x + return p +} + +func (x TransactionState) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TransactionState) Descriptor() protoreflect.EnumDescriptor { + return file_ledger_v1_msg_proto_enumTypes[1].Descriptor() +} + +func (TransactionState) Type() protoreflect.EnumType { + return &file_ledger_v1_msg_proto_enumTypes[1] +} + +func (x TransactionState) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TransactionState.Descriptor instead. +func (TransactionState) EnumDescriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{1} +} + +type AccountReference struct { + state protoimpl.MessageState `protogen:"open.v1"` + AccountClass AccountClass `protobuf:"varint,1,opt,name=account_class,json=accountClass,proto3,enum=ledger.v1.AccountClass" json:"account_class,omitempty"` + OwnerType string `protobuf:"bytes,2,opt,name=owner_type,json=ownerType,proto3" json:"owner_type,omitempty"` + OwnerId string `protobuf:"bytes,3,opt,name=owner_id,json=ownerId,proto3" json:"owner_id,omitempty"` + AssetId int64 `protobuf:"varint,4,opt,name=asset_id,json=assetId,proto3" json:"asset_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AccountReference) Reset() { + *x = AccountReference{} + mi := &file_ledger_v1_msg_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AccountReference) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AccountReference) ProtoMessage() {} + +func (x *AccountReference) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AccountReference.ProtoReflect.Descriptor instead. +func (*AccountReference) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{0} +} + +func (x *AccountReference) GetAccountClass() AccountClass { + if x != nil { + return x.AccountClass + } + return AccountClass_ACCOUNT_CLASS_UNSPECIFIED +} + +func (x *AccountReference) GetOwnerType() string { + if x != nil { + return x.OwnerType + } + return "" +} + +func (x *AccountReference) GetOwnerId() string { + if x != nil { + return x.OwnerId + } + return "" +} + +func (x *AccountReference) GetAssetId() int64 { + if x != nil { + return x.AssetId + } + return 0 +} + +type JournalEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + LineNumber uint32 `protobuf:"varint,1,opt,name=line_number,json=lineNumber,proto3" json:"line_number,omitempty"` + Account *AccountReference `protobuf:"bytes,2,opt,name=account,proto3" json:"account,omitempty"` + // Canonical base-10 decimal. Positive is credit and negative is debit. + Amount string `protobuf:"bytes,3,opt,name=amount,proto3" json:"amount,omitempty"` + Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *JournalEntry) Reset() { + *x = JournalEntry{} + mi := &file_ledger_v1_msg_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *JournalEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*JournalEntry) ProtoMessage() {} + +func (x *JournalEntry) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use JournalEntry.ProtoReflect.Descriptor instead. +func (*JournalEntry) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{1} +} + +func (x *JournalEntry) GetLineNumber() uint32 { + if x != nil { + return x.LineNumber + } + return 0 +} + +func (x *JournalEntry) GetAccount() *AccountReference { + if x != nil { + return x.Account + } + return nil +} + +func (x *JournalEntry) GetAmount() string { + if x != nil { + return x.Amount + } + return "" +} + +func (x *JournalEntry) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +type BlockchainReference struct { + state protoimpl.MessageState `protogen:"open.v1"` + Network string `protobuf:"bytes,1,opt,name=network,proto3" json:"network,omitempty"` + TransactionHash string `protobuf:"bytes,2,opt,name=transaction_hash,json=transactionHash,proto3" json:"transaction_hash,omitempty"` + LedgerSequence string `protobuf:"bytes,3,opt,name=ledger_sequence,json=ledgerSequence,proto3" json:"ledger_sequence,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BlockchainReference) Reset() { + *x = BlockchainReference{} + mi := &file_ledger_v1_msg_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BlockchainReference) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BlockchainReference) ProtoMessage() {} + +func (x *BlockchainReference) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BlockchainReference.ProtoReflect.Descriptor instead. +func (*BlockchainReference) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{2} +} + +func (x *BlockchainReference) GetNetwork() string { + if x != nil { + return x.Network + } + return "" +} + +func (x *BlockchainReference) GetTransactionHash() string { + if x != nil { + return x.TransactionHash + } + return "" +} + +func (x *BlockchainReference) GetLedgerSequence() string { + if x != nil { + return x.LedgerSequence + } + return "" +} + +type Journal struct { + state protoimpl.MessageState `protogen:"open.v1"` + JournalId string `protobuf:"bytes,1,opt,name=journal_id,json=journalId,proto3" json:"journal_id,omitempty"` + SourceService string `protobuf:"bytes,2,opt,name=source_service,json=sourceService,proto3" json:"source_service,omitempty"` + IdempotencyKey string `protobuf:"bytes,3,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` + SourceTransactionId string `protobuf:"bytes,4,opt,name=source_transaction_id,json=sourceTransactionId,proto3" json:"source_transaction_id,omitempty"` + TrackingCode string `protobuf:"bytes,5,opt,name=tracking_code,json=trackingCode,proto3" json:"tracking_code,omitempty"` + EffectKind string `protobuf:"bytes,6,opt,name=effect_kind,json=effectKind,proto3" json:"effect_kind,omitempty"` + EventVersion uint32 `protobuf:"varint,7,opt,name=event_version,json=eventVersion,proto3" json:"event_version,omitempty"` + Entries []*JournalEntry `protobuf:"bytes,8,rep,name=entries,proto3" json:"entries,omitempty"` + ReversalOfJournalId *string `protobuf:"bytes,9,opt,name=reversal_of_journal_id,json=reversalOfJournalId,proto3,oneof" json:"reversal_of_journal_id,omitempty"` + OccurredAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=occurred_at,json=occurredAt,proto3" json:"occurred_at,omitempty"` + RecordedAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=recorded_at,json=recordedAt,proto3" json:"recorded_at,omitempty"` + CorrelationId string `protobuf:"bytes,12,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` + ActorId string `protobuf:"bytes,13,opt,name=actor_id,json=actorId,proto3" json:"actor_id,omitempty"` + Blockchain *BlockchainReference `protobuf:"bytes,14,opt,name=blockchain,proto3" json:"blockchain,omitempty"` + Metadata map[string]string `protobuf:"bytes,15,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + PayloadHash string `protobuf:"bytes,16,opt,name=payload_hash,json=payloadHash,proto3" json:"payload_hash,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Journal) Reset() { + *x = Journal{} + mi := &file_ledger_v1_msg_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Journal) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Journal) ProtoMessage() {} + +func (x *Journal) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Journal.ProtoReflect.Descriptor instead. +func (*Journal) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{3} +} + +func (x *Journal) GetJournalId() string { + if x != nil { + return x.JournalId + } + return "" +} + +func (x *Journal) GetSourceService() string { + if x != nil { + return x.SourceService + } + return "" +} + +func (x *Journal) GetIdempotencyKey() string { + if x != nil { + return x.IdempotencyKey + } + return "" +} + +func (x *Journal) GetSourceTransactionId() string { + if x != nil { + return x.SourceTransactionId + } + return "" +} + +func (x *Journal) GetTrackingCode() string { + if x != nil { + return x.TrackingCode + } + return "" +} + +func (x *Journal) GetEffectKind() string { + if x != nil { + return x.EffectKind + } + return "" +} + +func (x *Journal) GetEventVersion() uint32 { + if x != nil { + return x.EventVersion + } + return 0 +} + +func (x *Journal) GetEntries() []*JournalEntry { + if x != nil { + return x.Entries + } + return nil +} + +func (x *Journal) GetReversalOfJournalId() string { + if x != nil && x.ReversalOfJournalId != nil { + return *x.ReversalOfJournalId + } + return "" +} + +func (x *Journal) GetOccurredAt() *timestamppb.Timestamp { + if x != nil { + return x.OccurredAt + } + return nil +} + +func (x *Journal) GetRecordedAt() *timestamppb.Timestamp { + if x != nil { + return x.RecordedAt + } + return nil +} + +func (x *Journal) GetCorrelationId() string { + if x != nil { + return x.CorrelationId + } + return "" +} + +func (x *Journal) GetActorId() string { + if x != nil { + return x.ActorId + } + return "" +} + +func (x *Journal) GetBlockchain() *BlockchainReference { + if x != nil { + return x.Blockchain + } + return nil +} + +func (x *Journal) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *Journal) GetPayloadHash() string { + if x != nil { + return x.PayloadHash + } + return "" +} + +type AppendJournalRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SourceService string `protobuf:"bytes,1,opt,name=source_service,json=sourceService,proto3" json:"source_service,omitempty"` + IdempotencyKey string `protobuf:"bytes,2,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` + SourceTransactionId string `protobuf:"bytes,3,opt,name=source_transaction_id,json=sourceTransactionId,proto3" json:"source_transaction_id,omitempty"` + TrackingCode string `protobuf:"bytes,4,opt,name=tracking_code,json=trackingCode,proto3" json:"tracking_code,omitempty"` + EffectKind string `protobuf:"bytes,5,opt,name=effect_kind,json=effectKind,proto3" json:"effect_kind,omitempty"` + EventVersion uint32 `protobuf:"varint,6,opt,name=event_version,json=eventVersion,proto3" json:"event_version,omitempty"` + Entries []*JournalEntry `protobuf:"bytes,7,rep,name=entries,proto3" json:"entries,omitempty"` + ReversalOfJournalId *string `protobuf:"bytes,8,opt,name=reversal_of_journal_id,json=reversalOfJournalId,proto3,oneof" json:"reversal_of_journal_id,omitempty"` + OccurredAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=occurred_at,json=occurredAt,proto3" json:"occurred_at,omitempty"` + CorrelationId string `protobuf:"bytes,10,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` + ActorId string `protobuf:"bytes,11,opt,name=actor_id,json=actorId,proto3" json:"actor_id,omitempty"` + Blockchain *BlockchainReference `protobuf:"bytes,12,opt,name=blockchain,proto3" json:"blockchain,omitempty"` + Metadata map[string]string `protobuf:"bytes,13,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AppendJournalRequest) Reset() { + *x = AppendJournalRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AppendJournalRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AppendJournalRequest) ProtoMessage() {} + +func (x *AppendJournalRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AppendJournalRequest.ProtoReflect.Descriptor instead. +func (*AppendJournalRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{4} +} + +func (x *AppendJournalRequest) GetSourceService() string { + if x != nil { + return x.SourceService + } + return "" +} + +func (x *AppendJournalRequest) GetIdempotencyKey() string { + if x != nil { + return x.IdempotencyKey + } + return "" +} + +func (x *AppendJournalRequest) GetSourceTransactionId() string { + if x != nil { + return x.SourceTransactionId + } + return "" +} + +func (x *AppendJournalRequest) GetTrackingCode() string { + if x != nil { + return x.TrackingCode + } + return "" +} + +func (x *AppendJournalRequest) GetEffectKind() string { + if x != nil { + return x.EffectKind + } + return "" +} + +func (x *AppendJournalRequest) GetEventVersion() uint32 { + if x != nil { + return x.EventVersion + } + return 0 +} + +func (x *AppendJournalRequest) GetEntries() []*JournalEntry { + if x != nil { + return x.Entries + } + return nil +} + +func (x *AppendJournalRequest) GetReversalOfJournalId() string { + if x != nil && x.ReversalOfJournalId != nil { + return *x.ReversalOfJournalId + } + return "" +} + +func (x *AppendJournalRequest) GetOccurredAt() *timestamppb.Timestamp { + if x != nil { + return x.OccurredAt + } + return nil +} + +func (x *AppendJournalRequest) GetCorrelationId() string { + if x != nil { + return x.CorrelationId + } + return "" +} + +func (x *AppendJournalRequest) GetActorId() string { + if x != nil { + return x.ActorId + } + return "" +} + +func (x *AppendJournalRequest) GetBlockchain() *BlockchainReference { + if x != nil { + return x.Blockchain + } + return nil +} + +func (x *AppendJournalRequest) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + +type AppendJournalResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Journal *Journal `protobuf:"bytes,1,opt,name=journal,proto3" json:"journal,omitempty"` + AlreadyExisted bool `protobuf:"varint,2,opt,name=already_existed,json=alreadyExisted,proto3" json:"already_existed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AppendJournalResponse) Reset() { + *x = AppendJournalResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AppendJournalResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AppendJournalResponse) ProtoMessage() {} + +func (x *AppendJournalResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AppendJournalResponse.ProtoReflect.Descriptor instead. +func (*AppendJournalResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{5} +} + +func (x *AppendJournalResponse) GetJournal() *Journal { + if x != nil { + return x.Journal + } + return nil +} + +func (x *AppendJournalResponse) GetAlreadyExisted() bool { + if x != nil { + return x.AlreadyExisted + } + return false +} + +type AppendTransactionEventRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SourceService string `protobuf:"bytes,1,opt,name=source_service,json=sourceService,proto3" json:"source_service,omitempty"` + IdempotencyKey string `protobuf:"bytes,2,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` + SourceTransactionId string `protobuf:"bytes,3,opt,name=source_transaction_id,json=sourceTransactionId,proto3" json:"source_transaction_id,omitempty"` + TrackingCode string `protobuf:"bytes,4,opt,name=tracking_code,json=trackingCode,proto3" json:"tracking_code,omitempty"` + EventVersion uint32 `protobuf:"varint,5,opt,name=event_version,json=eventVersion,proto3" json:"event_version,omitempty"` + State TransactionState `protobuf:"varint,6,opt,name=state,proto3,enum=ledger.v1.TransactionState" json:"state,omitempty"` + ErrorCode string `protobuf:"bytes,7,opt,name=error_code,json=errorCode,proto3" json:"error_code,omitempty"` + ErrorMessage string `protobuf:"bytes,8,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` + OccurredAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=occurred_at,json=occurredAt,proto3" json:"occurred_at,omitempty"` + CorrelationId string `protobuf:"bytes,10,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` + ActorId string `protobuf:"bytes,11,opt,name=actor_id,json=actorId,proto3" json:"actor_id,omitempty"` + Blockchain *BlockchainReference `protobuf:"bytes,12,opt,name=blockchain,proto3" json:"blockchain,omitempty"` + Metadata map[string]string `protobuf:"bytes,13,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AppendTransactionEventRequest) Reset() { + *x = AppendTransactionEventRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AppendTransactionEventRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AppendTransactionEventRequest) ProtoMessage() {} + +func (x *AppendTransactionEventRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AppendTransactionEventRequest.ProtoReflect.Descriptor instead. +func (*AppendTransactionEventRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{6} +} + +func (x *AppendTransactionEventRequest) GetSourceService() string { + if x != nil { + return x.SourceService + } + return "" +} + +func (x *AppendTransactionEventRequest) GetIdempotencyKey() string { + if x != nil { + return x.IdempotencyKey + } + return "" +} + +func (x *AppendTransactionEventRequest) GetSourceTransactionId() string { + if x != nil { + return x.SourceTransactionId + } + return "" +} + +func (x *AppendTransactionEventRequest) GetTrackingCode() string { + if x != nil { + return x.TrackingCode + } + return "" +} + +func (x *AppendTransactionEventRequest) GetEventVersion() uint32 { + if x != nil { + return x.EventVersion + } + return 0 +} + +func (x *AppendTransactionEventRequest) GetState() TransactionState { + if x != nil { + return x.State + } + return TransactionState_TRANSACTION_STATE_UNSPECIFIED +} + +func (x *AppendTransactionEventRequest) GetErrorCode() string { + if x != nil { + return x.ErrorCode + } + return "" +} + +func (x *AppendTransactionEventRequest) GetErrorMessage() string { + if x != nil { + return x.ErrorMessage + } + return "" +} + +func (x *AppendTransactionEventRequest) GetOccurredAt() *timestamppb.Timestamp { + if x != nil { + return x.OccurredAt + } + return nil +} + +func (x *AppendTransactionEventRequest) GetCorrelationId() string { + if x != nil { + return x.CorrelationId + } + return "" +} + +func (x *AppendTransactionEventRequest) GetActorId() string { + if x != nil { + return x.ActorId + } + return "" +} + +func (x *AppendTransactionEventRequest) GetBlockchain() *BlockchainReference { + if x != nil { + return x.Blockchain + } + return nil +} + +func (x *AppendTransactionEventRequest) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + +type TransactionEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + EventId string `protobuf:"bytes,1,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` + Event *AppendTransactionEventRequest `protobuf:"bytes,2,opt,name=event,proto3" json:"event,omitempty"` + RecordedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=recorded_at,json=recordedAt,proto3" json:"recorded_at,omitempty"` + PayloadHash string `protobuf:"bytes,4,opt,name=payload_hash,json=payloadHash,proto3" json:"payload_hash,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TransactionEvent) Reset() { + *x = TransactionEvent{} + mi := &file_ledger_v1_msg_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TransactionEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TransactionEvent) ProtoMessage() {} + +func (x *TransactionEvent) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TransactionEvent.ProtoReflect.Descriptor instead. +func (*TransactionEvent) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{7} +} + +func (x *TransactionEvent) GetEventId() string { + if x != nil { + return x.EventId + } + return "" +} + +func (x *TransactionEvent) GetEvent() *AppendTransactionEventRequest { + if x != nil { + return x.Event + } + return nil +} + +func (x *TransactionEvent) GetRecordedAt() *timestamppb.Timestamp { + if x != nil { + return x.RecordedAt + } + return nil +} + +func (x *TransactionEvent) GetPayloadHash() string { + if x != nil { + return x.PayloadHash + } + return "" +} + +type AppendTransactionEventResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Event *TransactionEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` + AlreadyExisted bool `protobuf:"varint,2,opt,name=already_existed,json=alreadyExisted,proto3" json:"already_existed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AppendTransactionEventResponse) Reset() { + *x = AppendTransactionEventResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AppendTransactionEventResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AppendTransactionEventResponse) ProtoMessage() {} + +func (x *AppendTransactionEventResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AppendTransactionEventResponse.ProtoReflect.Descriptor instead. +func (*AppendTransactionEventResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{8} +} + +func (x *AppendTransactionEventResponse) GetEvent() *TransactionEvent { + if x != nil { + return x.Event + } + return nil +} + +func (x *AppendTransactionEventResponse) GetAlreadyExisted() bool { + if x != nil { + return x.AlreadyExisted + } + return false +} + +type GetJournalRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Lookup: + // + // *GetJournalRequest_JournalId + // *GetJournalRequest_IdempotencyKey + Lookup isGetJournalRequest_Lookup `protobuf_oneof:"lookup"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetJournalRequest) Reset() { + *x = GetJournalRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetJournalRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetJournalRequest) ProtoMessage() {} + +func (x *GetJournalRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetJournalRequest.ProtoReflect.Descriptor instead. +func (*GetJournalRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{9} +} + +func (x *GetJournalRequest) GetLookup() isGetJournalRequest_Lookup { + if x != nil { + return x.Lookup + } + return nil +} + +func (x *GetJournalRequest) GetJournalId() string { + if x != nil { + if x, ok := x.Lookup.(*GetJournalRequest_JournalId); ok { + return x.JournalId + } + } + return "" +} + +func (x *GetJournalRequest) GetIdempotencyKey() string { + if x != nil { + if x, ok := x.Lookup.(*GetJournalRequest_IdempotencyKey); ok { + return x.IdempotencyKey + } + } + return "" +} + +type isGetJournalRequest_Lookup interface { + isGetJournalRequest_Lookup() +} + +type GetJournalRequest_JournalId struct { + JournalId string `protobuf:"bytes,1,opt,name=journal_id,json=journalId,proto3,oneof"` +} + +type GetJournalRequest_IdempotencyKey struct { + IdempotencyKey string `protobuf:"bytes,2,opt,name=idempotency_key,json=idempotencyKey,proto3,oneof"` +} + +func (*GetJournalRequest_JournalId) isGetJournalRequest_Lookup() {} + +func (*GetJournalRequest_IdempotencyKey) isGetJournalRequest_Lookup() {} + +type ListEntriesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Account *AccountReference `protobuf:"bytes,1,opt,name=account,proto3,oneof" json:"account,omitempty"` + AssetId *int64 `protobuf:"varint,2,opt,name=asset_id,json=assetId,proto3,oneof" json:"asset_id,omitempty"` + RecordedFrom *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=recorded_from,json=recordedFrom,proto3" json:"recorded_from,omitempty"` + RecordedTo *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=recorded_to,json=recordedTo,proto3" json:"recorded_to,omitempty"` + PageSize uint32 `protobuf:"varint,5,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + PageToken string `protobuf:"bytes,6,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListEntriesRequest) Reset() { + *x = ListEntriesRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListEntriesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListEntriesRequest) ProtoMessage() {} + +func (x *ListEntriesRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListEntriesRequest.ProtoReflect.Descriptor instead. +func (*ListEntriesRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{10} +} + +func (x *ListEntriesRequest) GetAccount() *AccountReference { + if x != nil { + return x.Account + } + return nil +} + +func (x *ListEntriesRequest) GetAssetId() int64 { + if x != nil && x.AssetId != nil { + return *x.AssetId + } + return 0 +} + +func (x *ListEntriesRequest) GetRecordedFrom() *timestamppb.Timestamp { + if x != nil { + return x.RecordedFrom + } + return nil +} + +func (x *ListEntriesRequest) GetRecordedTo() *timestamppb.Timestamp { + if x != nil { + return x.RecordedTo + } + return nil +} + +func (x *ListEntriesRequest) GetPageSize() uint32 { + if x != nil { + return x.PageSize + } + return 0 +} + +func (x *ListEntriesRequest) GetPageToken() string { + if x != nil { + return x.PageToken + } + return "" +} + +type ListEntriesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Journals []*Journal `protobuf:"bytes,1,rep,name=journals,proto3" json:"journals,omitempty"` + NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListEntriesResponse) Reset() { + *x = ListEntriesResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListEntriesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListEntriesResponse) ProtoMessage() {} + +func (x *ListEntriesResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListEntriesResponse.ProtoReflect.Descriptor instead. +func (*ListEntriesResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{11} +} + +func (x *ListEntriesResponse) GetJournals() []*Journal { + if x != nil { + return x.Journals + } + return nil +} + +func (x *ListEntriesResponse) GetNextPageToken() string { + if x != nil { + return x.NextPageToken + } + return "" +} + +type GetBalanceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Account *AccountReference `protobuf:"bytes,1,opt,name=account,proto3" json:"account,omitempty"` + AsOf *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=as_of,json=asOf,proto3" json:"as_of,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetBalanceRequest) Reset() { + *x = GetBalanceRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetBalanceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetBalanceRequest) ProtoMessage() {} + +func (x *GetBalanceRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetBalanceRequest.ProtoReflect.Descriptor instead. +func (*GetBalanceRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{12} +} + +func (x *GetBalanceRequest) GetAccount() *AccountReference { + if x != nil { + return x.Account + } + return nil +} + +func (x *GetBalanceRequest) GetAsOf() *timestamppb.Timestamp { + if x != nil { + return x.AsOf + } + return nil +} + +type GetBalanceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Account *AccountReference `protobuf:"bytes,1,opt,name=account,proto3" json:"account,omitempty"` + // Canonical base-10 decimal reconstructed from immutable entries. + Balance string `protobuf:"bytes,2,opt,name=balance,proto3" json:"balance,omitempty"` + AsOf *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=as_of,json=asOf,proto3" json:"as_of,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetBalanceResponse) Reset() { + *x = GetBalanceResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetBalanceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetBalanceResponse) ProtoMessage() {} + +func (x *GetBalanceResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetBalanceResponse.ProtoReflect.Descriptor instead. +func (*GetBalanceResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{13} +} + +func (x *GetBalanceResponse) GetAccount() *AccountReference { + if x != nil { + return x.Account + } + return nil +} + +func (x *GetBalanceResponse) GetBalance() string { + if x != nil { + return x.Balance + } + return "" +} + +func (x *GetBalanceResponse) GetAsOf() *timestamppb.Timestamp { + if x != nil { + return x.AsOf + } + return nil +} + +type ReplayJournalsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Each item is independently atomic and uses normal append idempotency. + Journals []*AppendJournalRequest `protobuf:"bytes,1,rep,name=journals,proto3" json:"journals,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReplayJournalsRequest) Reset() { + *x = ReplayJournalsRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReplayJournalsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReplayJournalsRequest) ProtoMessage() {} + +func (x *ReplayJournalsRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReplayJournalsRequest.ProtoReflect.Descriptor instead. +func (*ReplayJournalsRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{14} +} + +func (x *ReplayJournalsRequest) GetJournals() []*AppendJournalRequest { + if x != nil { + return x.Journals + } + return nil +} + +type ReplayJournalResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + IdempotencyKey string `protobuf:"bytes,1,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` + Journal *Journal `protobuf:"bytes,2,opt,name=journal,proto3" json:"journal,omitempty"` + AlreadyExisted bool `protobuf:"varint,3,opt,name=already_existed,json=alreadyExisted,proto3" json:"already_existed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReplayJournalResult) Reset() { + *x = ReplayJournalResult{} + mi := &file_ledger_v1_msg_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReplayJournalResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReplayJournalResult) ProtoMessage() {} + +func (x *ReplayJournalResult) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReplayJournalResult.ProtoReflect.Descriptor instead. +func (*ReplayJournalResult) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{15} +} + +func (x *ReplayJournalResult) GetIdempotencyKey() string { + if x != nil { + return x.IdempotencyKey + } + return "" +} + +func (x *ReplayJournalResult) GetJournal() *Journal { + if x != nil { + return x.Journal + } + return nil +} + +func (x *ReplayJournalResult) GetAlreadyExisted() bool { + if x != nil { + return x.AlreadyExisted + } + return false +} + +type ReplayJournalsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Results []*ReplayJournalResult `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReplayJournalsResponse) Reset() { + *x = ReplayJournalsResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReplayJournalsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReplayJournalsResponse) ProtoMessage() {} + +func (x *ReplayJournalsResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReplayJournalsResponse.ProtoReflect.Descriptor instead. +func (*ReplayJournalsResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{16} +} + +func (x *ReplayJournalsResponse) GetResults() []*ReplayJournalResult { + if x != nil { + return x.Results + } + return nil +} + +type HealthResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Serving bool `protobuf:"varint,1,opt,name=serving,proto3" json:"serving,omitempty"` + DatabaseReady bool `protobuf:"varint,2,opt,name=database_ready,json=databaseReady,proto3" json:"database_ready,omitempty"` + SettlementEnabled bool `protobuf:"varint,3,opt,name=settlement_enabled,json=settlementEnabled,proto3" json:"settlement_enabled,omitempty"` + SettlementReady bool `protobuf:"varint,4,opt,name=settlement_ready,json=settlementReady,proto3" json:"settlement_ready,omitempty"` + SettlementPending int64 `protobuf:"varint,5,opt,name=settlement_pending,json=settlementPending,proto3" json:"settlement_pending,omitempty"` + SettlementRetryable int64 `protobuf:"varint,6,opt,name=settlement_retryable,json=settlementRetryable,proto3" json:"settlement_retryable,omitempty"` + SettlementManualReview int64 `protobuf:"varint,7,opt,name=settlement_manual_review,json=settlementManualReview,proto3" json:"settlement_manual_review,omitempty"` + OldestPendingSeconds int64 `protobuf:"varint,8,opt,name=oldest_pending_seconds,json=oldestPendingSeconds,proto3" json:"oldest_pending_seconds,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthResponse) Reset() { + *x = HealthResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthResponse) ProtoMessage() {} + +func (x *HealthResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead. +func (*HealthResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{17} +} + +func (x *HealthResponse) GetServing() bool { + if x != nil { + return x.Serving + } + return false +} + +func (x *HealthResponse) GetDatabaseReady() bool { + if x != nil { + return x.DatabaseReady + } + return false +} + +func (x *HealthResponse) GetSettlementEnabled() bool { + if x != nil { + return x.SettlementEnabled + } + return false +} + +func (x *HealthResponse) GetSettlementReady() bool { + if x != nil { + return x.SettlementReady + } + return false +} + +func (x *HealthResponse) GetSettlementPending() int64 { + if x != nil { + return x.SettlementPending + } + return 0 +} + +func (x *HealthResponse) GetSettlementRetryable() int64 { + if x != nil { + return x.SettlementRetryable + } + return 0 +} + +func (x *HealthResponse) GetSettlementManualReview() int64 { + if x != nil { + return x.SettlementManualReview + } + return 0 +} + +func (x *HealthResponse) GetOldestPendingSeconds() int64 { + if x != nil { + return x.OldestPendingSeconds + } + return 0 +} + +type EnqueueSettlementRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SourceService string `protobuf:"bytes,1,opt,name=source_service,json=sourceService,proto3" json:"source_service,omitempty"` + SourceTransactionId string `protobuf:"bytes,2,opt,name=source_transaction_id,json=sourceTransactionId,proto3" json:"source_transaction_id,omitempty"` + IdempotencyKey string `protobuf:"bytes,3,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` + Network string `protobuf:"bytes,4,opt,name=network,proto3" json:"network,omitempty"` + // Signed transaction XDR. GL persists and submits this payload durably. + SignedTransactionXdr string `protobuf:"bytes,5,opt,name=signed_transaction_xdr,json=signedTransactionXdr,proto3" json:"signed_transaction_xdr,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EnqueueSettlementRequest) Reset() { + *x = EnqueueSettlementRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EnqueueSettlementRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EnqueueSettlementRequest) ProtoMessage() {} + +func (x *EnqueueSettlementRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EnqueueSettlementRequest.ProtoReflect.Descriptor instead. +func (*EnqueueSettlementRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{18} +} + +func (x *EnqueueSettlementRequest) GetSourceService() string { + if x != nil { + return x.SourceService + } + return "" +} + +func (x *EnqueueSettlementRequest) GetSourceTransactionId() string { + if x != nil { + return x.SourceTransactionId + } + return "" +} + +func (x *EnqueueSettlementRequest) GetIdempotencyKey() string { + if x != nil { + return x.IdempotencyKey + } + return "" +} + +func (x *EnqueueSettlementRequest) GetNetwork() string { + if x != nil { + return x.Network + } + return "" +} + +func (x *EnqueueSettlementRequest) GetSignedTransactionXdr() string { + if x != nil { + return x.SignedTransactionXdr + } + return "" +} + +type EnqueueSettlementResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + SettlementId string `protobuf:"bytes,1,opt,name=settlement_id,json=settlementId,proto3" json:"settlement_id,omitempty"` + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + AlreadyExisted bool `protobuf:"varint,3,opt,name=already_existed,json=alreadyExisted,proto3" json:"already_existed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EnqueueSettlementResponse) Reset() { + *x = EnqueueSettlementResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EnqueueSettlementResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EnqueueSettlementResponse) ProtoMessage() {} + +func (x *EnqueueSettlementResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EnqueueSettlementResponse.ProtoReflect.Descriptor instead. +func (*EnqueueSettlementResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{19} +} + +func (x *EnqueueSettlementResponse) GetSettlementId() string { + if x != nil { + return x.SettlementId + } + return "" +} + +func (x *EnqueueSettlementResponse) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *EnqueueSettlementResponse) GetAlreadyExisted() bool { + if x != nil { + return x.AlreadyExisted + } + return false +} + +type GetSettlementRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Lookup: + // + // *GetSettlementRequest_SettlementId + // *GetSettlementRequest_IdempotencyKey + Lookup isGetSettlementRequest_Lookup `protobuf_oneof:"lookup"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSettlementRequest) Reset() { + *x = GetSettlementRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSettlementRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSettlementRequest) ProtoMessage() {} + +func (x *GetSettlementRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSettlementRequest.ProtoReflect.Descriptor instead. +func (*GetSettlementRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{20} +} + +func (x *GetSettlementRequest) GetLookup() isGetSettlementRequest_Lookup { + if x != nil { + return x.Lookup + } + return nil +} + +func (x *GetSettlementRequest) GetSettlementId() string { + if x != nil { + if x, ok := x.Lookup.(*GetSettlementRequest_SettlementId); ok { + return x.SettlementId + } + } + return "" +} + +func (x *GetSettlementRequest) GetIdempotencyKey() string { + if x != nil { + if x, ok := x.Lookup.(*GetSettlementRequest_IdempotencyKey); ok { + return x.IdempotencyKey + } + } + return "" +} + +type isGetSettlementRequest_Lookup interface { + isGetSettlementRequest_Lookup() +} + +type GetSettlementRequest_SettlementId struct { + SettlementId string `protobuf:"bytes,1,opt,name=settlement_id,json=settlementId,proto3,oneof"` +} + +type GetSettlementRequest_IdempotencyKey struct { + IdempotencyKey string `protobuf:"bytes,2,opt,name=idempotency_key,json=idempotencyKey,proto3,oneof"` +} + +func (*GetSettlementRequest_SettlementId) isGetSettlementRequest_Lookup() {} + +func (*GetSettlementRequest_IdempotencyKey) isGetSettlementRequest_Lookup() {} + +type Settlement struct { + state protoimpl.MessageState `protogen:"open.v1"` + SettlementId string `protobuf:"bytes,1,opt,name=settlement_id,json=settlementId,proto3" json:"settlement_id,omitempty"` + SourceService string `protobuf:"bytes,2,opt,name=source_service,json=sourceService,proto3" json:"source_service,omitempty"` + SourceTransactionId string `protobuf:"bytes,3,opt,name=source_transaction_id,json=sourceTransactionId,proto3" json:"source_transaction_id,omitempty"` + IdempotencyKey string `protobuf:"bytes,4,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` + Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"` + Network string `protobuf:"bytes,6,opt,name=network,proto3" json:"network,omitempty"` + TransactionHash string `protobuf:"bytes,7,opt,name=transaction_hash,json=transactionHash,proto3" json:"transaction_hash,omitempty"` + Attempts uint32 `protobuf:"varint,8,opt,name=attempts,proto3" json:"attempts,omitempty"` + LastError string `protobuf:"bytes,9,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Settlement) Reset() { + *x = Settlement{} + mi := &file_ledger_v1_msg_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Settlement) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Settlement) ProtoMessage() {} + +func (x *Settlement) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Settlement.ProtoReflect.Descriptor instead. +func (*Settlement) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{21} +} + +func (x *Settlement) GetSettlementId() string { + if x != nil { + return x.SettlementId + } + return "" +} + +func (x *Settlement) GetSourceService() string { + if x != nil { + return x.SourceService + } + return "" +} + +func (x *Settlement) GetSourceTransactionId() string { + if x != nil { + return x.SourceTransactionId + } + return "" +} + +func (x *Settlement) GetIdempotencyKey() string { + if x != nil { + return x.IdempotencyKey + } + return "" +} + +func (x *Settlement) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *Settlement) GetNetwork() string { + if x != nil { + return x.Network + } + return "" +} + +func (x *Settlement) GetTransactionHash() string { + if x != nil { + return x.TransactionHash + } + return "" +} + +func (x *Settlement) GetAttempts() uint32 { + if x != nil { + return x.Attempts + } + return 0 +} + +func (x *Settlement) GetLastError() string { + if x != nil { + return x.LastError + } + return "" +} + +type RetrySettlementRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SettlementId string `protobuf:"bytes,1,opt,name=settlement_id,json=settlementId,proto3" json:"settlement_id,omitempty"` + ActorId string `protobuf:"bytes,2,opt,name=actor_id,json=actorId,proto3" json:"actor_id,omitempty"` + Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RetrySettlementRequest) Reset() { + *x = RetrySettlementRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RetrySettlementRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RetrySettlementRequest) ProtoMessage() {} + +func (x *RetrySettlementRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RetrySettlementRequest.ProtoReflect.Descriptor instead. +func (*RetrySettlementRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{22} +} + +func (x *RetrySettlementRequest) GetSettlementId() string { + if x != nil { + return x.SettlementId + } + return "" +} + +func (x *RetrySettlementRequest) GetActorId() string { + if x != nil { + return x.ActorId + } + return "" +} + +func (x *RetrySettlementRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type RetrySettlementResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Settlement *Settlement `protobuf:"bytes,1,opt,name=settlement,proto3" json:"settlement,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RetrySettlementResponse) Reset() { + *x = RetrySettlementResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RetrySettlementResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RetrySettlementResponse) ProtoMessage() {} + +func (x *RetrySettlementResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RetrySettlementResponse.ProtoReflect.Descriptor instead. +func (*RetrySettlementResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{23} +} + +func (x *RetrySettlementResponse) GetSettlement() *Settlement { + if x != nil { + return x.Settlement + } + return nil +} + +var File_ledger_v1_msg_proto protoreflect.FileDescriptor + +const file_ledger_v1_msg_proto_rawDesc = "" + + "\n" + + "\x13ledger/v1/msg.proto\x12\tledger.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xa5\x01\n" + + "\x10AccountReference\x12<\n" + + "\raccount_class\x18\x01 \x01(\x0e2\x17.ledger.v1.AccountClassR\faccountClass\x12\x1d\n" + + "\n" + + "owner_type\x18\x02 \x01(\tR\townerType\x12\x19\n" + + "\bowner_id\x18\x03 \x01(\tR\aownerId\x12\x19\n" + + "\basset_id\x18\x04 \x01(\x03R\aassetId\"\xa0\x01\n" + + "\fJournalEntry\x12\x1f\n" + + "\vline_number\x18\x01 \x01(\rR\n" + + "lineNumber\x125\n" + + "\aaccount\x18\x02 \x01(\v2\x1b.ledger.v1.AccountReferenceR\aaccount\x12\x16\n" + + "\x06amount\x18\x03 \x01(\tR\x06amount\x12 \n" + + "\vdescription\x18\x04 \x01(\tR\vdescription\"\x83\x01\n" + + "\x13BlockchainReference\x12\x18\n" + + "\anetwork\x18\x01 \x01(\tR\anetwork\x12)\n" + + "\x10transaction_hash\x18\x02 \x01(\tR\x0ftransactionHash\x12'\n" + + "\x0fledger_sequence\x18\x03 \x01(\tR\x0eledgerSequence\"\xb9\x06\n" + + "\aJournal\x12\x1d\n" + + "\n" + + "journal_id\x18\x01 \x01(\tR\tjournalId\x12%\n" + + "\x0esource_service\x18\x02 \x01(\tR\rsourceService\x12'\n" + + "\x0fidempotency_key\x18\x03 \x01(\tR\x0eidempotencyKey\x122\n" + + "\x15source_transaction_id\x18\x04 \x01(\tR\x13sourceTransactionId\x12#\n" + + "\rtracking_code\x18\x05 \x01(\tR\ftrackingCode\x12\x1f\n" + + "\veffect_kind\x18\x06 \x01(\tR\n" + + "effectKind\x12#\n" + + "\revent_version\x18\a \x01(\rR\feventVersion\x121\n" + + "\aentries\x18\b \x03(\v2\x17.ledger.v1.JournalEntryR\aentries\x128\n" + + "\x16reversal_of_journal_id\x18\t \x01(\tH\x00R\x13reversalOfJournalId\x88\x01\x01\x12;\n" + + "\voccurred_at\x18\n" + + " \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "occurredAt\x12;\n" + + "\vrecorded_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "recordedAt\x12%\n" + + "\x0ecorrelation_id\x18\f \x01(\tR\rcorrelationId\x12\x19\n" + + "\bactor_id\x18\r \x01(\tR\aactorId\x12>\n" + + "\n" + + "blockchain\x18\x0e \x01(\v2\x1e.ledger.v1.BlockchainReferenceR\n" + + "blockchain\x12<\n" + + "\bmetadata\x18\x0f \x03(\v2 .ledger.v1.Journal.MetadataEntryR\bmetadata\x12!\n" + + "\fpayload_hash\x18\x10 \x01(\tR\vpayloadHash\x1a;\n" + + "\rMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x19\n" + + "\x17_reversal_of_journal_id\"\xd4\x05\n" + + "\x14AppendJournalRequest\x12%\n" + + "\x0esource_service\x18\x01 \x01(\tR\rsourceService\x12'\n" + + "\x0fidempotency_key\x18\x02 \x01(\tR\x0eidempotencyKey\x122\n" + + "\x15source_transaction_id\x18\x03 \x01(\tR\x13sourceTransactionId\x12#\n" + + "\rtracking_code\x18\x04 \x01(\tR\ftrackingCode\x12\x1f\n" + + "\veffect_kind\x18\x05 \x01(\tR\n" + + "effectKind\x12#\n" + + "\revent_version\x18\x06 \x01(\rR\feventVersion\x121\n" + + "\aentries\x18\a \x03(\v2\x17.ledger.v1.JournalEntryR\aentries\x128\n" + + "\x16reversal_of_journal_id\x18\b \x01(\tH\x00R\x13reversalOfJournalId\x88\x01\x01\x12;\n" + + "\voccurred_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "occurredAt\x12%\n" + + "\x0ecorrelation_id\x18\n" + + " \x01(\tR\rcorrelationId\x12\x19\n" + + "\bactor_id\x18\v \x01(\tR\aactorId\x12>\n" + + "\n" + + "blockchain\x18\f \x01(\v2\x1e.ledger.v1.BlockchainReferenceR\n" + + "blockchain\x12I\n" + + "\bmetadata\x18\r \x03(\v2-.ledger.v1.AppendJournalRequest.MetadataEntryR\bmetadata\x1a;\n" + + "\rMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x19\n" + + "\x17_reversal_of_journal_id\"n\n" + + "\x15AppendJournalResponse\x12,\n" + + "\ajournal\x18\x01 \x01(\v2\x12.ledger.v1.JournalR\ajournal\x12'\n" + + "\x0falready_existed\x18\x02 \x01(\bR\x0ealreadyExisted\"\xb4\x05\n" + + "\x1dAppendTransactionEventRequest\x12%\n" + + "\x0esource_service\x18\x01 \x01(\tR\rsourceService\x12'\n" + + "\x0fidempotency_key\x18\x02 \x01(\tR\x0eidempotencyKey\x122\n" + + "\x15source_transaction_id\x18\x03 \x01(\tR\x13sourceTransactionId\x12#\n" + + "\rtracking_code\x18\x04 \x01(\tR\ftrackingCode\x12#\n" + + "\revent_version\x18\x05 \x01(\rR\feventVersion\x121\n" + + "\x05state\x18\x06 \x01(\x0e2\x1b.ledger.v1.TransactionStateR\x05state\x12\x1d\n" + + "\n" + + "error_code\x18\a \x01(\tR\terrorCode\x12#\n" + + "\rerror_message\x18\b \x01(\tR\ferrorMessage\x12;\n" + + "\voccurred_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "occurredAt\x12%\n" + + "\x0ecorrelation_id\x18\n" + + " \x01(\tR\rcorrelationId\x12\x19\n" + + "\bactor_id\x18\v \x01(\tR\aactorId\x12>\n" + + "\n" + + "blockchain\x18\f \x01(\v2\x1e.ledger.v1.BlockchainReferenceR\n" + + "blockchain\x12R\n" + + "\bmetadata\x18\r \x03(\v26.ledger.v1.AppendTransactionEventRequest.MetadataEntryR\bmetadata\x1a;\n" + + "\rMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xcd\x01\n" + + "\x10TransactionEvent\x12\x19\n" + + "\bevent_id\x18\x01 \x01(\tR\aeventId\x12>\n" + + "\x05event\x18\x02 \x01(\v2(.ledger.v1.AppendTransactionEventRequestR\x05event\x12;\n" + + "\vrecorded_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "recordedAt\x12!\n" + + "\fpayload_hash\x18\x04 \x01(\tR\vpayloadHash\"|\n" + + "\x1eAppendTransactionEventResponse\x121\n" + + "\x05event\x18\x01 \x01(\v2\x1b.ledger.v1.TransactionEventR\x05event\x12'\n" + + "\x0falready_existed\x18\x02 \x01(\bR\x0ealreadyExisted\"i\n" + + "\x11GetJournalRequest\x12\x1f\n" + + "\n" + + "journal_id\x18\x01 \x01(\tH\x00R\tjournalId\x12)\n" + + "\x0fidempotency_key\x18\x02 \x01(\tH\x00R\x0eidempotencyKeyB\b\n" + + "\x06lookup\"\xc3\x02\n" + + "\x12ListEntriesRequest\x12:\n" + + "\aaccount\x18\x01 \x01(\v2\x1b.ledger.v1.AccountReferenceH\x00R\aaccount\x88\x01\x01\x12\x1e\n" + + "\basset_id\x18\x02 \x01(\x03H\x01R\aassetId\x88\x01\x01\x12?\n" + + "\rrecorded_from\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\frecordedFrom\x12;\n" + + "\vrecorded_to\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "recordedTo\x12\x1b\n" + + "\tpage_size\x18\x05 \x01(\rR\bpageSize\x12\x1d\n" + + "\n" + + "page_token\x18\x06 \x01(\tR\tpageTokenB\n" + + "\n" + + "\b_accountB\v\n" + + "\t_asset_id\"m\n" + + "\x13ListEntriesResponse\x12.\n" + + "\bjournals\x18\x01 \x03(\v2\x12.ledger.v1.JournalR\bjournals\x12&\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"{\n" + + "\x11GetBalanceRequest\x125\n" + + "\aaccount\x18\x01 \x01(\v2\x1b.ledger.v1.AccountReferenceR\aaccount\x12/\n" + + "\x05as_of\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x04asOf\"\x96\x01\n" + + "\x12GetBalanceResponse\x125\n" + + "\aaccount\x18\x01 \x01(\v2\x1b.ledger.v1.AccountReferenceR\aaccount\x12\x18\n" + + "\abalance\x18\x02 \x01(\tR\abalance\x12/\n" + + "\x05as_of\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x04asOf\"T\n" + + "\x15ReplayJournalsRequest\x12;\n" + + "\bjournals\x18\x01 \x03(\v2\x1f.ledger.v1.AppendJournalRequestR\bjournals\"\x95\x01\n" + + "\x13ReplayJournalResult\x12'\n" + + "\x0fidempotency_key\x18\x01 \x01(\tR\x0eidempotencyKey\x12,\n" + + "\ajournal\x18\x02 \x01(\v2\x12.ledger.v1.JournalR\ajournal\x12'\n" + + "\x0falready_existed\x18\x03 \x01(\bR\x0ealreadyExisted\"R\n" + + "\x16ReplayJournalsResponse\x128\n" + + "\aresults\x18\x01 \x03(\v2\x1e.ledger.v1.ReplayJournalResultR\aresults\"\xfd\x02\n" + + "\x0eHealthResponse\x12\x18\n" + + "\aserving\x18\x01 \x01(\bR\aserving\x12%\n" + + "\x0edatabase_ready\x18\x02 \x01(\bR\rdatabaseReady\x12-\n" + + "\x12settlement_enabled\x18\x03 \x01(\bR\x11settlementEnabled\x12)\n" + + "\x10settlement_ready\x18\x04 \x01(\bR\x0fsettlementReady\x12-\n" + + "\x12settlement_pending\x18\x05 \x01(\x03R\x11settlementPending\x121\n" + + "\x14settlement_retryable\x18\x06 \x01(\x03R\x13settlementRetryable\x128\n" + + "\x18settlement_manual_review\x18\a \x01(\x03R\x16settlementManualReview\x124\n" + + "\x16oldest_pending_seconds\x18\b \x01(\x03R\x14oldestPendingSeconds\"\xee\x01\n" + + "\x18EnqueueSettlementRequest\x12%\n" + + "\x0esource_service\x18\x01 \x01(\tR\rsourceService\x122\n" + + "\x15source_transaction_id\x18\x02 \x01(\tR\x13sourceTransactionId\x12'\n" + + "\x0fidempotency_key\x18\x03 \x01(\tR\x0eidempotencyKey\x12\x18\n" + + "\anetwork\x18\x04 \x01(\tR\anetwork\x124\n" + + "\x16signed_transaction_xdr\x18\x05 \x01(\tR\x14signedTransactionXdr\"\x81\x01\n" + + "\x19EnqueueSettlementResponse\x12#\n" + + "\rsettlement_id\x18\x01 \x01(\tR\fsettlementId\x12\x16\n" + + "\x06status\x18\x02 \x01(\tR\x06status\x12'\n" + + "\x0falready_existed\x18\x03 \x01(\bR\x0ealreadyExisted\"r\n" + + "\x14GetSettlementRequest\x12%\n" + + "\rsettlement_id\x18\x01 \x01(\tH\x00R\fsettlementId\x12)\n" + + "\x0fidempotency_key\x18\x02 \x01(\tH\x00R\x0eidempotencyKeyB\b\n" + + "\x06lookup\"\xcd\x02\n" + + "\n" + + "Settlement\x12#\n" + + "\rsettlement_id\x18\x01 \x01(\tR\fsettlementId\x12%\n" + + "\x0esource_service\x18\x02 \x01(\tR\rsourceService\x122\n" + + "\x15source_transaction_id\x18\x03 \x01(\tR\x13sourceTransactionId\x12'\n" + + "\x0fidempotency_key\x18\x04 \x01(\tR\x0eidempotencyKey\x12\x16\n" + + "\x06status\x18\x05 \x01(\tR\x06status\x12\x18\n" + + "\anetwork\x18\x06 \x01(\tR\anetwork\x12)\n" + + "\x10transaction_hash\x18\a \x01(\tR\x0ftransactionHash\x12\x1a\n" + + "\battempts\x18\b \x01(\rR\battempts\x12\x1d\n" + + "\n" + + "last_error\x18\t \x01(\tR\tlastError\"p\n" + + "\x16RetrySettlementRequest\x12#\n" + + "\rsettlement_id\x18\x01 \x01(\tR\fsettlementId\x12\x19\n" + + "\bactor_id\x18\x02 \x01(\tR\aactorId\x12\x16\n" + + "\x06reason\x18\x03 \x01(\tR\x06reason\"P\n" + + "\x17RetrySettlementResponse\x125\n" + + "\n" + + "settlement\x18\x01 \x01(\v2\x15.ledger.v1.SettlementR\n" + + "settlement*\x9a\x02\n" + + "\fAccountClass\x12\x1d\n" + + "\x19ACCOUNT_CLASS_UNSPECIFIED\x10\x00\x12 \n" + + "\x1cACCOUNT_CLASS_USER_AVAILABLE\x10\x01\x12\x1d\n" + + "\x19ACCOUNT_CLASS_USER_FROZEN\x10\x02\x12%\n" + + "!ACCOUNT_CLASS_EXTERNAL_BLOCKCHAIN\x10\x03\x12\x1a\n" + + "\x16ACCOUNT_CLASS_TREASURY\x10\x04\x12!\n" + + "\x1dACCOUNT_CLASS_MARKET_CLEARING\x10\x05\x12\x1e\n" + + "\x1aACCOUNT_CLASS_IPG_CLEARING\x10\x06\x12$\n" + + " ACCOUNT_CLASS_COMMISSION_REVENUE\x10\a*\x85\x02\n" + + "\x10TransactionState\x12!\n" + + "\x1dTRANSACTION_STATE_UNSPECIFIED\x10\x00\x12\x1d\n" + + "\x19TRANSACTION_STATE_CREATED\x10\x01\x12)\n" + + "%TRANSACTION_STATE_PENDING_TRANSACTION\x10\x02\x12#\n" + + "\x1fTRANSACTION_STATE_PENDING_ADMIN\x10\x03\x12 \n" + + "\x1cTRANSACTION_STATE_SUCCESSFUL\x10\x04\x12\x1c\n" + + "\x18TRANSACTION_STATE_FAILED\x10\x05\x12\x1f\n" + + "\x1bTRANSACTION_STATE_SUSPENDED\x10\x06By\n" + + "\rcom.ledger.v1B\bMsgProtoP\x01Z\x19gl/gen/ledger/v1;ledgerv1\xa2\x02\x03LXX\xaa\x02\tLedger.V1\xca\x02\tLedger\\V1\xe2\x02\x15Ledger\\V1\\GPBMetadata\xea\x02\n" + + "Ledger::V1b\x06proto3" + +var ( + file_ledger_v1_msg_proto_rawDescOnce sync.Once + file_ledger_v1_msg_proto_rawDescData []byte +) + +func file_ledger_v1_msg_proto_rawDescGZIP() []byte { + file_ledger_v1_msg_proto_rawDescOnce.Do(func() { + file_ledger_v1_msg_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ledger_v1_msg_proto_rawDesc), len(file_ledger_v1_msg_proto_rawDesc))) + }) + return file_ledger_v1_msg_proto_rawDescData +} + +var file_ledger_v1_msg_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_ledger_v1_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 27) +var file_ledger_v1_msg_proto_goTypes = []any{ + (AccountClass)(0), // 0: ledger.v1.AccountClass + (TransactionState)(0), // 1: ledger.v1.TransactionState + (*AccountReference)(nil), // 2: ledger.v1.AccountReference + (*JournalEntry)(nil), // 3: ledger.v1.JournalEntry + (*BlockchainReference)(nil), // 4: ledger.v1.BlockchainReference + (*Journal)(nil), // 5: ledger.v1.Journal + (*AppendJournalRequest)(nil), // 6: ledger.v1.AppendJournalRequest + (*AppendJournalResponse)(nil), // 7: ledger.v1.AppendJournalResponse + (*AppendTransactionEventRequest)(nil), // 8: ledger.v1.AppendTransactionEventRequest + (*TransactionEvent)(nil), // 9: ledger.v1.TransactionEvent + (*AppendTransactionEventResponse)(nil), // 10: ledger.v1.AppendTransactionEventResponse + (*GetJournalRequest)(nil), // 11: ledger.v1.GetJournalRequest + (*ListEntriesRequest)(nil), // 12: ledger.v1.ListEntriesRequest + (*ListEntriesResponse)(nil), // 13: ledger.v1.ListEntriesResponse + (*GetBalanceRequest)(nil), // 14: ledger.v1.GetBalanceRequest + (*GetBalanceResponse)(nil), // 15: ledger.v1.GetBalanceResponse + (*ReplayJournalsRequest)(nil), // 16: ledger.v1.ReplayJournalsRequest + (*ReplayJournalResult)(nil), // 17: ledger.v1.ReplayJournalResult + (*ReplayJournalsResponse)(nil), // 18: ledger.v1.ReplayJournalsResponse + (*HealthResponse)(nil), // 19: ledger.v1.HealthResponse + (*EnqueueSettlementRequest)(nil), // 20: ledger.v1.EnqueueSettlementRequest + (*EnqueueSettlementResponse)(nil), // 21: ledger.v1.EnqueueSettlementResponse + (*GetSettlementRequest)(nil), // 22: ledger.v1.GetSettlementRequest + (*Settlement)(nil), // 23: ledger.v1.Settlement + (*RetrySettlementRequest)(nil), // 24: ledger.v1.RetrySettlementRequest + (*RetrySettlementResponse)(nil), // 25: ledger.v1.RetrySettlementResponse + nil, // 26: ledger.v1.Journal.MetadataEntry + nil, // 27: ledger.v1.AppendJournalRequest.MetadataEntry + nil, // 28: ledger.v1.AppendTransactionEventRequest.MetadataEntry + (*timestamppb.Timestamp)(nil), // 29: google.protobuf.Timestamp +} +var file_ledger_v1_msg_proto_depIdxs = []int32{ + 0, // 0: ledger.v1.AccountReference.account_class:type_name -> ledger.v1.AccountClass + 2, // 1: ledger.v1.JournalEntry.account:type_name -> ledger.v1.AccountReference + 3, // 2: ledger.v1.Journal.entries:type_name -> ledger.v1.JournalEntry + 29, // 3: ledger.v1.Journal.occurred_at:type_name -> google.protobuf.Timestamp + 29, // 4: ledger.v1.Journal.recorded_at:type_name -> google.protobuf.Timestamp + 4, // 5: ledger.v1.Journal.blockchain:type_name -> ledger.v1.BlockchainReference + 26, // 6: ledger.v1.Journal.metadata:type_name -> ledger.v1.Journal.MetadataEntry + 3, // 7: ledger.v1.AppendJournalRequest.entries:type_name -> ledger.v1.JournalEntry + 29, // 8: ledger.v1.AppendJournalRequest.occurred_at:type_name -> google.protobuf.Timestamp + 4, // 9: ledger.v1.AppendJournalRequest.blockchain:type_name -> ledger.v1.BlockchainReference + 27, // 10: ledger.v1.AppendJournalRequest.metadata:type_name -> ledger.v1.AppendJournalRequest.MetadataEntry + 5, // 11: ledger.v1.AppendJournalResponse.journal:type_name -> ledger.v1.Journal + 1, // 12: ledger.v1.AppendTransactionEventRequest.state:type_name -> ledger.v1.TransactionState + 29, // 13: ledger.v1.AppendTransactionEventRequest.occurred_at:type_name -> google.protobuf.Timestamp + 4, // 14: ledger.v1.AppendTransactionEventRequest.blockchain:type_name -> ledger.v1.BlockchainReference + 28, // 15: ledger.v1.AppendTransactionEventRequest.metadata:type_name -> ledger.v1.AppendTransactionEventRequest.MetadataEntry + 8, // 16: ledger.v1.TransactionEvent.event:type_name -> ledger.v1.AppendTransactionEventRequest + 29, // 17: ledger.v1.TransactionEvent.recorded_at:type_name -> google.protobuf.Timestamp + 9, // 18: ledger.v1.AppendTransactionEventResponse.event:type_name -> ledger.v1.TransactionEvent + 2, // 19: ledger.v1.ListEntriesRequest.account:type_name -> ledger.v1.AccountReference + 29, // 20: ledger.v1.ListEntriesRequest.recorded_from:type_name -> google.protobuf.Timestamp + 29, // 21: ledger.v1.ListEntriesRequest.recorded_to:type_name -> google.protobuf.Timestamp + 5, // 22: ledger.v1.ListEntriesResponse.journals:type_name -> ledger.v1.Journal + 2, // 23: ledger.v1.GetBalanceRequest.account:type_name -> ledger.v1.AccountReference + 29, // 24: ledger.v1.GetBalanceRequest.as_of:type_name -> google.protobuf.Timestamp + 2, // 25: ledger.v1.GetBalanceResponse.account:type_name -> ledger.v1.AccountReference + 29, // 26: ledger.v1.GetBalanceResponse.as_of:type_name -> google.protobuf.Timestamp + 6, // 27: ledger.v1.ReplayJournalsRequest.journals:type_name -> ledger.v1.AppendJournalRequest + 5, // 28: ledger.v1.ReplayJournalResult.journal:type_name -> ledger.v1.Journal + 17, // 29: ledger.v1.ReplayJournalsResponse.results:type_name -> ledger.v1.ReplayJournalResult + 23, // 30: ledger.v1.RetrySettlementResponse.settlement:type_name -> ledger.v1.Settlement + 31, // [31:31] is the sub-list for method output_type + 31, // [31:31] is the sub-list for method input_type + 31, // [31:31] is the sub-list for extension type_name + 31, // [31:31] is the sub-list for extension extendee + 0, // [0:31] is the sub-list for field type_name +} + +func init() { file_ledger_v1_msg_proto_init() } +func file_ledger_v1_msg_proto_init() { + if File_ledger_v1_msg_proto != nil { + return + } + file_ledger_v1_msg_proto_msgTypes[3].OneofWrappers = []any{} + file_ledger_v1_msg_proto_msgTypes[4].OneofWrappers = []any{} + file_ledger_v1_msg_proto_msgTypes[9].OneofWrappers = []any{ + (*GetJournalRequest_JournalId)(nil), + (*GetJournalRequest_IdempotencyKey)(nil), + } + file_ledger_v1_msg_proto_msgTypes[10].OneofWrappers = []any{} + file_ledger_v1_msg_proto_msgTypes[20].OneofWrappers = []any{ + (*GetSettlementRequest_SettlementId)(nil), + (*GetSettlementRequest_IdempotencyKey)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_ledger_v1_msg_proto_rawDesc), len(file_ledger_v1_msg_proto_rawDesc)), + NumEnums: 2, + NumMessages: 27, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_ledger_v1_msg_proto_goTypes, + DependencyIndexes: file_ledger_v1_msg_proto_depIdxs, + EnumInfos: file_ledger_v1_msg_proto_enumTypes, + MessageInfos: file_ledger_v1_msg_proto_msgTypes, + }.Build() + File_ledger_v1_msg_proto = out.File + file_ledger_v1_msg_proto_goTypes = nil + file_ledger_v1_msg_proto_depIdxs = nil +} diff --git a/gen/ledger/v1/srv.pb.go b/gen/ledger/v1/srv.pb.go new file mode 100644 index 0000000..ed563a9 --- /dev/null +++ b/gen/ledger/v1/srv.pb.go @@ -0,0 +1,117 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.6 +// protoc (unknown) +// source: ledger/v1/srv.proto + +package ledgerv1 + +import ( + v1 "gl/gen/base/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_ledger_v1_srv_proto protoreflect.FileDescriptor + +const file_ledger_v1_srv_proto_rawDesc = "" + + "\n" + + "\x13ledger/v1/srv.proto\x12\tledger.v1\x1a\x11base/v1/msg.proto\x1a\x13ledger/v1/msg.proto2\xc1\x06\n" + + "\x14GeneralLedgerService\x123\n" + + "\x06Health\x12\x0e.base.v1.Empty\x1a\x19.ledger.v1.HealthResponse\x12R\n" + + "\rAppendJournal\x12\x1f.ledger.v1.AppendJournalRequest\x1a .ledger.v1.AppendJournalResponse\x12m\n" + + "\x16AppendTransactionEvent\x12(.ledger.v1.AppendTransactionEventRequest\x1a).ledger.v1.AppendTransactionEventResponse\x12>\n" + + "\n" + + "GetJournal\x12\x1c.ledger.v1.GetJournalRequest\x1a\x12.ledger.v1.Journal\x12L\n" + + "\vListEntries\x12\x1d.ledger.v1.ListEntriesRequest\x1a\x1e.ledger.v1.ListEntriesResponse\x12I\n" + + "\n" + + "GetBalance\x12\x1c.ledger.v1.GetBalanceRequest\x1a\x1d.ledger.v1.GetBalanceResponse\x12U\n" + + "\x0eReplayJournals\x12 .ledger.v1.ReplayJournalsRequest\x1a!.ledger.v1.ReplayJournalsResponse\x12^\n" + + "\x11EnqueueSettlement\x12#.ledger.v1.EnqueueSettlementRequest\x1a$.ledger.v1.EnqueueSettlementResponse\x12G\n" + + "\rGetSettlement\x12\x1f.ledger.v1.GetSettlementRequest\x1a\x15.ledger.v1.Settlement\x12X\n" + + "\x0fRetrySettlement\x12!.ledger.v1.RetrySettlementRequest\x1a\".ledger.v1.RetrySettlementResponseBy\n" + + "\rcom.ledger.v1B\bSrvProtoP\x01Z\x19gl/gen/ledger/v1;ledgerv1\xa2\x02\x03LXX\xaa\x02\tLedger.V1\xca\x02\tLedger\\V1\xe2\x02\x15Ledger\\V1\\GPBMetadata\xea\x02\n" + + "Ledger::V1b\x06proto3" + +var file_ledger_v1_srv_proto_goTypes = []any{ + (*v1.Empty)(nil), // 0: base.v1.Empty + (*AppendJournalRequest)(nil), // 1: ledger.v1.AppendJournalRequest + (*AppendTransactionEventRequest)(nil), // 2: ledger.v1.AppendTransactionEventRequest + (*GetJournalRequest)(nil), // 3: ledger.v1.GetJournalRequest + (*ListEntriesRequest)(nil), // 4: ledger.v1.ListEntriesRequest + (*GetBalanceRequest)(nil), // 5: ledger.v1.GetBalanceRequest + (*ReplayJournalsRequest)(nil), // 6: ledger.v1.ReplayJournalsRequest + (*EnqueueSettlementRequest)(nil), // 7: ledger.v1.EnqueueSettlementRequest + (*GetSettlementRequest)(nil), // 8: ledger.v1.GetSettlementRequest + (*RetrySettlementRequest)(nil), // 9: ledger.v1.RetrySettlementRequest + (*HealthResponse)(nil), // 10: ledger.v1.HealthResponse + (*AppendJournalResponse)(nil), // 11: ledger.v1.AppendJournalResponse + (*AppendTransactionEventResponse)(nil), // 12: ledger.v1.AppendTransactionEventResponse + (*Journal)(nil), // 13: ledger.v1.Journal + (*ListEntriesResponse)(nil), // 14: ledger.v1.ListEntriesResponse + (*GetBalanceResponse)(nil), // 15: ledger.v1.GetBalanceResponse + (*ReplayJournalsResponse)(nil), // 16: ledger.v1.ReplayJournalsResponse + (*EnqueueSettlementResponse)(nil), // 17: ledger.v1.EnqueueSettlementResponse + (*Settlement)(nil), // 18: ledger.v1.Settlement + (*RetrySettlementResponse)(nil), // 19: ledger.v1.RetrySettlementResponse +} +var file_ledger_v1_srv_proto_depIdxs = []int32{ + 0, // 0: ledger.v1.GeneralLedgerService.Health:input_type -> base.v1.Empty + 1, // 1: ledger.v1.GeneralLedgerService.AppendJournal:input_type -> ledger.v1.AppendJournalRequest + 2, // 2: ledger.v1.GeneralLedgerService.AppendTransactionEvent:input_type -> ledger.v1.AppendTransactionEventRequest + 3, // 3: ledger.v1.GeneralLedgerService.GetJournal:input_type -> ledger.v1.GetJournalRequest + 4, // 4: ledger.v1.GeneralLedgerService.ListEntries:input_type -> ledger.v1.ListEntriesRequest + 5, // 5: ledger.v1.GeneralLedgerService.GetBalance:input_type -> ledger.v1.GetBalanceRequest + 6, // 6: ledger.v1.GeneralLedgerService.ReplayJournals:input_type -> ledger.v1.ReplayJournalsRequest + 7, // 7: ledger.v1.GeneralLedgerService.EnqueueSettlement:input_type -> ledger.v1.EnqueueSettlementRequest + 8, // 8: ledger.v1.GeneralLedgerService.GetSettlement:input_type -> ledger.v1.GetSettlementRequest + 9, // 9: ledger.v1.GeneralLedgerService.RetrySettlement:input_type -> ledger.v1.RetrySettlementRequest + 10, // 10: ledger.v1.GeneralLedgerService.Health:output_type -> ledger.v1.HealthResponse + 11, // 11: ledger.v1.GeneralLedgerService.AppendJournal:output_type -> ledger.v1.AppendJournalResponse + 12, // 12: ledger.v1.GeneralLedgerService.AppendTransactionEvent:output_type -> ledger.v1.AppendTransactionEventResponse + 13, // 13: ledger.v1.GeneralLedgerService.GetJournal:output_type -> ledger.v1.Journal + 14, // 14: ledger.v1.GeneralLedgerService.ListEntries:output_type -> ledger.v1.ListEntriesResponse + 15, // 15: ledger.v1.GeneralLedgerService.GetBalance:output_type -> ledger.v1.GetBalanceResponse + 16, // 16: ledger.v1.GeneralLedgerService.ReplayJournals:output_type -> ledger.v1.ReplayJournalsResponse + 17, // 17: ledger.v1.GeneralLedgerService.EnqueueSettlement:output_type -> ledger.v1.EnqueueSettlementResponse + 18, // 18: ledger.v1.GeneralLedgerService.GetSettlement:output_type -> ledger.v1.Settlement + 19, // 19: ledger.v1.GeneralLedgerService.RetrySettlement:output_type -> ledger.v1.RetrySettlementResponse + 10, // [10:20] is the sub-list for method output_type + 0, // [0:10] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_ledger_v1_srv_proto_init() } +func file_ledger_v1_srv_proto_init() { + if File_ledger_v1_srv_proto != nil { + return + } + file_ledger_v1_msg_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_ledger_v1_srv_proto_rawDesc), len(file_ledger_v1_srv_proto_rawDesc)), + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_ledger_v1_srv_proto_goTypes, + DependencyIndexes: file_ledger_v1_srv_proto_depIdxs, + }.Build() + File_ledger_v1_srv_proto = out.File + file_ledger_v1_srv_proto_goTypes = nil + file_ledger_v1_srv_proto_depIdxs = nil +} diff --git a/gen/ledger/v1/srv_grpc.pb.go b/gen/ledger/v1/srv_grpc.pb.go new file mode 100644 index 0000000..13290b2 --- /dev/null +++ b/gen/ledger/v1/srv_grpc.pb.go @@ -0,0 +1,464 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc (unknown) +// source: ledger/v1/srv.proto + +package ledgerv1 + +import ( + context "context" + v1 "gl/gen/base/v1" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + GeneralLedgerService_Health_FullMethodName = "/ledger.v1.GeneralLedgerService/Health" + GeneralLedgerService_AppendJournal_FullMethodName = "/ledger.v1.GeneralLedgerService/AppendJournal" + GeneralLedgerService_AppendTransactionEvent_FullMethodName = "/ledger.v1.GeneralLedgerService/AppendTransactionEvent" + GeneralLedgerService_GetJournal_FullMethodName = "/ledger.v1.GeneralLedgerService/GetJournal" + GeneralLedgerService_ListEntries_FullMethodName = "/ledger.v1.GeneralLedgerService/ListEntries" + GeneralLedgerService_GetBalance_FullMethodName = "/ledger.v1.GeneralLedgerService/GetBalance" + GeneralLedgerService_ReplayJournals_FullMethodName = "/ledger.v1.GeneralLedgerService/ReplayJournals" + GeneralLedgerService_EnqueueSettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/EnqueueSettlement" + GeneralLedgerService_GetSettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/GetSettlement" + GeneralLedgerService_RetrySettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/RetrySettlement" +) + +// GeneralLedgerServiceClient is the client API for GeneralLedgerService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type GeneralLedgerServiceClient interface { + Health(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*HealthResponse, error) + AppendJournal(ctx context.Context, in *AppendJournalRequest, opts ...grpc.CallOption) (*AppendJournalResponse, error) + AppendTransactionEvent(ctx context.Context, in *AppendTransactionEventRequest, opts ...grpc.CallOption) (*AppendTransactionEventResponse, error) + GetJournal(ctx context.Context, in *GetJournalRequest, opts ...grpc.CallOption) (*Journal, error) + ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error) + GetBalance(ctx context.Context, in *GetBalanceRequest, opts ...grpc.CallOption) (*GetBalanceResponse, error) + ReplayJournals(ctx context.Context, in *ReplayJournalsRequest, opts ...grpc.CallOption) (*ReplayJournalsResponse, error) + EnqueueSettlement(ctx context.Context, in *EnqueueSettlementRequest, opts ...grpc.CallOption) (*EnqueueSettlementResponse, error) + GetSettlement(ctx context.Context, in *GetSettlementRequest, opts ...grpc.CallOption) (*Settlement, error) + // Restricted operator command. Requires the configured x-admin-token metadata. + RetrySettlement(ctx context.Context, in *RetrySettlementRequest, opts ...grpc.CallOption) (*RetrySettlementResponse, error) +} + +type generalLedgerServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewGeneralLedgerServiceClient(cc grpc.ClientConnInterface) GeneralLedgerServiceClient { + return &generalLedgerServiceClient{cc} +} + +func (c *generalLedgerServiceClient) Health(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*HealthResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(HealthResponse) + err := c.cc.Invoke(ctx, GeneralLedgerService_Health_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *generalLedgerServiceClient) AppendJournal(ctx context.Context, in *AppendJournalRequest, opts ...grpc.CallOption) (*AppendJournalResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AppendJournalResponse) + err := c.cc.Invoke(ctx, GeneralLedgerService_AppendJournal_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *generalLedgerServiceClient) AppendTransactionEvent(ctx context.Context, in *AppendTransactionEventRequest, opts ...grpc.CallOption) (*AppendTransactionEventResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AppendTransactionEventResponse) + err := c.cc.Invoke(ctx, GeneralLedgerService_AppendTransactionEvent_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *generalLedgerServiceClient) GetJournal(ctx context.Context, in *GetJournalRequest, opts ...grpc.CallOption) (*Journal, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Journal) + err := c.cc.Invoke(ctx, GeneralLedgerService_GetJournal_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *generalLedgerServiceClient) ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListEntriesResponse) + err := c.cc.Invoke(ctx, GeneralLedgerService_ListEntries_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *generalLedgerServiceClient) GetBalance(ctx context.Context, in *GetBalanceRequest, opts ...grpc.CallOption) (*GetBalanceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetBalanceResponse) + err := c.cc.Invoke(ctx, GeneralLedgerService_GetBalance_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *generalLedgerServiceClient) ReplayJournals(ctx context.Context, in *ReplayJournalsRequest, opts ...grpc.CallOption) (*ReplayJournalsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ReplayJournalsResponse) + err := c.cc.Invoke(ctx, GeneralLedgerService_ReplayJournals_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *generalLedgerServiceClient) EnqueueSettlement(ctx context.Context, in *EnqueueSettlementRequest, opts ...grpc.CallOption) (*EnqueueSettlementResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EnqueueSettlementResponse) + err := c.cc.Invoke(ctx, GeneralLedgerService_EnqueueSettlement_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *generalLedgerServiceClient) GetSettlement(ctx context.Context, in *GetSettlementRequest, opts ...grpc.CallOption) (*Settlement, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Settlement) + err := c.cc.Invoke(ctx, GeneralLedgerService_GetSettlement_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *generalLedgerServiceClient) RetrySettlement(ctx context.Context, in *RetrySettlementRequest, opts ...grpc.CallOption) (*RetrySettlementResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RetrySettlementResponse) + err := c.cc.Invoke(ctx, GeneralLedgerService_RetrySettlement_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// GeneralLedgerServiceServer is the server API for GeneralLedgerService service. +// All implementations should embed UnimplementedGeneralLedgerServiceServer +// for forward compatibility. +type GeneralLedgerServiceServer interface { + Health(context.Context, *v1.Empty) (*HealthResponse, error) + AppendJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error) + AppendTransactionEvent(context.Context, *AppendTransactionEventRequest) (*AppendTransactionEventResponse, error) + GetJournal(context.Context, *GetJournalRequest) (*Journal, error) + ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) + GetBalance(context.Context, *GetBalanceRequest) (*GetBalanceResponse, error) + ReplayJournals(context.Context, *ReplayJournalsRequest) (*ReplayJournalsResponse, error) + EnqueueSettlement(context.Context, *EnqueueSettlementRequest) (*EnqueueSettlementResponse, error) + GetSettlement(context.Context, *GetSettlementRequest) (*Settlement, error) + // Restricted operator command. Requires the configured x-admin-token metadata. + RetrySettlement(context.Context, *RetrySettlementRequest) (*RetrySettlementResponse, error) +} + +// UnimplementedGeneralLedgerServiceServer should be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedGeneralLedgerServiceServer struct{} + +func (UnimplementedGeneralLedgerServiceServer) Health(context.Context, *v1.Empty) (*HealthResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Health not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) AppendJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method AppendJournal not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) AppendTransactionEvent(context.Context, *AppendTransactionEventRequest) (*AppendTransactionEventResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method AppendTransactionEvent not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) GetJournal(context.Context, *GetJournalRequest) (*Journal, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetJournal not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListEntries not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) GetBalance(context.Context, *GetBalanceRequest) (*GetBalanceResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetBalance not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) ReplayJournals(context.Context, *ReplayJournalsRequest) (*ReplayJournalsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ReplayJournals not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) EnqueueSettlement(context.Context, *EnqueueSettlementRequest) (*EnqueueSettlementResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method EnqueueSettlement not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) GetSettlement(context.Context, *GetSettlementRequest) (*Settlement, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetSettlement not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) RetrySettlement(context.Context, *RetrySettlementRequest) (*RetrySettlementResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RetrySettlement not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) testEmbeddedByValue() {} + +// UnsafeGeneralLedgerServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to GeneralLedgerServiceServer will +// result in compilation errors. +type UnsafeGeneralLedgerServiceServer interface { + mustEmbedUnimplementedGeneralLedgerServiceServer() +} + +func RegisterGeneralLedgerServiceServer(s grpc.ServiceRegistrar, srv GeneralLedgerServiceServer) { + // If the following call pancis, it indicates UnimplementedGeneralLedgerServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&GeneralLedgerService_ServiceDesc, srv) +} + +func _GeneralLedgerService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(v1.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).Health(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_Health_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).Health(ctx, req.(*v1.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _GeneralLedgerService_AppendJournal_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AppendJournalRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).AppendJournal(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_AppendJournal_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).AppendJournal(ctx, req.(*AppendJournalRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GeneralLedgerService_AppendTransactionEvent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AppendTransactionEventRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).AppendTransactionEvent(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_AppendTransactionEvent_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).AppendTransactionEvent(ctx, req.(*AppendTransactionEventRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GeneralLedgerService_GetJournal_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetJournalRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).GetJournal(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_GetJournal_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).GetJournal(ctx, req.(*GetJournalRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GeneralLedgerService_ListEntries_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListEntriesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).ListEntries(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_ListEntries_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).ListEntries(ctx, req.(*ListEntriesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GeneralLedgerService_GetBalance_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetBalanceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).GetBalance(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_GetBalance_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).GetBalance(ctx, req.(*GetBalanceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GeneralLedgerService_ReplayJournals_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReplayJournalsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).ReplayJournals(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_ReplayJournals_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).ReplayJournals(ctx, req.(*ReplayJournalsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GeneralLedgerService_EnqueueSettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(EnqueueSettlementRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).EnqueueSettlement(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_EnqueueSettlement_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).EnqueueSettlement(ctx, req.(*EnqueueSettlementRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GeneralLedgerService_GetSettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetSettlementRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).GetSettlement(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_GetSettlement_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).GetSettlement(ctx, req.(*GetSettlementRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GeneralLedgerService_RetrySettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RetrySettlementRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).RetrySettlement(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_RetrySettlement_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).RetrySettlement(ctx, req.(*RetrySettlementRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// GeneralLedgerService_ServiceDesc is the grpc.ServiceDesc for GeneralLedgerService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var GeneralLedgerService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "ledger.v1.GeneralLedgerService", + HandlerType: (*GeneralLedgerServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Health", + Handler: _GeneralLedgerService_Health_Handler, + }, + { + MethodName: "AppendJournal", + Handler: _GeneralLedgerService_AppendJournal_Handler, + }, + { + MethodName: "AppendTransactionEvent", + Handler: _GeneralLedgerService_AppendTransactionEvent_Handler, + }, + { + MethodName: "GetJournal", + Handler: _GeneralLedgerService_GetJournal_Handler, + }, + { + MethodName: "ListEntries", + Handler: _GeneralLedgerService_ListEntries_Handler, + }, + { + MethodName: "GetBalance", + Handler: _GeneralLedgerService_GetBalance_Handler, + }, + { + MethodName: "ReplayJournals", + Handler: _GeneralLedgerService_ReplayJournals_Handler, + }, + { + MethodName: "EnqueueSettlement", + Handler: _GeneralLedgerService_EnqueueSettlement_Handler, + }, + { + MethodName: "GetSettlement", + Handler: _GeneralLedgerService_GetSettlement_Handler, + }, + { + MethodName: "RetrySettlement", + Handler: _GeneralLedgerService_RetrySettlement_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "ledger/v1/srv.proto", +} diff --git a/gl.cfg.toml b/gl.cfg.toml new file mode 100644 index 0000000..ae21f10 --- /dev/null +++ b/gl.cfg.toml @@ -0,0 +1,23 @@ +environment = "local" + +[grpc] +host = "0.0.0.0" +port = 8600 +timeout = "10s" + +[db] +host = "127.0.0.1" +port = 5432 +name = "gl_db" +user = "postgres" +password = "" +ssl-mode = "disable" + +[settlement] +enabled = false +endpoint = "" +worker-id = "gl-local" +poll-interval = "5s" +batch-size = 20 +max-attempts = 8 +admin-token = "" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dc3b925 --- /dev/null +++ b/go.mod @@ -0,0 +1,65 @@ +module gl + +go 1.26 + +require ( + github.com/a-h/templ v0.3.1020 + github.com/jackc/pgx/v5 v5.4.3 + github.com/knadh/koanf/parsers/toml v0.1.0 + github.com/knadh/koanf/providers/file v1.2.1 + github.com/knadh/koanf/v2 v2.3.4 + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 + go.opentelemetry.io/otel/sdk v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 + google.golang.org/grpc v1.81.1 + google.golang.org/protobuf v1.36.11 +) + +require ( + github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cli/browser v1.3.0 // indirect + github.com/fatih/color v1.16.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/natefinch/atomic v1.0.1 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.44.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect +) + +tool ( + github.com/a-h/templ + github.com/a-h/templ/cmd/templ +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4822ea7 --- /dev/null +++ b/go.sum @@ -0,0 +1,130 @@ +github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo= +github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ= +github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= +github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= +github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI= +github.com/knadh/koanf/parsers/toml v0.1.0/go.mod h1:yUprhq6eo3GbyVXFFMdbfZSo928ksS+uo0FFqNMnO18= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/v2 v2.3.4 h1:fnynNSDlujWE+v83hAp8wKr/cdoxHLO0629SN+U8Urc= +github.com/knadh/koanf/v2 v2.3.4/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/infrastructure/config/config.go b/infrastructure/config/config.go new file mode 100644 index 0000000..5372718 --- /dev/null +++ b/infrastructure/config/config.go @@ -0,0 +1,192 @@ +// Package config owns GL configuration loading and validation. +package config + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/knadh/koanf/parsers/toml" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/v2" +) + +type Config struct { + Environment string `koanf:"environment"` + GRPC GRPCConfig `koanf:"grpc"` + Database DatabaseConfig `koanf:"db"` + Settlement SettlementConfig `koanf:"settlement"` + Telemetry TelemetryConfig `koanf:"telemetry"` +} + +type DashboardConfig struct { + Environment string `koanf:"environment"` + HTTP HTTPConfig `koanf:"http"` + Database DatabaseConfig `koanf:"db"` + Telemetry TelemetryConfig `koanf:"telemetry"` +} + +type TelemetryConfig struct { + Enabled bool `koanf:"enabled"` + Protocol string `koanf:"protocol"` + Endpoint string `koanf:"endpoint"` + Insecure bool `koanf:"insecure"` + Headers map[string]string `koanf:"headers"` + Sampler string `koanf:"sampler"` + SamplerArg string `koanf:"sampler-arg"` + ExportTimeout time.Duration `koanf:"export-timeout"` + BatchTimeout time.Duration `koanf:"batch-timeout"` + ShutdownTimeout time.Duration `koanf:"shutdown-timeout"` +} + +type GRPCConfig struct { + Host string `koanf:"host"` + Port int `koanf:"port"` + ShutdownTimeout time.Duration `koanf:"timeout"` +} + +type HTTPConfig struct { + Host string `koanf:"host"` + Port int `koanf:"port"` + ReadHeaderTimeout time.Duration `koanf:"read-header-timeout"` + ShutdownTimeout time.Duration `koanf:"shutdown-timeout"` +} + +type DatabaseConfig struct { + Host string `koanf:"host"` + Port int `koanf:"port"` + Name string `koanf:"name"` + User string `koanf:"user"` + Password string `koanf:"password"` + GormLogLevel int `koanf:"gorm-log-level"` + SSLMode string `koanf:"-"` +} + +type SettlementConfig struct { + Enabled bool `koanf:"enabled"` + Endpoint string `koanf:"endpoint"` + WorkerID string `koanf:"worker-id"` + PollInterval time.Duration `koanf:"poll-interval"` + BatchSize int `koanf:"batch-size"` + MaxAttempts int `koanf:"max-attempts"` + AdminToken string `koanf:"admin-token"` +} + +func Load(path string) (*Config, error) { + cfg := &Config{ + Environment: "local", + GRPC: GRPCConfig{ + Host: "0.0.0.0", + Port: 8600, + ShutdownTimeout: 10 * time.Second, + }, + Database: defaultDatabaseConfig(), + Settlement: SettlementConfig{WorkerID: "gl-settlement-1", PollInterval: 5 * time.Second, BatchSize: 20, MaxAttempts: 8}, + } + + if err := load(path, cfg); err != nil { + return nil, err + } + applyEnvironment(&cfg.Database, &cfg.Settlement) + if err := cfg.Validate(); err != nil { + return nil, err + } + return cfg, nil +} + +func LoadDashboard(path string) (*DashboardConfig, error) { + cfg := &DashboardConfig{ + Environment: "local", + HTTP: HTTPConfig{ + Host: "0.0.0.0", + Port: 8080, + ReadHeaderTimeout: 5 * time.Second, + ShutdownTimeout: 10 * time.Second, + }, + Database: defaultDatabaseConfig(), + } + if err := load(path, cfg); err != nil { + return nil, err + } + applyEnvironment(&cfg.Database, nil) + if err := cfg.Validate(); err != nil { + return nil, err + } + return cfg, nil +} + +func applyEnvironment(database *DatabaseConfig, settlement *SettlementConfig) { + if value, ok := os.LookupEnv("DARANO_GL_DB_PASSWORD"); ok { + database.Password = value + } + if settlement == nil { + return + } + if value, ok := os.LookupEnv("DARANO_GL_SETTLEMENT_ENDPOINT"); ok { + settlement.Endpoint = strings.TrimRight(value, "/") + } + if value, ok := os.LookupEnv("DARANO_GL_ADMIN_TOKEN"); ok { + settlement.AdminToken = value + } +} + +func load(path string, target any) error { + k := koanf.New(".") + if err := k.Load(file.Provider(path), toml.Parser()); err != nil { + return fmt.Errorf("load config: %w", err) + } + if err := k.Unmarshal("", target); err != nil { + return fmt.Errorf("decode config: %w", err) + } + return nil +} + +func defaultDatabaseConfig() DatabaseConfig { + return DatabaseConfig{ + Host: "127.0.0.1", + Port: 5432, + Name: "gl_db", + User: "postgres", + SSLMode: "disable", + } +} + +func (c *Config) Validate() error { + if c.GRPC.Host == "" { + return fmt.Errorf("grpc host is required") + } + if c.GRPC.Port < 0 || c.GRPC.Port > 65535 { + return fmt.Errorf("grpc port must be between 0 and 65535") + } + if c.GRPC.ShutdownTimeout <= 0 { + return fmt.Errorf("grpc shutdown timeout must be positive") + } + if c.Environment == "production" && c.Settlement.AdminToken == "" { + return fmt.Errorf("settlement admin token is required in production") + } + return validateDatabase(c.Database) +} + +func (c *DashboardConfig) Validate() error { + if c.HTTP.Host == "" { + return fmt.Errorf("http host is required") + } + if c.HTTP.Port < 0 || c.HTTP.Port > 65535 { + return fmt.Errorf("http port must be between 0 and 65535") + } + if c.HTTP.ReadHeaderTimeout <= 0 || c.HTTP.ShutdownTimeout <= 0 { + return fmt.Errorf("http timeouts must be positive") + } + return validateDatabase(c.Database) +} + +func validateDatabase(database DatabaseConfig) error { + if database.Host == "" || database.Name == "" || database.User == "" { + return fmt.Errorf("database host, name, and user are required") + } + if database.Port < 1 || database.Port > 65535 { + return fmt.Errorf("database port must be between 1 and 65535") + } + return nil +} diff --git a/infrastructure/config/config_test.go b/infrastructure/config/config_test.go new file mode 100644 index 0000000..7959462 --- /dev/null +++ b/infrastructure/config/config_test.go @@ -0,0 +1,109 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestLoadUsesDefaultsAndOverrides(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "gl.toml") + contents := []byte("[grpc]\nport = 0\ntimeout = \"3s\"\n") + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.GRPC.Port != 0 || cfg.GRPC.ShutdownTimeout != 3*time.Second { + t.Fatalf("unexpected grpc config: %+v", cfg.GRPC) + } + if cfg.Database.Name != "gl_db" || cfg.Database.Port != 5432 { + t.Fatalf("unexpected database defaults: %+v", cfg.Database) + } +} + +func TestLoadDashboardUsesHTTPDefaultsAndOverrides(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "dashboard.toml") + contents := []byte("[http]\nport = 0\nshutdown-timeout = \"3s\"\n[db]\nname = \"dashboard_db\"\n") + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := LoadDashboard(path) + if err != nil { + t.Fatal(err) + } + if cfg.HTTP.Port != 0 || cfg.HTTP.ShutdownTimeout != 3*time.Second || cfg.HTTP.ReadHeaderTimeout != 5*time.Second { + t.Fatalf("unexpected http config: %+v", cfg.HTTP) + } + if cfg.Database.Name != "dashboard_db" || cfg.Database.Port != 5432 { + t.Fatalf("unexpected database defaults: %+v", cfg.Database) + } +} + +func TestLoadRejectsInvalidConfiguration(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "gl.toml") + if err := os.WriteFile(path, []byte("[grpc]\ntimeout = \"0s\"\n"), 0o600); err != nil { + t.Fatal(err) + } + + if _, err := Load(path); err == nil { + t.Fatal("expected validation error") + } +} + +func TestLoadReturnsMissingFileError(t *testing.T) { + if _, err := Load(filepath.Join(t.TempDir(), "missing.toml")); err == nil { + t.Fatal("expected missing file error") + } +} + +func TestLoadAppliesSecretEnvironmentOverrides(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "gl.toml") + contents := []byte("environment = \"production\"\n[grpc]\ntimeout = \"3s\"\n[settlement]\nenabled = true\nendpoint = \"https://invalid.example\"\n") + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("DARANO_GL_DB_PASSWORD", "runtime-db-secret") + t.Setenv("DARANO_GL_ADMIN_TOKEN", "runtime-admin-secret") + t.Setenv("DARANO_GL_SETTLEMENT_ENDPOINT", "https://horizon.example/") + + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Database.Password != "runtime-db-secret" { + t.Fatal("database password was not loaded from the environment") + } + if cfg.Settlement.AdminToken != "runtime-admin-secret" { + t.Fatal("admin token was not loaded from the environment") + } + if cfg.Settlement.Endpoint != "https://horizon.example" { + t.Fatalf("unexpected settlement endpoint: %q", cfg.Settlement.Endpoint) + } +} + +func TestLoadDashboardAppliesDatabasePasswordEnvironmentOverride(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "dashboard.toml") + if err := os.WriteFile(path, []byte("[http]\nport = 0\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("DARANO_GL_DB_PASSWORD", "runtime-db-secret") + + cfg, err := LoadDashboard(path) + if err != nil { + t.Fatal(err) + } + if cfg.Database.Password != "runtime-db-secret" { + t.Fatal("database password was not loaded from the environment") + } +} diff --git a/infrastructure/kuknos/submitter.go b/infrastructure/kuknos/submitter.go new file mode 100644 index 0000000..fea33ef --- /dev/null +++ b/infrastructure/kuknos/submitter.go @@ -0,0 +1,55 @@ +package kuknos + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "gl/domain/ledger" +) + +// Submitter sends a pre-signed Stellar/Kuknos transaction to Horizon. Signing +// remains outside GL; GL owns durable admission, retry, and reconciliation. +type Submitter struct { + Endpoint string + Client *http.Client +} + +func (s Submitter) Submit(ctx context.Context, record ledger.Settlement) (string, error) { + if s.Endpoint == "" || record.SignedTransactionXDR == "" { + return "", fmt.Errorf("kuknos submission endpoint and signed xdr are required") + } + form := url.Values{"tx": {record.SignedTransactionXDR}} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.Endpoint, "/")+"/transactions", strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + client := s.Client + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + var body struct { + Hash string `json:"hash"` + Extras struct { + ResultCodes struct { + Transaction string `json:"transaction"` + } `json:"result_codes"` + } `json:"extras"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", fmt.Errorf("decode kuknos response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 || body.Hash == "" { + return "", fmt.Errorf("kuknos rejected transaction: status=%d code=%s", resp.StatusCode, body.Extras.ResultCodes.Transaction) + } + return body.Hash, nil +} diff --git a/infrastructure/observability/logger.go b/infrastructure/observability/logger.go new file mode 100644 index 0000000..69c6954 --- /dev/null +++ b/infrastructure/observability/logger.go @@ -0,0 +1,61 @@ +package observability + +import ( + "context" + "log/slog" + "os" + + "go.opentelemetry.io/otel/trace" +) + +// Configure installs the process-wide JSON logger used by every GL adapter. +func Configure(service string, level slog.Leveler) *slog.Logger { + handler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + AddSource: true, + Level: level, + ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr { + switch attr.Key { + case slog.TimeKey: + attr.Key = "timestamp" + case slog.LevelKey: + attr.Key = "severity" + attr.Value = slog.StringValue(levelName(attr.Value)) + case slog.MessageKey: + attr.Key = "message" + case slog.SourceKey: + attr.Key = "source" + } + return attr + }, + }) + logger := slog.New(&traceHandler{Handler: handler}).With("service", service) + slog.SetDefault(logger) + return logger +} + +func levelName(value slog.Value) string { + if value.Kind() == slog.KindInt64 { + return slog.Level(value.Int64()).String() + } + if level, ok := value.Any().(slog.Level); ok { + return level.String() + } + return value.String() +} + +type traceHandler struct{ slog.Handler } + +func (h *traceHandler) Handle(ctx context.Context, record slog.Record) error { + spanContext := trace.SpanContextFromContext(ctx) + if spanContext.IsValid() { + record.AddAttrs(slog.String("trace_id", spanContext.TraceID().String()), slog.String("span_id", spanContext.SpanID().String())) + } + return h.Handler.Handle(ctx, record) +} + +func (h *traceHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &traceHandler{Handler: h.Handler.WithAttrs(attrs)} +} +func (h *traceHandler) WithGroup(name string) slog.Handler { + return &traceHandler{Handler: h.Handler.WithGroup(name)} +} diff --git a/infrastructure/observability/telemetry.go b/infrastructure/observability/telemetry.go new file mode 100644 index 0000000..87d5862 --- /dev/null +++ b/infrastructure/observability/telemetry.go @@ -0,0 +1,39 @@ +package observability + +import ( + "context" + "fmt" + "log/slog" + "os" + "strconv" + "strings" + "time" + + "gl/infrastructure/config" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +// Setup configures tracing without making telemetry availability a startup dependency. +func Setup(ctx context.Context, cfg config.TelemetryConfig, service, environment string) func(context.Context) { + if value := os.Getenv("OTEL_SERVICE_NAME"); value != "" { service = value } + if value := os.Getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"); value != "" { cfg.Endpoint = value } else if value := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"); value != "" { cfg.Endpoint = value } + if value := os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL"); value != "" { cfg.Protocol = value } + if value := os.Getenv("OTEL_TRACES_SAMPLER"); value != "" { cfg.Sampler = value }; if value := os.Getenv("OTEL_TRACES_SAMPLER_ARG"); value != "" { cfg.SamplerArg = value } + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{})) + if !cfg.Enabled || cfg.Endpoint == "" || (cfg.Protocol != "grpc" && cfg.Protocol != "http/protobuf") { if cfg.Enabled { slog.Warn("OpenTelemetry disabled due to invalid configuration") }; return func(context.Context) {} } + var exp sdktrace.SpanExporter; var err error + if cfg.Protocol == "grpc" { options := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(cfg.Endpoint), otlptracegrpc.WithTimeout(timeout(cfg.ExportTimeout)), otlptracegrpc.WithHeaders(cfg.Headers)}; if cfg.Insecure { options = append(options, otlptracegrpc.WithInsecure()) }; exp, err = otlptracegrpc.New(ctx, options...) } else { options := []otlptracehttp.Option{otlptracehttp.WithEndpoint(cfg.Endpoint), otlptracehttp.WithTimeout(timeout(cfg.ExportTimeout)), otlptracehttp.WithHeaders(cfg.Headers)}; if cfg.Insecure { options = append(options, otlptracehttp.WithInsecure()) }; exp, err = otlptracehttp.New(ctx, options...) } + if err != nil { slog.Warn("OpenTelemetry exporter unavailable; tracing disabled", "error", err); return func(context.Context) {} } + host, _ := os.Hostname(); res, err := resource.New(ctx, resource.WithAttributes(attribute.String("service.name", service), attribute.String("service.namespace", "darano"), attribute.String("deployment.environment.name", environment), attribute.String("host.name", host))); if err != nil { slog.Warn("OpenTelemetry resource setup failed", "error", err); return func(context.Context) {} } + ratio, parseErr := strconv.ParseFloat(cfg.SamplerArg, 64); if cfg.Sampler == "parentbased_traceidratio" && (parseErr != nil || ratio < 0 || ratio > 1) { slog.Warn("OpenTelemetry disabled due to invalid sampler", "error", fmt.Errorf("invalid sampler-arg")); return func(context.Context) {} } + provider := sdktrace.NewTracerProvider(sdktrace.WithResource(res), sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(ratio))), sdktrace.WithBatcher(exp, sdktrace.WithMaxQueueSize(2048), sdktrace.WithBatchTimeout(timeout(cfg.BatchTimeout)), sdktrace.WithExportTimeout(timeout(cfg.ExportTimeout)))) + otel.SetTracerProvider(provider); return func(stop context.Context) { stop, cancel := context.WithTimeout(stop, timeout(cfg.ShutdownTimeout)); defer cancel(); if err := provider.Shutdown(stop); err != nil { slog.Warn("OpenTelemetry shutdown failed", "error", err) } } +} +func timeout(value time.Duration) time.Duration { if value > 0 { return value }; return 5*time.Second } +var _ = strings.TrimSpace diff --git a/infrastructure/postgres/database.go b/infrastructure/postgres/database.go new file mode 100644 index 0000000..939b12b --- /dev/null +++ b/infrastructure/postgres/database.go @@ -0,0 +1,104 @@ +// Package postgres provides GL's PostgreSQL adapters. +package postgres + +import ( + "context" + "fmt" + "net/url" + "strconv" + + "gl/infrastructure/config" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Row interface { + Scan(dest ...any) error +} + +type Rows interface { + Next() bool + Scan(dest ...any) error + Err() error + Close() +} + +type Tx interface { + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) + QueryRow(context.Context, string, ...any) Row + Commit(context.Context) error + Rollback(context.Context) error +} + +type Database interface { + Begin(context.Context) (Tx, error) + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) + QueryRow(context.Context, string, ...any) Row + Query(context.Context, string, ...any) (Rows, error) + Ping(context.Context) error + Close() +} + +type Pool struct { + pool *pgxpool.Pool +} + +func Open(ctx context.Context, cfg config.DatabaseConfig) (*Pool, error) { + connectionURL := &url.URL{ + Scheme: "postgres", + User: url.UserPassword(cfg.User, cfg.Password), + Host: cfg.Host + ":" + strconv.Itoa(cfg.Port), + Path: cfg.Name, + } + query := connectionURL.Query() + query.Set("sslmode", cfg.SSLMode) + connectionURL.RawQuery = query.Encode() + + pool, err := pgxpool.New(ctx, connectionURL.String()) + if err != nil { + return nil, fmt.Errorf("create postgres pool: %w", err) + } + return &Pool{pool: pool}, nil +} + +func (p *Pool) Begin(ctx context.Context) (Tx, error) { + tx, err := p.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return nil, err + } + return txAdapter{Tx: tx}, nil +} + +func (p *Pool) Ping(ctx context.Context) error { + return p.pool.Ping(ctx) +} + +func (p *Pool) QueryRow(ctx context.Context, sql string, args ...any) Row { + return p.pool.QueryRow(ctx, sql, args...) +} + +func (p *Pool) Query(ctx context.Context, sql string, args ...any) (Rows, error) { + return p.pool.Query(ctx, sql, args...) +} + +func (p *Pool) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) { + return p.pool.Exec(ctx, sql, args...) +} + +func (p *Pool) Close() { + p.pool.Close() +} + +type txAdapter struct { + pgx.Tx +} + +func (t txAdapter) QueryRow(ctx context.Context, sql string, args ...any) Row { + return t.Tx.QueryRow(ctx, sql, args...) +} + +func (t txAdapter) Query(ctx context.Context, sql string, args ...any) (Rows, error) { + return t.Tx.Query(ctx, sql, args...) +} diff --git a/infrastructure/postgres/event_repository.go b/infrastructure/postgres/event_repository.go new file mode 100644 index 0000000..b0fc698 --- /dev/null +++ b/infrastructure/postgres/event_repository.go @@ -0,0 +1,106 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "gl/domain/ledger" + + "github.com/jackc/pgx/v5" +) + +func (r *JournalRepository) AppendEvent(ctx context.Context, event ledger.TransactionEvent) (ledger.TransactionEvent, bool, error) { + if err := event.Validate(); err != nil { + return ledger.TransactionEvent{}, false, fmt.Errorf("validate transaction event: %w", err) + } + metadataValues := event.Metadata + if metadataValues == nil { + metadataValues = map[string]string{} + } + metadata, err := json.Marshal(metadataValues) + if err != nil { + return ledger.TransactionEvent{}, false, fmt.Errorf("encode event metadata: %w", err) + } + + err = r.database.QueryRow(ctx, insertEventSQL, + event.ID, + event.SourceService, + event.IdempotencyKey, + event.SourceTransactionID, + event.TrackingCode, + event.EventVersion, + event.State, + event.ErrorCode, + event.ErrorMessage, + event.OccurredAt, + event.CorrelationID, + event.ActorID, + event.Blockchain.Network, + event.Blockchain.TransactionHash, + event.Blockchain.LedgerSequence, + metadata, + event.PayloadHash, + ).Scan(&event.RecordedAt) + if !errors.Is(err, pgx.ErrNoRows) { + if err != nil { + return ledger.TransactionEvent{}, false, fmt.Errorf("insert transaction event: %w", err) + } + return event, false, nil + } + + var stored ledger.TransactionEvent + var storedMetadata []byte + err = r.database.QueryRow(ctx, selectEventByIdempotencySQL, event.IdempotencyKey).Scan( + &stored.ID, + &stored.SourceService, + &stored.IdempotencyKey, + &stored.SourceTransactionID, + &stored.TrackingCode, + &stored.EventVersion, + &stored.State, + &stored.ErrorCode, + &stored.ErrorMessage, + &stored.OccurredAt, + &stored.RecordedAt, + &stored.CorrelationID, + &stored.ActorID, + &stored.Blockchain.Network, + &stored.Blockchain.TransactionHash, + &stored.Blockchain.LedgerSequence, + &storedMetadata, + &stored.PayloadHash, + ) + if err != nil { + return ledger.TransactionEvent{}, false, fmt.Errorf("read idempotent transaction event: %w", err) + } + if stored.PayloadHash != event.PayloadHash { + return ledger.TransactionEvent{}, false, ErrIdempotencyConflict + } + if err := json.Unmarshal(storedMetadata, &stored.Metadata); err != nil { + return ledger.TransactionEvent{}, false, fmt.Errorf("decode event metadata: %w", err) + } + return stored, true, nil +} + +const insertEventSQL = ` +INSERT INTO transaction_events ( + id, source_service, idempotency_key, source_transaction_id, tracking_code, + event_version, state, error_code, error_message, occurred_at, correlation_id, + actor_id, blockchain_network, blockchain_transaction_hash, + blockchain_ledger_sequence, metadata, payload_hash +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17 +) +ON CONFLICT (idempotency_key) DO NOTHING +RETURNING recorded_at` + +const selectEventByIdempotencySQL = ` +SELECT id, source_service, idempotency_key, source_transaction_id, + tracking_code, event_version, state, error_code, error_message, + occurred_at, recorded_at, correlation_id, actor_id, blockchain_network, + blockchain_transaction_hash, blockchain_ledger_sequence, metadata, + payload_hash +FROM transaction_events +WHERE idempotency_key = $1` diff --git a/infrastructure/postgres/event_repository_test.go b/infrastructure/postgres/event_repository_test.go new file mode 100644 index 0000000..8149292 --- /dev/null +++ b/infrastructure/postgres/event_repository_test.go @@ -0,0 +1,73 @@ +package postgres + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "gl/domain/ledger" + + "github.com/jackc/pgx/v5" +) + +func TestAppendEventStoresAndReturnsRecordingTime(t *testing.T) { + event := repositoryEvent() + recordedAt := time.Unix(2, 0).UTC() + database := &fakeDatabase{directRows: []Row{fakeRow{values: []any{recordedAt}}}} + + stored, existed, err := NewJournalRepository(database).AppendEvent(context.Background(), event) + if err != nil { + t.Fatal(err) + } + if existed || !stored.RecordedAt.Equal(recordedAt) { + t.Fatalf("unexpected append result: existed=%v event=%+v", existed, stored) + } +} + +func TestAppendEventRejectsConflictingIdempotencyPayload(t *testing.T) { + event := repositoryEvent() + storedHash := strings.Repeat("b", 64) + database := &fakeDatabase{directRows: []Row{ + fakeRow{err: pgx.ErrNoRows}, + fakeRow{values: []any{ + event.ID, + event.SourceService, + event.IdempotencyKey, + event.SourceTransactionID, + event.TrackingCode, + event.EventVersion, + event.State, + event.ErrorCode, + event.ErrorMessage, + event.OccurredAt, + time.Unix(2, 0).UTC(), + event.CorrelationID, + event.ActorID, + event.Blockchain.Network, + event.Blockchain.TransactionHash, + event.Blockchain.LedgerSequence, + []byte(`{}`), + storedHash, + }}, + }} + + _, _, err := NewJournalRepository(database).AppendEvent(context.Background(), event) + if !errors.Is(err, ErrIdempotencyConflict) { + t.Fatalf("expected idempotency conflict, got %v", err) + } +} + +func repositoryEvent() ledger.TransactionEvent { + return ledger.TransactionEvent{ + ID: "11111111-1111-4111-8111-111111111111", + SourceService: "wallet", + IdempotencyKey: "wallet:1:status:v1", + SourceTransactionID: "1", + EventVersion: 1, + State: ledger.TransactionStateCreated, + OccurredAt: time.Unix(1, 0).UTC(), + PayloadHash: strings.Repeat("a", 64), + } +} diff --git a/infrastructure/postgres/journal_repository.go b/infrastructure/postgres/journal_repository.go new file mode 100644 index 0000000..72c593b --- /dev/null +++ b/infrastructure/postgres/journal_repository.go @@ -0,0 +1,181 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "gl/domain/ledger" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +var ( + ErrIdempotencyConflict = ledger.ErrIdempotencyConflict + ErrIncompleteJournal = ledger.ErrIncompleteJournal +) + +type AppendResult = ledger.AppendResult + +type JournalRepository struct { + database Database +} + +func NewJournalRepository(database Database) *JournalRepository { + return &JournalRepository{database: database} +} + +func (r *JournalRepository) Append(ctx context.Context, journal ledger.Journal) (result AppendResult, err error) { + if err := journal.Validate(); err != nil { + return AppendResult{}, fmt.Errorf("validate journal: %w", err) + } + metadataValues := journal.Metadata + if metadataValues == nil { + metadataValues = map[string]string{} + } + metadata, err := json.Marshal(metadataValues) + if err != nil { + return AppendResult{}, fmt.Errorf("encode metadata: %w", err) + } + + tx, err := r.database.Begin(ctx) + if err != nil { + return AppendResult{}, fmt.Errorf("begin journal append: %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback(ctx) + } + }() + + var insertedID string + err = tx.QueryRow(ctx, insertJournalSQL, + journal.ID, + journal.SourceService, + journal.IdempotencyKey, + journal.SourceTransactionID, + journal.TrackingCode, + journal.EffectKind, + journal.EventVersion, + nullIfEmpty(journal.ReversalOfJournalID), + journal.OccurredAt, + journal.CorrelationID, + journal.ActorID, + journal.Blockchain.Network, + journal.Blockchain.TransactionHash, + journal.Blockchain.LedgerSequence, + metadata, + journal.PayloadHash, + ).Scan(&insertedID) + if errors.Is(err, pgx.ErrNoRows) { + _ = tx.Rollback(ctx) + return r.resolveDuplicate(ctx, journal) + } + if err != nil { + var postgresError *pgconn.PgError + if errors.As(err, &postgresError) && postgresError.ConstraintName == "journals_one_reversal_idx" { + return AppendResult{}, ledger.ErrAlreadyReversed + } + return AppendResult{}, fmt.Errorf("insert journal: %w", err) + } + + for _, entry := range journal.Entries { + var accountID int64 + err = tx.QueryRow(ctx, insertAccountSQL, + entry.Account.Class, + entry.Account.OwnerType, + entry.Account.OwnerID, + entry.Account.AssetID, + ).Scan(&accountID) + if errors.Is(err, pgx.ErrNoRows) { + err = tx.QueryRow(ctx, selectAccountSQL, + entry.Account.Class, + entry.Account.OwnerType, + entry.Account.OwnerID, + entry.Account.AssetID, + ).Scan(&accountID) + } + if err != nil { + return AppendResult{}, fmt.Errorf("resolve entry %d account: %w", entry.LineNumber, err) + } + + if _, err = tx.Exec(ctx, insertEntrySQL, + journal.ID, + entry.LineNumber, + accountID, + entry.Account.AssetID, + entry.Amount.String(), + entry.Description, + ); err != nil { + return AppendResult{}, fmt.Errorf("insert entry %d: %w", entry.LineNumber, err) + } + } + + if _, err = tx.Exec(ctx, "UPDATE journals SET sealed_at = clock_timestamp() WHERE id = $1", journal.ID); err != nil { + return AppendResult{}, fmt.Errorf("seal journal: %w", err) + } + if err = tx.Commit(ctx); err != nil { + return AppendResult{}, fmt.Errorf("commit journal: %w", err) + } + return AppendResult{JournalID: insertedID}, nil +} + +func (r *JournalRepository) resolveDuplicate(ctx context.Context, journal ledger.Journal) (AppendResult, error) { + var ( + journalID string + payloadHash string + sealed bool + ) + err := r.database.QueryRow(ctx, selectIdempotencySQL, journal.IdempotencyKey).Scan(&journalID, &payloadHash, &sealed) + if err != nil { + return AppendResult{}, fmt.Errorf("read idempotent journal: %w", err) + } + if payloadHash != journal.PayloadHash { + return AppendResult{}, ErrIdempotencyConflict + } + if !sealed { + return AppendResult{}, ErrIncompleteJournal + } + return AppendResult{JournalID: journalID, AlreadyExists: true}, nil +} + +func nullIfEmpty(value string) any { + if value == "" { + return nil + } + return value +} + +const insertJournalSQL = ` +INSERT INTO journals ( + id, source_service, idempotency_key, source_transaction_id, tracking_code, + effect_kind, event_version, reversal_of_journal_id, occurred_at, + correlation_id, actor_id, blockchain_network, blockchain_transaction_hash, + blockchain_ledger_sequence, metadata, payload_hash +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16 +) +ON CONFLICT (idempotency_key) DO NOTHING +RETURNING id` + +const insertAccountSQL = ` +INSERT INTO ledger_accounts (class, owner_type, owner_id, asset_id) +VALUES ($1, $2, $3, $4) +ON CONFLICT (class, owner_type, owner_id, asset_id) DO NOTHING +RETURNING id` + +const selectAccountSQL = ` +SELECT id FROM ledger_accounts +WHERE class = $1 AND owner_type = $2 AND owner_id = $3 AND asset_id = $4` + +const insertEntrySQL = ` +INSERT INTO journal_entries ( + journal_id, line_number, account_id, asset_id, amount, description +) VALUES ($1, $2, $3, $4, $5, $6)` + +const selectIdempotencySQL = ` +SELECT id, payload_hash, sealed_at IS NOT NULL +FROM journals +WHERE idempotency_key = $1` diff --git a/infrastructure/postgres/journal_repository_test.go b/infrastructure/postgres/journal_repository_test.go new file mode 100644 index 0000000..a90154f --- /dev/null +++ b/infrastructure/postgres/journal_repository_test.go @@ -0,0 +1,235 @@ +package postgres + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "gl/domain/ledger" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type fakeRow struct { + values []any + err error +} + +func (r fakeRow) Scan(dest ...any) error { + if r.err != nil { + return r.err + } + if len(dest) != len(r.values) { + return errors.New("unexpected scan width") + } + for index, value := range r.values { + switch target := dest[index].(type) { + case *string: + *target = value.(string) + case *int64: + *target = value.(int64) + case *bool: + *target = value.(bool) + case *uint32: + *target = value.(uint32) + case *time.Time: + *target = value.(time.Time) + case *ledger.TransactionState: + *target = value.(ledger.TransactionState) + case *[]byte: + *target = value.([]byte) + default: + return errors.New("unsupported scan target") + } + } + return nil +} + +type fakeTx struct { + rows []Row + execCount int + execErrAt int + committed bool + rolledBack bool +} + +func (t *fakeTx) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) { + t.execCount++ + if t.execCount == t.execErrAt { + return pgconn.CommandTag{}, errors.New("exec failed") + } + return pgconn.NewCommandTag("INSERT 0 1"), nil +} + +func (t *fakeTx) QueryRow(context.Context, string, ...any) Row { + row := t.rows[0] + t.rows = t.rows[1:] + return row +} + +func (t *fakeTx) Commit(context.Context) error { + t.committed = true + return nil +} + +func (t *fakeTx) Rollback(context.Context) error { + t.rolledBack = true + return nil +} + +type fakeDatabase struct { + tx *fakeTx + directRow Row + directRows []Row + beginCalled bool +} + +func (d *fakeDatabase) Begin(context.Context) (Tx, error) { + d.beginCalled = true + return d.tx, nil +} + +func (d *fakeDatabase) QueryRow(context.Context, string, ...any) Row { + if len(d.directRows) > 0 { + row := d.directRows[0] + d.directRows = d.directRows[1:] + return row + } + return d.directRow +} +func (d *fakeDatabase) Query(context.Context, string, ...any) (Rows, error) { + return nil, errors.New("unexpected query") +} +func (d *fakeDatabase) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) { + return pgconn.CommandTag{}, errors.New("unexpected exec") +} +func (d *fakeDatabase) Ping(context.Context) error { return nil } +func (d *fakeDatabase) Close() {} + +func TestJournalRepositoryAppendCommitsJournalEntriesAndSeal(t *testing.T) { + journal := repositoryJournal(t) + tx := &fakeTx{rows: []Row{ + fakeRow{values: []any{journal.ID}}, + fakeRow{values: []any{int64(10)}}, + fakeRow{values: []any{int64(20)}}, + }} + database := &fakeDatabase{tx: tx} + + result, err := NewJournalRepository(database).Append(context.Background(), journal) + if err != nil { + t.Fatal(err) + } + if result.JournalID != journal.ID || result.AlreadyExists { + t.Fatalf("unexpected result: %+v", result) + } + if !tx.committed || tx.rolledBack { + t.Fatalf("unexpected transaction state: %+v", tx) + } + if tx.execCount != 3 { + t.Fatalf("expected two entry inserts and one seal, got %d execs", tx.execCount) + } +} + +func TestJournalRepositoryReturnsExistingIdenticalJournal(t *testing.T) { + journal := repositoryJournal(t) + tx := &fakeTx{rows: []Row{fakeRow{err: pgx.ErrNoRows}}} + database := &fakeDatabase{ + tx: tx, + directRow: fakeRow{values: []any{journal.ID, journal.PayloadHash, true}}, + } + + result, err := NewJournalRepository(database).Append(context.Background(), journal) + if err != nil { + t.Fatal(err) + } + if !result.AlreadyExists || result.JournalID != journal.ID { + t.Fatalf("unexpected result: %+v", result) + } + if !tx.rolledBack || tx.committed { + t.Fatalf("duplicate transaction was not rolled back: %+v", tx) + } +} + +func TestJournalRepositoryRejectsConflictingIdempotencyPayload(t *testing.T) { + journal := repositoryJournal(t) + database := &fakeDatabase{ + tx: &fakeTx{rows: []Row{fakeRow{err: pgx.ErrNoRows}}}, + directRow: fakeRow{values: []any{journal.ID, strings.Repeat("b", 64), true}}, + } + + _, err := NewJournalRepository(database).Append(context.Background(), journal) + if !errors.Is(err, ErrIdempotencyConflict) { + t.Fatalf("expected idempotency conflict, got %v", err) + } +} + +func TestJournalRepositoryRollsBackEntryFailure(t *testing.T) { + journal := repositoryJournal(t) + tx := &fakeTx{ + rows: []Row{ + fakeRow{values: []any{journal.ID}}, + fakeRow{values: []any{int64(10)}}, + }, + execErrAt: 1, + } + database := &fakeDatabase{tx: tx} + + if _, err := NewJournalRepository(database).Append(context.Background(), journal); err == nil { + t.Fatal("expected entry insert error") + } + if !tx.rolledBack || tx.committed { + t.Fatalf("failed append was not rolled back: %+v", tx) + } +} + +func TestJournalRepositoryValidatesBeforeOpeningTransaction(t *testing.T) { + journal := repositoryJournal(t) + journal.Entries[1].Amount, _ = ledger.ParseAmount("9") + database := &fakeDatabase{tx: &fakeTx{}} + + if _, err := NewJournalRepository(database).Append(context.Background(), journal); err == nil { + t.Fatal("expected validation error") + } + if database.beginCalled { + t.Fatal("invalid journal opened a database transaction") + } +} + +func repositoryJournal(t *testing.T) ledger.Journal { + t.Helper() + debit, err := ledger.ParseAmount("-10.25") + if err != nil { + t.Fatal(err) + } + return ledger.Journal{ + ID: "11111111-1111-4111-8111-111111111111", + SourceService: "wallet", + IdempotencyKey: "wallet:1:internal-transfer:v1", + SourceTransactionID: "1", + TrackingCode: "track-1", + EffectKind: "internal-transfer", + EventVersion: 1, + OccurredAt: time.Unix(1, 0).UTC(), + PayloadHash: strings.Repeat("a", 64), + Metadata: map[string]string{"origin": "test"}, + Entries: []ledger.Entry{ + { + LineNumber: 1, + Account: ledger.AccountReference{ + Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "1", AssetID: 5, + }, + Amount: debit, + }, + { + LineNumber: 2, + Account: ledger.AccountReference{ + Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "2", AssetID: 5, + }, + Amount: debit.Negate(), + }, + }, + } +} diff --git a/infrastructure/postgres/migrate.go b/infrastructure/postgres/migrate.go new file mode 100644 index 0000000..35dad7b --- /dev/null +++ b/infrastructure/postgres/migrate.go @@ -0,0 +1,75 @@ +package postgres + +import ( + "context" + "embed" + "fmt" + "io/fs" + "sort" + "strconv" + "strings" +) + +//go:embed migrations/*.up.sql +var migrationFiles embed.FS + +const migrationLockID int64 = 674301 + +func Migrate(ctx context.Context, database Database) (err error) { + tx, err := database.Begin(ctx) + if err != nil { + return fmt.Errorf("begin migrations: %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback(ctx) + } + }() + + if _, err = tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", migrationLockID); err != nil { + return fmt.Errorf("lock migrations: %w", err) + } + if _, err = tx.Exec(ctx, `CREATE TABLE IF NOT EXISTS ledger_schema_migrations ( + version bigint PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT clock_timestamp() + )`); err != nil { + return fmt.Errorf("create migration ledger: %w", err) + } + + files, err := fs.Glob(migrationFiles, "migrations/*.up.sql") + if err != nil { + return fmt.Errorf("list migrations: %w", err) + } + sort.Strings(files) + for _, name := range files { + versionText := strings.SplitN(strings.TrimPrefix(name, "migrations/"), "_", 2)[0] + version, parseErr := strconv.ParseInt(versionText, 10, 64) + if parseErr != nil { + return fmt.Errorf("parse migration %s: %w", name, parseErr) + } + + var applied bool + if err = tx.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM ledger_schema_migrations WHERE version = $1)", version).Scan(&applied); err != nil { + return fmt.Errorf("check migration %s: %w", name, err) + } + if applied { + continue + } + + contents, readErr := migrationFiles.ReadFile(name) + if readErr != nil { + return fmt.Errorf("read migration %s: %w", name, readErr) + } + if _, err = tx.Exec(ctx, string(contents)); err != nil { + return fmt.Errorf("apply migration %s: %w", name, err) + } + if _, err = tx.Exec(ctx, "INSERT INTO ledger_schema_migrations (version) VALUES ($1) ON CONFLICT DO NOTHING", version); err != nil { + return fmt.Errorf("record migration %s: %w", name, err) + } + } + + if err = tx.Commit(ctx); err != nil { + return fmt.Errorf("commit migrations: %w", err) + } + return nil +} diff --git a/infrastructure/postgres/migration_test.go b/infrastructure/postgres/migration_test.go new file mode 100644 index 0000000..9293b1d --- /dev/null +++ b/infrastructure/postgres/migration_test.go @@ -0,0 +1,40 @@ +package postgres + +import ( + "strings" + "testing" +) + +func TestInitialMigrationContainsLedgerSafetyGuards(t *testing.T) { + contents, err := migrationFiles.ReadFile("migrations/000001_init.up.sql") + if err != nil { + t.Fatal(err) + } + schema := string(contents) + for _, required := range []string{ + "amount numeric(38, 18)", + "idempotency_key text NOT NULL UNIQUE", + "guard_journal_seal", + "journal is not balanced per asset", + "cannot append to a sealed journal", + "reject_ledger_mutation", + "journals_one_reversal_idx", + } { + if !strings.Contains(schema, required) { + t.Fatalf("migration is missing %q", required) + } + } +} + +func TestExplorerFilterMigrationAddsSupportingIndexes(t *testing.T) { + contents, err := migrationFiles.ReadFile("migrations/000002_explorer_filters.up.sql") + if err != nil { + t.Fatal(err) + } + schema := string(contents) + for _, required := range []string{"journals_effect_recorded_idx", "lower(effect_kind)", "ledger_accounts_user_owner_idx", "USER_AVAILABLE", "USER_FROZEN"} { + if !strings.Contains(schema, required) { + t.Fatalf("explorer filter migration is missing %q", required) + } + } +} diff --git a/infrastructure/postgres/migrations/000001_init.down.sql b/infrastructure/postgres/migrations/000001_init.down.sql new file mode 100644 index 0000000..5fa6e5b --- /dev/null +++ b/infrastructure/postgres/migrations/000001_init.down.sql @@ -0,0 +1,14 @@ +DROP TRIGGER IF EXISTS transaction_events_reject_update_or_delete ON transaction_events; +DROP TRIGGER IF EXISTS accounts_reject_update_or_delete ON ledger_accounts; +DROP TRIGGER IF EXISTS entries_reject_update_or_delete ON journal_entries; +DROP TRIGGER IF EXISTS entries_guard_insert ON journal_entries; +DROP TRIGGER IF EXISTS journals_reject_delete ON journals; +DROP TRIGGER IF EXISTS journals_guard_update ON journals; +DROP FUNCTION IF EXISTS guard_entry_insert(); +DROP FUNCTION IF EXISTS guard_journal_seal(); +DROP FUNCTION IF EXISTS reject_ledger_mutation(); +DROP TABLE IF EXISTS transaction_events; +DROP TABLE IF EXISTS journal_entries; +DROP TABLE IF EXISTS journals; +DROP TABLE IF EXISTS ledger_accounts; +DROP TABLE IF EXISTS ledger_schema_migrations; diff --git a/infrastructure/postgres/migrations/000001_init.up.sql b/infrastructure/postgres/migrations/000001_init.up.sql new file mode 100644 index 0000000..5d86fba --- /dev/null +++ b/infrastructure/postgres/migrations/000001_init.up.sql @@ -0,0 +1,176 @@ +CREATE TABLE IF NOT EXISTS ledger_schema_migrations ( + version bigint PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE TABLE ledger_accounts ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + class text NOT NULL CHECK (class IN ( + 'USER_AVAILABLE', + 'USER_FROZEN', + 'EXTERNAL_BLOCKCHAIN', + 'TREASURY', + 'MARKET_CLEARING', + 'IPG_CLEARING', + 'COMMISSION_REVENUE' + )), + owner_type text NOT NULL DEFAULT '', + owner_id text NOT NULL DEFAULT '', + asset_id bigint NOT NULL CHECK (asset_id > 0), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE (class, owner_type, owner_id, asset_id), + UNIQUE (id, asset_id), + CHECK ( + class NOT IN ('USER_AVAILABLE', 'USER_FROZEN') + OR (owner_type <> '' AND owner_id <> '') + ) +); + +CREATE TABLE journals ( + id uuid PRIMARY KEY, + source_service text NOT NULL CHECK (source_service <> ''), + idempotency_key text NOT NULL UNIQUE CHECK (idempotency_key <> ''), + source_transaction_id text NOT NULL CHECK (source_transaction_id <> ''), + tracking_code text NOT NULL DEFAULT '', + effect_kind text NOT NULL CHECK (effect_kind <> ''), + event_version integer NOT NULL CHECK (event_version > 0), + reversal_of_journal_id uuid REFERENCES journals (id), + occurred_at timestamptz NOT NULL, + recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(), + sealed_at timestamptz, + correlation_id text NOT NULL DEFAULT '', + actor_id text NOT NULL DEFAULT '', + blockchain_network text NOT NULL DEFAULT '', + blockchain_transaction_hash text NOT NULL DEFAULT '', + blockchain_ledger_sequence text NOT NULL DEFAULT '', + metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'), + payload_hash char(64) NOT NULL CHECK (payload_hash ~ '^[0-9a-f]{64}$'), + CHECK (reversal_of_journal_id IS NULL OR reversal_of_journal_id <> id) +); + +CREATE INDEX journals_source_transaction_idx + ON journals (source_service, source_transaction_id); + +CREATE INDEX journals_recorded_at_idx ON journals (recorded_at, id); + +CREATE UNIQUE INDEX journals_one_reversal_idx + ON journals (reversal_of_journal_id) + WHERE reversal_of_journal_id IS NOT NULL; + +CREATE TABLE journal_entries ( + journal_id uuid NOT NULL REFERENCES journals (id), + line_number integer NOT NULL CHECK (line_number > 0), + account_id bigint NOT NULL, + asset_id bigint NOT NULL CHECK (asset_id > 0), + amount numeric(38, 18) NOT NULL CHECK (amount <> 0), + description text NOT NULL DEFAULT '', + PRIMARY KEY (journal_id, line_number), + FOREIGN KEY (account_id, asset_id) REFERENCES ledger_accounts (id, asset_id) +); + +CREATE INDEX journal_entries_account_idx + ON journal_entries (account_id, journal_id, line_number); + +CREATE TABLE transaction_events ( + id uuid PRIMARY KEY, + source_service text NOT NULL CHECK (source_service <> ''), + idempotency_key text NOT NULL UNIQUE CHECK (idempotency_key <> ''), + source_transaction_id text NOT NULL CHECK (source_transaction_id <> ''), + tracking_code text NOT NULL DEFAULT '', + event_version integer NOT NULL CHECK (event_version > 0), + state text NOT NULL CHECK (state IN ( + 'CREATED', + 'PENDING_TRANSACTION', + 'PENDING_ADMIN', + 'SUCCESSFUL', + 'FAILED', + 'SUSPENDED' + )), + error_code text NOT NULL DEFAULT '', + error_message text NOT NULL DEFAULT '', + occurred_at timestamptz NOT NULL, + recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(), + correlation_id text NOT NULL DEFAULT '', + actor_id text NOT NULL DEFAULT '', + blockchain_network text NOT NULL DEFAULT '', + blockchain_transaction_hash text NOT NULL DEFAULT '', + blockchain_ledger_sequence text NOT NULL DEFAULT '', + metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'), + payload_hash char(64) NOT NULL CHECK (payload_hash ~ '^[0-9a-f]{64}$') +); + +CREATE INDEX transaction_events_source_idx + ON transaction_events (source_service, source_transaction_id, event_version); + +CREATE FUNCTION reject_ledger_mutation() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION '% is append-only', TG_TABLE_NAME + USING ERRCODE = '55000'; +END; +$$; + +CREATE FUNCTION guard_journal_seal() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF OLD.sealed_at IS NOT NULL + OR NEW.sealed_at IS NULL + OR (to_jsonb(NEW) - 'sealed_at') IS DISTINCT FROM (to_jsonb(OLD) - 'sealed_at') THEN + RAISE EXCEPTION 'journals are immutable except for initial sealing' + USING ERRCODE = '55000'; + END IF; + + IF (SELECT count(*) FROM journal_entries WHERE journal_id = NEW.id) < 2 THEN + RAISE EXCEPTION 'journal requires at least two entries' + USING ERRCODE = '23514'; + END IF; + + IF EXISTS ( + SELECT asset_id + FROM journal_entries + WHERE journal_id = NEW.id + GROUP BY asset_id + HAVING sum(amount) <> 0 + ) THEN + RAISE EXCEPTION 'journal is not balanced per asset' + USING ERRCODE = '23514'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE FUNCTION guard_entry_insert() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF (SELECT sealed_at IS NOT NULL FROM journals WHERE id = NEW.journal_id) THEN + RAISE EXCEPTION 'cannot append to a sealed journal' + USING ERRCODE = '55000'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER journals_guard_update +BEFORE UPDATE ON journals +FOR EACH ROW EXECUTE FUNCTION guard_journal_seal(); + +CREATE TRIGGER journals_reject_delete +BEFORE DELETE ON journals +FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation(); + +CREATE TRIGGER entries_guard_insert +BEFORE INSERT ON journal_entries +FOR EACH ROW EXECUTE FUNCTION guard_entry_insert(); + +CREATE TRIGGER entries_reject_update_or_delete +BEFORE UPDATE OR DELETE ON journal_entries +FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation(); + +CREATE TRIGGER accounts_reject_update_or_delete +BEFORE UPDATE OR DELETE ON ledger_accounts +FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation(); + +CREATE TRIGGER transaction_events_reject_update_or_delete +BEFORE UPDATE OR DELETE ON transaction_events +FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation(); diff --git a/infrastructure/postgres/migrations/000002_explorer_filters.down.sql b/infrastructure/postgres/migrations/000002_explorer_filters.down.sql new file mode 100644 index 0000000..1c77993 --- /dev/null +++ b/infrastructure/postgres/migrations/000002_explorer_filters.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS ledger_accounts_user_owner_idx; +DROP INDEX IF EXISTS journals_effect_recorded_idx; diff --git a/infrastructure/postgres/migrations/000002_explorer_filters.up.sql b/infrastructure/postgres/migrations/000002_explorer_filters.up.sql new file mode 100644 index 0000000..7f71d4e --- /dev/null +++ b/infrastructure/postgres/migrations/000002_explorer_filters.up.sql @@ -0,0 +1,7 @@ +CREATE INDEX journals_effect_recorded_idx + ON journals (lower(effect_kind), recorded_at DESC, id DESC) + WHERE sealed_at IS NOT NULL; + +CREATE INDEX ledger_accounts_user_owner_idx + ON ledger_accounts (owner_id, id) + WHERE class IN ('USER_AVAILABLE', 'USER_FROZEN'); diff --git a/infrastructure/postgres/migrations/000003_kuknos_settlements.up.sql b/infrastructure/postgres/migrations/000003_kuknos_settlements.up.sql new file mode 100644 index 0000000..ea080c7 --- /dev/null +++ b/infrastructure/postgres/migrations/000003_kuknos_settlements.up.sql @@ -0,0 +1,22 @@ +CREATE TABLE kuknos_settlements ( + id uuid PRIMARY KEY, + source_service text NOT NULL CHECK (source_service <> ''), + source_transaction_id text NOT NULL CHECK (source_transaction_id <> ''), + idempotency_key text NOT NULL UNIQUE CHECK (idempotency_key <> ''), + status text NOT NULL CHECK (status IN ('PENDING','SUBMITTED','CONFIRMED','RETRYABLE','MANUAL_REVIEW')), + network text NOT NULL DEFAULT 'kuknos', + signed_transaction_xdr text NOT NULL DEFAULT '', + transaction_hash text NOT NULL DEFAULT '', + attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0), + available_at timestamptz NOT NULL DEFAULT clock_timestamp(), + locked_at timestamptz, + locked_by text NOT NULL DEFAULT '', + last_error text NOT NULL DEFAULT '', + submitted_at timestamptz, + confirmed_at timestamptz, + manual_review_at timestamptz, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE INDEX kuknos_settlements_due_idx ON kuknos_settlements (status, available_at); diff --git a/infrastructure/postgres/migrations/000004_settlement_operator_audit.up.sql b/infrastructure/postgres/migrations/000004_settlement_operator_audit.up.sql new file mode 100644 index 0000000..e91c4e2 --- /dev/null +++ b/infrastructure/postgres/migrations/000004_settlement_operator_audit.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE kuknos_settlement_operator_audits ( + id bigserial PRIMARY KEY, + settlement_id uuid NOT NULL REFERENCES kuknos_settlements(id), + actor_id text NOT NULL CHECK (actor_id <> ''), + action text NOT NULL CHECK (action IN ('RETRY')), + reason text NOT NULL CHECK (reason <> ''), + previous_status text NOT NULL, + resulting_status text NOT NULL, + created_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE INDEX kuknos_settlement_operator_audits_settlement_idx + ON kuknos_settlement_operator_audits (settlement_id, created_at DESC); diff --git a/infrastructure/postgres/migrations/000005_settlement_reconciliation_issues.up.sql b/infrastructure/postgres/migrations/000005_settlement_reconciliation_issues.up.sql new file mode 100644 index 0000000..f17fbbf --- /dev/null +++ b/infrastructure/postgres/migrations/000005_settlement_reconciliation_issues.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE kuknos_reconciliation_issues ( + settlement_id uuid PRIMARY KEY REFERENCES kuknos_settlements(id), + issue_type text NOT NULL CHECK (issue_type IN ('MISSING_LEDGER_CONFIRMATION','TRANSACTION_HASH_MISMATCH')), + expected_hash text NOT NULL DEFAULT '', + actual_hash text NOT NULL DEFAULT '', + status text NOT NULL DEFAULT 'OPEN' CHECK (status IN ('OPEN','RESOLVED')), + first_detected_at timestamptz NOT NULL DEFAULT clock_timestamp(), + last_detected_at timestamptz NOT NULL DEFAULT clock_timestamp(), + resolved_at timestamptz +); + +CREATE INDEX kuknos_reconciliation_issues_open_idx + ON kuknos_reconciliation_issues (status, last_detected_at DESC); diff --git a/infrastructure/postgres/query_repository.go b/infrastructure/postgres/query_repository.go new file mode 100644 index 0000000..c99c716 --- /dev/null +++ b/infrastructure/postgres/query_repository.go @@ -0,0 +1,378 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "gl/application/explorer" + "gl/domain/ledger" + + "github.com/jackc/pgx/v5" +) + +var ErrNotFound = ledger.ErrNotFound + +type JournalFilter = ledger.JournalFilter + +func (r *JournalRepository) GetByID(ctx context.Context, journalID string) (ledger.Journal, error) { + return r.get(ctx, getJournalByIDSQL, journalID) +} + +func (r *JournalRepository) GetByIdempotencyKey(ctx context.Context, idempotencyKey string) (ledger.Journal, error) { + return r.get(ctx, getJournalByIdempotencySQL, idempotencyKey) +} + +func (r *JournalRepository) get(ctx context.Context, query string, value string) (ledger.Journal, error) { + journal, err := scanJournal(r.database.QueryRow(ctx, query, value)) + if errors.Is(err, pgx.ErrNoRows) { + return ledger.Journal{}, ErrNotFound + } + if err != nil { + return ledger.Journal{}, fmt.Errorf("read journal: %w", err) + } + journal.Entries, err = r.loadEntries(ctx, journal.ID) + if err != nil { + return ledger.Journal{}, err + } + return journal, nil +} + +func (r *JournalRepository) List(ctx context.Context, filter JournalFilter) ([]ledger.Journal, error) { + limit := filter.Limit + if limit <= 0 || limit > 201 { + limit = 50 + } + if filter.Offset < 0 { + filter.Offset = 0 + } + + var accountClass, ownerType, ownerID any + if filter.Account != nil { + accountClass = filter.Account.Class + ownerType = filter.Account.OwnerType + ownerID = filter.Account.OwnerID + } + return r.queryJournals(ctx, listJournalsSQL, + filter.AssetID, + accountClass, + ownerType, + ownerID, + filter.OwnerID, + filter.EffectKind, + filter.RecordedFrom, + filter.RecordedTo, + limit, + filter.Offset, + ) +} + +func (r *JournalRepository) GetByTransactionHash(ctx context.Context, hash string) ([]ledger.Journal, error) { + return r.queryJournals(ctx, getJournalsByTransactionHashSQL, hash) +} + +func (r *JournalRepository) Stats(ctx context.Context) (explorer.Stats, error) { + var stats explorer.Stats + if err := r.database.QueryRow(ctx, getExplorerStatsSQL).Scan( + &stats.JournalCount, + &stats.EntryCount, + &stats.AccountCount, + &stats.LastRecordedAt, + ); err != nil { + return explorer.Stats{}, fmt.Errorf("read explorer stats: %w", err) + } + return stats, nil +} + +func (r *JournalRepository) SettlementDashboard(ctx context.Context, limit int) (explorer.SettlementStats, []explorer.Settlement, error) { + if limit <= 0 || limit > 100 { + limit = 12 + } + var stats explorer.SettlementStats + if err := r.database.QueryRow(ctx, `SELECT + count(*) FILTER (WHERE status='PENDING'), + count(*) FILTER (WHERE status='RETRYABLE'), + count(*) FILTER (WHERE status='CONFIRMED'), + count(*) FILTER (WHERE status='MANUAL_REVIEW') + FROM kuknos_settlements`).Scan(&stats.Pending, &stats.Retryable, &stats.Confirmed, &stats.ManualReview); err != nil { + return explorer.SettlementStats{}, nil, fmt.Errorf("read settlement stats: %w", err) + } + rows, err := r.database.Query(ctx, `SELECT id, source_service, source_transaction_id, status, + network, transaction_hash, attempts, last_error, updated_at + FROM kuknos_settlements ORDER BY updated_at DESC LIMIT $1`, limit) + if err != nil { + return explorer.SettlementStats{}, nil, fmt.Errorf("list recent settlements: %w", err) + } + defer rows.Close() + settlements := make([]explorer.Settlement, 0, limit) + for rows.Next() { + var settlement explorer.Settlement + if err := rows.Scan(&settlement.ID, &settlement.SourceService, &settlement.SourceTransactionID, + &settlement.Status, &settlement.Network, &settlement.TransactionHash, &settlement.Attempts, + &settlement.LastError, &settlement.UpdatedAt); err != nil { + return explorer.SettlementStats{}, nil, fmt.Errorf("scan recent settlement: %w", err) + } + settlements = append(settlements, settlement) + } + if err := rows.Err(); err != nil { + return explorer.SettlementStats{}, nil, fmt.Errorf("iterate recent settlements: %w", err) + } + return stats, settlements, nil +} + +func (r *JournalRepository) TopHolders(ctx context.Context, assetLimit, holderLimit int) ([]explorer.Holder, error) { + if assetLimit <= 0 || assetLimit > 20 { + assetLimit = 5 + } + if holderLimit <= 0 || holderLimit > 20 { + holderLimit = 5 + } + rows, err := r.database.Query(ctx, topHoldersSQL, assetLimit, holderLimit) + if err != nil { + return nil, fmt.Errorf("list top holders: %w", err) + } + defer rows.Close() + + holders := make([]explorer.Holder, 0, assetLimit*holderLimit) + for rows.Next() { + var holder explorer.Holder + var balance string + if err := rows.Scan(&holder.Rank, &holder.OwnerType, &holder.OwnerID, &holder.AssetID, &balance); err != nil { + return nil, fmt.Errorf("scan top holder: %w", err) + } + holder.Balance, err = ledger.ParseAmount(balance) + if err != nil { + return nil, fmt.Errorf("decode top holder balance: %w", err) + } + holders = append(holders, holder) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate top holders: %w", err) + } + return holders, nil +} + +func (r *JournalRepository) queryJournals(ctx context.Context, query string, args ...any) ([]ledger.Journal, error) { + rows, err := r.database.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list journals: %w", err) + } + defer rows.Close() + + journals := make([]ledger.Journal, 0) + for rows.Next() { + journal, scanErr := scanJournal(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan journal: %w", scanErr) + } + journals = append(journals, journal) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate journals: %w", err) + } + rows.Close() + + for index := range journals { + journals[index].Entries, err = r.loadEntries(ctx, journals[index].ID) + if err != nil { + return nil, err + } + } + return journals, nil +} + +func (r *JournalRepository) Balance(ctx context.Context, account ledger.AccountReference, asOf time.Time) (ledger.Amount, error) { + if err := account.Validate(); err != nil { + return ledger.Amount{}, err + } + var asOfValue any + if !asOf.IsZero() { + asOfValue = asOf + } + var value string + if err := r.database.QueryRow(ctx, getBalanceSQL, + account.Class, + account.OwnerType, + account.OwnerID, + account.AssetID, + asOfValue, + ).Scan(&value); err != nil { + return ledger.Amount{}, fmt.Errorf("read balance: %w", err) + } + amount, err := ledger.ParseAmount(value) + if err != nil { + return ledger.Amount{}, fmt.Errorf("decode balance: %w", err) + } + return amount, nil +} + +type scanner interface { + Scan(...any) error +} + +func scanJournal(row scanner) (ledger.Journal, error) { + var ( + journal ledger.Journal + reversal *string + metadata []byte + ) + err := row.Scan( + &journal.ID, + &journal.SourceService, + &journal.IdempotencyKey, + &journal.SourceTransactionID, + &journal.TrackingCode, + &journal.EffectKind, + &journal.EventVersion, + &reversal, + &journal.OccurredAt, + &journal.RecordedAt, + &journal.CorrelationID, + &journal.ActorID, + &journal.Blockchain.Network, + &journal.Blockchain.TransactionHash, + &journal.Blockchain.LedgerSequence, + &metadata, + &journal.PayloadHash, + ) + if err != nil { + return ledger.Journal{}, err + } + if reversal != nil { + journal.ReversalOfJournalID = *reversal + } + if err := json.Unmarshal(metadata, &journal.Metadata); err != nil { + return ledger.Journal{}, fmt.Errorf("decode metadata: %w", err) + } + return journal, nil +} + +func (r *JournalRepository) loadEntries(ctx context.Context, journalID string) ([]ledger.Entry, error) { + rows, err := r.database.Query(ctx, getEntriesSQL, journalID) + if err != nil { + return nil, fmt.Errorf("read journal entries: %w", err) + } + defer rows.Close() + + entries := make([]ledger.Entry, 0) + for rows.Next() { + var ( + entry ledger.Entry + amountValue string + ) + if err := rows.Scan( + &entry.LineNumber, + &entry.Account.Class, + &entry.Account.OwnerType, + &entry.Account.OwnerID, + &entry.Account.AssetID, + &amountValue, + &entry.Description, + ); err != nil { + return nil, fmt.Errorf("scan journal entry: %w", err) + } + entry.Amount, err = ledger.ParseAmount(amountValue) + if err != nil { + return nil, fmt.Errorf("decode journal entry amount: %w", err) + } + entries = append(entries, entry) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate journal entries: %w", err) + } + return entries, nil +} + +const journalColumns = ` + j.id, j.source_service, j.idempotency_key, j.source_transaction_id, + j.tracking_code, j.effect_kind, j.event_version, j.reversal_of_journal_id, + j.occurred_at, j.recorded_at, j.correlation_id, j.actor_id, + j.blockchain_network, j.blockchain_transaction_hash, + j.blockchain_ledger_sequence, j.metadata, j.payload_hash` + +const getJournalByIDSQL = `SELECT ` + journalColumns + ` +FROM journals j WHERE j.id = $1 AND j.sealed_at IS NOT NULL` + +const getJournalByIdempotencySQL = `SELECT ` + journalColumns + ` +FROM journals j WHERE j.idempotency_key = $1 AND j.sealed_at IS NOT NULL` + +const getJournalsByTransactionHashSQL = `SELECT ` + journalColumns + ` +FROM journals j +WHERE j.blockchain_transaction_hash = $1 AND j.sealed_at IS NOT NULL +ORDER BY j.recorded_at DESC, j.id DESC` + +const topHoldersSQL = `WITH recent_assets AS ( + SELECT e.asset_id, MAX(j.recorded_at) AS last_activity + FROM journal_entries e + JOIN journals j ON j.id = e.journal_id + WHERE j.sealed_at IS NOT NULL + GROUP BY e.asset_id + ORDER BY last_activity DESC, e.asset_id + LIMIT $1 +), holder_balances AS ( + SELECT a.owner_type, a.owner_id, e.asset_id, SUM(e.amount) AS balance + FROM journal_entries e + JOIN journals j ON j.id = e.journal_id AND j.sealed_at IS NOT NULL + JOIN ledger_accounts a ON a.id = e.account_id + JOIN recent_assets ra ON ra.asset_id = e.asset_id + WHERE a.class IN ('USER_AVAILABLE', 'USER_FROZEN') + GROUP BY a.owner_type, a.owner_id, e.asset_id + HAVING SUM(e.amount) > 0 +), ranked_holders AS ( + SELECT owner_type, owner_id, asset_id, balance, + ROW_NUMBER() OVER ( + PARTITION BY asset_id + ORDER BY balance DESC, owner_type, owner_id + ) AS holder_rank + FROM holder_balances +) +SELECT rh.holder_rank, rh.owner_type, rh.owner_id, rh.asset_id, rh.balance::text +FROM ranked_holders rh +JOIN recent_assets ra ON ra.asset_id = rh.asset_id +WHERE rh.holder_rank <= $2 +ORDER BY ra.last_activity DESC, rh.asset_id, rh.holder_rank` + +const listJournalsSQL = `SELECT DISTINCT ` + journalColumns + ` +FROM journals j +JOIN journal_entries e ON e.journal_id = j.id +JOIN ledger_accounts a ON a.id = e.account_id +WHERE j.sealed_at IS NOT NULL + AND ($1::bigint IS NULL OR e.asset_id = $1) + AND ($2::text IS NULL OR ( + a.class = $2 AND a.owner_type = $3 AND a.owner_id = $4 + )) + AND ($5::text IS NULL OR ( + a.class IN ('USER_AVAILABLE', 'USER_FROZEN') AND a.owner_id = $5 + )) + AND ($6::text IS NULL OR lower(j.effect_kind) = lower($6)) + AND ($7::timestamptz IS NULL OR j.recorded_at >= $7) + AND ($8::timestamptz IS NULL OR j.recorded_at <= $8) +ORDER BY j.recorded_at DESC, j.id DESC +LIMIT $9 OFFSET $10` + +const getEntriesSQL = ` +SELECT e.line_number, a.class, a.owner_type, a.owner_id, e.asset_id, + e.amount::text, e.description +FROM journal_entries e +JOIN ledger_accounts a ON a.id = e.account_id +WHERE e.journal_id = $1 +ORDER BY e.line_number` + +const getBalanceSQL = ` +SELECT COALESCE(sum(e.amount), 0)::text +FROM journal_entries e +JOIN ledger_accounts a ON a.id = e.account_id +JOIN journals j ON j.id = e.journal_id +WHERE j.sealed_at IS NOT NULL + AND a.class = $1 AND a.owner_type = $2 AND a.owner_id = $3 + AND a.asset_id = $4 + AND ($5::timestamptz IS NULL OR j.recorded_at <= $5)` + +const getExplorerStatsSQL = ` +SELECT + (SELECT count(*) FROM journals WHERE sealed_at IS NOT NULL), + (SELECT count(*) FROM journal_entries e JOIN journals j ON j.id = e.journal_id WHERE j.sealed_at IS NOT NULL), + (SELECT count(*) FROM ledger_accounts), + COALESCE((SELECT max(recorded_at) FROM journals WHERE sealed_at IS NOT NULL), 'epoch'::timestamptz)` diff --git a/infrastructure/postgres/settlement_repository.go b/infrastructure/postgres/settlement_repository.go new file mode 100644 index 0000000..236cbe1 --- /dev/null +++ b/infrastructure/postgres/settlement_repository.go @@ -0,0 +1,204 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "gl/domain/ledger" +) + +type SettlementRepository struct{ database Database } + +type ReconciliationStats struct { + Open, Detected, Resolved int64 +} + +type txQueryer interface { + Query(context.Context, string, ...any) (Rows, error) +} + +func NewSettlementRepository(database Database) *SettlementRepository { + return &SettlementRepository{database: database} +} + +func (r *SettlementRepository) Stats(ctx context.Context) (ledger.SettlementStats, error) { + var stats ledger.SettlementStats + err := r.database.QueryRow(ctx, `SELECT + COUNT(*) FILTER (WHERE status='PENDING'), + COUNT(*) FILTER (WHERE status='RETRYABLE'), + COUNT(*) FILTER (WHERE status='MANUAL_REVIEW'), + COALESCE(EXTRACT(EPOCH FROM (clock_timestamp() - MIN(created_at) FILTER (WHERE status IN ('PENDING','RETRYABLE'))))::bigint, 0) +FROM kuknos_settlements`).Scan(&stats.Pending, &stats.Retryable, &stats.ManualReview, &stats.OldestPendingSeconds) + if err != nil { + return ledger.SettlementStats{}, fmt.Errorf("settlement stats: %w", err) + } + return stats, nil +} + +func (r *SettlementRepository) Enqueue(ctx context.Context, s ledger.Settlement) (ledger.Settlement, bool, error) { + if err := s.Validate(); err != nil { + return ledger.Settlement{}, false, err + } + var inserted string + err := r.database.QueryRow(ctx, `INSERT INTO kuknos_settlements + (id, source_service, source_transaction_id, idempotency_key, status, network, signed_transaction_xdr, available_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (idempotency_key) DO NOTHING RETURNING id`, + s.ID, s.SourceService, s.SourceTxID, s.IdempotencyKey, s.Status, s.Network, s.SignedTransactionXDR, s.AvailableAt).Scan(&inserted) + if err == nil { + return s, false, nil + } + if err == pgx.ErrNoRows { + var existingXDR string + if err := r.database.QueryRow(ctx, `SELECT signed_transaction_xdr, id FROM kuknos_settlements WHERE idempotency_key=$1`, s.IdempotencyKey).Scan(&existingXDR, &inserted); err != nil { + return ledger.Settlement{}, false, fmt.Errorf("resolve settlement idempotency: %w", err) + } + if existingXDR != s.SignedTransactionXDR { + return ledger.Settlement{}, false, ledger.ErrIdempotencyConflict + } + existing, err := r.Get(ctx, inserted, "") + return existing, true, err + } + return ledger.Settlement{}, false, fmt.Errorf("enqueue settlement: %w", err) +} + +func (r *SettlementRepository) Get(ctx context.Context, id, idempotencyKey string) (ledger.Settlement, error) { + var s ledger.Settlement + err := r.database.QueryRow(ctx, `SELECT id, source_service, source_transaction_id, idempotency_key, status, network, transaction_hash, attempts, available_at, last_error, signed_transaction_xdr FROM kuknos_settlements WHERE id=NULLIF($1,'')::uuid OR idempotency_key=NULLIF($2,'')`, id, idempotencyKey).Scan(&s.ID, &s.SourceService, &s.SourceTxID, &s.IdempotencyKey, &s.Status, &s.Network, &s.TransactionHash, &s.Attempts, &s.AvailableAt, &s.LastError, &s.SignedTransactionXDR) + if err != nil { + return ledger.Settlement{}, fmt.Errorf("get settlement: %w", err) + } + return s, nil +} + +func (r *SettlementRepository) ClaimDue(ctx context.Context, workerID string, limit int, now time.Time) ([]ledger.Settlement, error) { + tx, err := r.database.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + if _, err := tx.Exec(ctx, `UPDATE kuknos_settlements SET locked_at=NULL, locked_by='', updated_at=clock_timestamp() WHERE locked_at < clock_timestamp() - interval '10 minutes'`); err != nil { + return nil, fmt.Errorf("release stale settlement locks: %w", err) + } + queryer, ok := tx.(txQueryer) + if !ok { + return nil, fmt.Errorf("settlement transaction does not support row queries") + } + rows, err := queryer.Query(ctx, `WITH due AS ( +SELECT id FROM kuknos_settlements +WHERE status IN ('PENDING','RETRYABLE') AND available_at <= $1 AND locked_by='' +ORDER BY available_at, created_at FOR UPDATE SKIP LOCKED LIMIT $2 +) +UPDATE kuknos_settlements s SET locked_at=clock_timestamp(), locked_by=$3, updated_at=clock_timestamp() +FROM due WHERE s.id=due.id +RETURNING s.id, s.source_service, s.source_transaction_id, s.idempotency_key, s.status, s.network, s.transaction_hash, s.attempts, s.available_at, s.last_error, s.signed_transaction_xdr`, now, limit, workerID) + if err != nil { + return nil, err + } + defer rows.Close() + var result []ledger.Settlement + for rows.Next() { + var s ledger.Settlement + if err := rows.Scan(&s.ID, &s.SourceService, &s.SourceTxID, &s.IdempotencyKey, &s.Status, &s.Network, &s.TransactionHash, &s.Attempts, &s.AvailableAt, &s.LastError, &s.SignedTransactionXDR); err != nil { + return nil, err + } + result = append(result, s) + } + if err := rows.Err(); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return result, nil +} + +func (r *SettlementRepository) Save(ctx context.Context, s ledger.Settlement, workerID string) error { + _, err := r.database.Exec(ctx, `UPDATE kuknos_settlements SET status=$2, transaction_hash=$3, attempts=$4, available_at=$5, last_error=$6, locked_at=NULL, locked_by='', submitted_at=CASE WHEN $2 IN ('SUBMITTED','CONFIRMED') THEN COALESCE(submitted_at, clock_timestamp()) ELSE submitted_at END, confirmed_at=CASE WHEN $2='CONFIRMED' THEN COALESCE(confirmed_at, clock_timestamp()) ELSE confirmed_at END, manual_review_at=CASE WHEN $2='MANUAL_REVIEW' THEN clock_timestamp() ELSE manual_review_at END, updated_at=clock_timestamp() WHERE id=$1 AND locked_by=$7`, s.ID, s.Status, s.TransactionHash, s.Attempts, s.AvailableAt, s.LastError, workerID) + return err +} + +func (r *SettlementRepository) RetryByOperator(ctx context.Context, id, actorID, reason string, now time.Time) (ledger.Settlement, error) { + tx, err := r.database.Begin(ctx) + if err != nil { + return ledger.Settlement{}, fmt.Errorf("begin operator retry: %w", err) + } + defer tx.Rollback(ctx) + var previous ledger.SettlementStatus + if err := tx.QueryRow(ctx, `SELECT status FROM kuknos_settlements WHERE id=$1 FOR UPDATE`, id).Scan(&previous); err != nil { + return ledger.Settlement{}, fmt.Errorf("lock settlement for operator retry: %w", err) + } + if previous == ledger.SettlementConfirmed { + return ledger.Settlement{}, fmt.Errorf("confirmed settlement cannot be retried") + } + if _, err := tx.Exec(ctx, `UPDATE kuknos_settlements SET status='RETRYABLE', available_at=$2, locked_at=NULL, locked_by='', last_error='', manual_review_at=NULL, updated_at=clock_timestamp() WHERE id=$1`, id, now); err != nil { + return ledger.Settlement{}, fmt.Errorf("retry settlement: %w", err) + } + if _, err := tx.Exec(ctx, `INSERT INTO kuknos_settlement_operator_audits (settlement_id, actor_id, action, reason, previous_status, resulting_status) VALUES ($1,$2,'RETRY',$3,$4,'RETRYABLE')`, id, actorID, reason, previous); err != nil { + return ledger.Settlement{}, fmt.Errorf("audit operator retry: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return ledger.Settlement{}, fmt.Errorf("commit operator retry: %w", err) + } + return r.Get(ctx, id, "") +} + +// ReconcileConfirmationEvidence durably records confirmed Kuknos settlements +// whose latest GL lifecycle evidence is missing or has a different hash. +func (r *SettlementRepository) ReconcileConfirmationEvidence(ctx context.Context, grace time.Duration) (ReconciliationStats, error) { + if grace <= 0 { + grace = 10 * time.Minute + } + tx, err := r.database.Begin(ctx) + if err != nil { + return ReconciliationStats{}, err + } + defer tx.Rollback(ctx) + var detected int64 + err = tx.QueryRow(ctx, `WITH inconsistent AS ( + SELECT s.id, + CASE WHEN e.id IS NULL OR e.blockchain_transaction_hash='' THEN 'MISSING_LEDGER_CONFIRMATION' ELSE 'TRANSACTION_HASH_MISMATCH' END issue_type, + s.transaction_hash expected_hash, COALESCE(e.blockchain_transaction_hash,'') actual_hash + FROM kuknos_settlements s + LEFT JOIN LATERAL ( + SELECT id, blockchain_transaction_hash FROM transaction_events + WHERE source_service=s.source_service AND source_transaction_id=s.source_transaction_id + ORDER BY event_version DESC, recorded_at DESC LIMIT 1 + ) e ON true + WHERE s.status='CONFIRMED' AND s.confirmed_at < clock_timestamp() - $1::interval + AND (e.id IS NULL OR e.blockchain_transaction_hash='' OR e.blockchain_transaction_hash<>s.transaction_hash) +), upserted AS ( + INSERT INTO kuknos_reconciliation_issues (settlement_id, issue_type, expected_hash, actual_hash) + SELECT id, issue_type, expected_hash, actual_hash FROM inconsistent + ON CONFLICT (settlement_id) DO UPDATE SET issue_type=EXCLUDED.issue_type, + expected_hash=EXCLUDED.expected_hash, actual_hash=EXCLUDED.actual_hash, + status='OPEN', last_detected_at=clock_timestamp(), resolved_at=NULL + WHERE kuknos_reconciliation_issues.status<>'OPEN' + OR kuknos_reconciliation_issues.issue_type<>EXCLUDED.issue_type + OR kuknos_reconciliation_issues.expected_hash<>EXCLUDED.expected_hash + OR kuknos_reconciliation_issues.actual_hash<>EXCLUDED.actual_hash + RETURNING 1 +) SELECT count(*) FROM upserted`, fmt.Sprintf("%f seconds", grace.Seconds())).Scan(&detected) + if err != nil { + return ReconciliationStats{}, fmt.Errorf("detect settlement reconciliation issues: %w", err) + } + result, err := tx.Exec(ctx, `UPDATE kuknos_reconciliation_issues i SET status='RESOLVED', resolved_at=clock_timestamp() + WHERE status='OPEN' AND EXISTS ( + SELECT 1 FROM kuknos_settlements s JOIN transaction_events e + ON e.source_service=s.source_service AND e.source_transaction_id=s.source_transaction_id + WHERE s.id=i.settlement_id AND s.status='CONFIRMED' + AND e.blockchain_transaction_hash=s.transaction_hash AND e.blockchain_transaction_hash<>'' + )`) + if err != nil { + return ReconciliationStats{}, fmt.Errorf("resolve settlement reconciliation issues: %w", err) + } + var open int64 + if err := tx.QueryRow(ctx, `SELECT count(*) FROM kuknos_reconciliation_issues WHERE status='OPEN'`).Scan(&open); err != nil { + return ReconciliationStats{}, err + } + if err := tx.Commit(ctx); err != nil { + return ReconciliationStats{}, err + } + return ReconciliationStats{Open: open, Detected: detected, Resolved: result.RowsAffected()}, nil +} diff --git a/infrastructure/postgres/settlement_repository_test.go b/infrastructure/postgres/settlement_repository_test.go new file mode 100644 index 0000000..d2f14ae --- /dev/null +++ b/infrastructure/postgres/settlement_repository_test.go @@ -0,0 +1,41 @@ +package postgres + +import ( + "context" + "testing" + "time" +) + +func TestReconcileConfirmationEvidenceCommitsDurableIssueStats(t *testing.T) { + tx := &fakeTx{rows: []Row{ + fakeRow{values: []any{int64(1)}}, + fakeRow{values: []any{int64(2)}}, + }} + repository := NewSettlementRepository(&fakeDatabase{tx: tx}) + + stats, err := repository.ReconcileConfirmationEvidence(context.Background(), 10*time.Minute) + if err != nil { + t.Fatal(err) + } + if stats.Detected != 1 || stats.Resolved != 1 || stats.Open != 2 { + t.Fatalf("unexpected reconciliation stats: %+v", stats) + } + if !tx.committed || tx.execCount != 1 { + t.Fatalf("unexpected transaction state: %+v", tx) + } +} + +func TestReconcileConfirmationEvidenceRollsBackResolutionFailure(t *testing.T) { + tx := &fakeTx{ + rows: []Row{fakeRow{values: []any{int64(1)}}}, + execErrAt: 1, + } + repository := NewSettlementRepository(&fakeDatabase{tx: tx}) + + if _, err := repository.ReconcileConfirmationEvidence(context.Background(), time.Minute); err == nil { + t.Fatal("expected resolution error") + } + if !tx.rolledBack || tx.committed { + t.Fatalf("failed reconciliation was not rolled back: %+v", tx) + } +} diff --git a/interface/grpc/health.go b/interface/grpc/health.go new file mode 100644 index 0000000..9b8b5e9 --- /dev/null +++ b/interface/grpc/health.go @@ -0,0 +1,47 @@ +package grpcadapter + +import ( + "context" + + "gl/application/health" + applicationledger "gl/application/ledger" + applicationsettlement "gl/application/settlement" + basev1 "gl/gen/base/v1" + ledgerv1 "gl/gen/ledger/v1" +) + +type Handler struct { + ledgerv1.UnimplementedGeneralLedgerServiceServer + health *health.Service + ledger *applicationledger.Service + settlement *applicationsettlement.Service + adminToken string +} + +func (h *Handler) WithSettlementAdminToken(token string) *Handler { h.adminToken = token; return h } + +func NewHandler(healthService *health.Service, ledgerService *applicationledger.Service, settlementService ...*applicationsettlement.Service) *Handler { + var settlement *applicationsettlement.Service + if len(settlementService) > 0 { + settlement = settlementService[0] + } + return &Handler{health: healthService, ledger: ledgerService, settlement: settlement} +} + +func NewHealthHandler(service *health.Service) *Handler { + return NewHandler(service, nil) +} + +func (h *Handler) Health(ctx context.Context, _ *basev1.Empty) (*ledgerv1.HealthResponse, error) { + result := h.health.Check(ctx) + return &ledgerv1.HealthResponse{ + Serving: result.Serving, + DatabaseReady: result.DatabaseReady, + SettlementEnabled: result.SettlementEnabled, + SettlementReady: result.SettlementReady, + SettlementPending: result.SettlementStats.Pending, + SettlementRetryable: result.SettlementStats.Retryable, + SettlementManualReview: result.SettlementStats.ManualReview, + OldestPendingSeconds: result.SettlementStats.OldestPendingSeconds, + }, nil +} diff --git a/interface/grpc/health_test.go b/interface/grpc/health_test.go new file mode 100644 index 0000000..e955442 --- /dev/null +++ b/interface/grpc/health_test.go @@ -0,0 +1,19 @@ +package grpcadapter + +import ( + "context" + "testing" + + "gl/application/health" + basev1 "gl/gen/base/v1" +) + +func TestHealth(t *testing.T) { + response, err := NewHealthHandler(health.NewService(nil)).Health(context.Background(), &basev1.Empty{}) + if err != nil { + t.Fatal(err) + } + if response.Serving || response.DatabaseReady { + t.Fatalf("unexpected response: %+v", response) + } +} diff --git a/interface/grpc/ledger.go b/interface/grpc/ledger.go new file mode 100644 index 0000000..acff151 --- /dev/null +++ b/interface/grpc/ledger.go @@ -0,0 +1,408 @@ +package grpcadapter + +import ( + "context" + "errors" + "time" + + applicationledger "gl/application/ledger" + domain "gl/domain/ledger" + ledgerv1 "gl/gen/ledger/v1" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func (h *Handler) AppendJournal(ctx context.Context, request *ledgerv1.AppendJournalRequest) (*ledgerv1.AppendJournalResponse, error) { + command, err := appendJournalCommand(request) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + result, err := h.ledger.AppendJournal(ctx, command) + if err != nil { + return nil, rpcError(err) + } + return &ledgerv1.AppendJournalResponse{ + Journal: journalMessage(result.Journal), + AlreadyExisted: result.AlreadyExisted, + }, nil +} + +func (h *Handler) AppendTransactionEvent(ctx context.Context, request *ledgerv1.AppendTransactionEventRequest) (*ledgerv1.AppendTransactionEventResponse, error) { + occurredAt, err := requiredTime(request.GetOccurredAt(), "occurred_at") + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + event, alreadyExisted, err := h.ledger.AppendEvent(ctx, applicationledger.AppendEventCommand{ + SourceService: request.GetSourceService(), + IdempotencyKey: request.GetIdempotencyKey(), + SourceTransactionID: request.GetSourceTransactionId(), + TrackingCode: request.GetTrackingCode(), + EventVersion: request.GetEventVersion(), + State: transactionState(request.GetState()), + ErrorCode: request.GetErrorCode(), + ErrorMessage: request.GetErrorMessage(), + OccurredAt: occurredAt, + CorrelationID: request.GetCorrelationId(), + ActorID: request.GetActorId(), + Blockchain: blockchainReference(request.GetBlockchain()), + Metadata: request.GetMetadata(), + }) + if err != nil { + return nil, rpcError(err) + } + return &ledgerv1.AppendTransactionEventResponse{ + Event: eventMessage(event), + AlreadyExisted: alreadyExisted, + }, nil +} + +func (h *Handler) GetJournal(ctx context.Context, request *ledgerv1.GetJournalRequest) (*ledgerv1.Journal, error) { + var journalID, idempotencyKey string + switch lookup := request.GetLookup().(type) { + case *ledgerv1.GetJournalRequest_JournalId: + journalID = lookup.JournalId + case *ledgerv1.GetJournalRequest_IdempotencyKey: + idempotencyKey = lookup.IdempotencyKey + } + journal, err := h.ledger.GetJournal(ctx, journalID, idempotencyKey) + if err != nil { + return nil, rpcError(err) + } + return journalMessage(journal), nil +} + +func (h *Handler) ListEntries(ctx context.Context, request *ledgerv1.ListEntriesRequest) (*ledgerv1.ListEntriesResponse, error) { + command := applicationledger.ListCommand{ + PageSize: request.GetPageSize(), + PageToken: request.GetPageToken(), + } + if request.Account != nil { + account := accountReference(request.Account) + command.Account = &account + } + if request.AssetId != nil { + assetID := request.GetAssetId() + command.AssetID = &assetID + } + var err error + if request.RecordedFrom != nil { + value, parseErr := requiredTime(request.RecordedFrom, "recorded_from") + if parseErr != nil { + return nil, status.Error(codes.InvalidArgument, parseErr.Error()) + } + command.RecordedFrom = &value + } + if request.RecordedTo != nil { + value, parseErr := requiredTime(request.RecordedTo, "recorded_to") + if parseErr != nil { + return nil, status.Error(codes.InvalidArgument, parseErr.Error()) + } + command.RecordedTo = &value + } + journals, nextPageToken, err := h.ledger.List(ctx, command) + if err != nil { + return nil, rpcError(err) + } + response := &ledgerv1.ListEntriesResponse{ + Journals: make([]*ledgerv1.Journal, 0, len(journals)), + NextPageToken: nextPageToken, + } + for _, journal := range journals { + response.Journals = append(response.Journals, journalMessage(journal)) + } + return response, nil +} + +func (h *Handler) GetBalance(ctx context.Context, request *ledgerv1.GetBalanceRequest) (*ledgerv1.GetBalanceResponse, error) { + if request.Account == nil { + return nil, status.Error(codes.InvalidArgument, "account is required") + } + asOf := time.Now().UTC() + if request.AsOf != nil { + value, err := requiredTime(request.AsOf, "as_of") + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + asOf = value + } + account := accountReference(request.Account) + balance, err := h.ledger.Balance(ctx, account, asOf) + if err != nil { + return nil, rpcError(err) + } + return &ledgerv1.GetBalanceResponse{ + Account: accountMessage(account), + Balance: balance.String(), + AsOf: timestamppb.New(asOf), + }, nil +} + +func (h *Handler) ReplayJournals(ctx context.Context, request *ledgerv1.ReplayJournalsRequest) (*ledgerv1.ReplayJournalsResponse, error) { + commands := make([]applicationledger.AppendJournalCommand, 0, len(request.GetJournals())) + for _, journal := range request.GetJournals() { + command, err := appendJournalCommand(journal) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + commands = append(commands, command) + } + results, err := h.ledger.Replay(ctx, commands) + if err != nil { + return nil, rpcError(err) + } + response := &ledgerv1.ReplayJournalsResponse{Results: make([]*ledgerv1.ReplayJournalResult, 0, len(results))} + for _, result := range results { + response.Results = append(response.Results, &ledgerv1.ReplayJournalResult{ + IdempotencyKey: result.Journal.IdempotencyKey, + Journal: journalMessage(result.Journal), + AlreadyExisted: result.AlreadyExisted, + }) + } + return response, nil +} + +func appendJournalCommand(request *ledgerv1.AppendJournalRequest) (applicationledger.AppendJournalCommand, error) { + occurredAt, err := requiredTime(request.GetOccurredAt(), "occurred_at") + if err != nil { + return applicationledger.AppendJournalCommand{}, err + } + command := applicationledger.AppendJournalCommand{ + SourceService: request.GetSourceService(), + IdempotencyKey: request.GetIdempotencyKey(), + SourceTransactionID: request.GetSourceTransactionId(), + TrackingCode: request.GetTrackingCode(), + EffectKind: request.GetEffectKind(), + EventVersion: request.GetEventVersion(), + ReversalOfJournalID: request.GetReversalOfJournalId(), + OccurredAt: occurredAt, + CorrelationID: request.GetCorrelationId(), + ActorID: request.GetActorId(), + Blockchain: blockchainReference(request.GetBlockchain()), + Metadata: request.GetMetadata(), + Entries: make([]applicationledger.EntryCommand, 0, len(request.GetEntries())), + } + for _, entry := range request.GetEntries() { + if entry.Account == nil { + return applicationledger.AppendJournalCommand{}, errors.New("entry account is required") + } + command.Entries = append(command.Entries, applicationledger.EntryCommand{ + LineNumber: entry.GetLineNumber(), + Account: accountReference(entry.Account), + Amount: entry.GetAmount(), + Description: entry.GetDescription(), + }) + } + return command, nil +} + +func requiredTime(value *timestamppb.Timestamp, field string) (time.Time, error) { + if value == nil { + return time.Time{}, errors.New(field + " is required") + } + if err := value.CheckValid(); err != nil { + return time.Time{}, errors.New(field + " is invalid: " + err.Error()) + } + return value.AsTime().UTC(), nil +} + +func accountReference(value *ledgerv1.AccountReference) domain.AccountReference { + return domain.AccountReference{ + Class: accountClass(value.GetAccountClass()), + OwnerType: value.GetOwnerType(), + OwnerID: value.GetOwnerId(), + AssetID: value.GetAssetId(), + } +} + +func accountClass(value ledgerv1.AccountClass) domain.AccountClass { + switch value { + case ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE: + return domain.AccountClassUserAvailable + case ledgerv1.AccountClass_ACCOUNT_CLASS_USER_FROZEN: + return domain.AccountClassUserFrozen + case ledgerv1.AccountClass_ACCOUNT_CLASS_EXTERNAL_BLOCKCHAIN: + return domain.AccountClassExternalBlockchain + case ledgerv1.AccountClass_ACCOUNT_CLASS_TREASURY: + return domain.AccountClassTreasury + case ledgerv1.AccountClass_ACCOUNT_CLASS_MARKET_CLEARING: + return domain.AccountClassMarketClearing + case ledgerv1.AccountClass_ACCOUNT_CLASS_IPG_CLEARING: + return domain.AccountClassIPGClearing + case ledgerv1.AccountClass_ACCOUNT_CLASS_COMMISSION_REVENUE: + return domain.AccountClassCommissionRevenue + default: + return "" + } +} + +func accountClassMessage(value domain.AccountClass) ledgerv1.AccountClass { + switch value { + case domain.AccountClassUserAvailable: + return ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE + case domain.AccountClassUserFrozen: + return ledgerv1.AccountClass_ACCOUNT_CLASS_USER_FROZEN + case domain.AccountClassExternalBlockchain: + return ledgerv1.AccountClass_ACCOUNT_CLASS_EXTERNAL_BLOCKCHAIN + case domain.AccountClassTreasury: + return ledgerv1.AccountClass_ACCOUNT_CLASS_TREASURY + case domain.AccountClassMarketClearing: + return ledgerv1.AccountClass_ACCOUNT_CLASS_MARKET_CLEARING + case domain.AccountClassIPGClearing: + return ledgerv1.AccountClass_ACCOUNT_CLASS_IPG_CLEARING + case domain.AccountClassCommissionRevenue: + return ledgerv1.AccountClass_ACCOUNT_CLASS_COMMISSION_REVENUE + default: + return ledgerv1.AccountClass_ACCOUNT_CLASS_UNSPECIFIED + } +} + +func transactionState(value ledgerv1.TransactionState) domain.TransactionState { + switch value { + case ledgerv1.TransactionState_TRANSACTION_STATE_CREATED: + return domain.TransactionStateCreated + case ledgerv1.TransactionState_TRANSACTION_STATE_PENDING_TRANSACTION: + return domain.TransactionStatePendingTransaction + case ledgerv1.TransactionState_TRANSACTION_STATE_PENDING_ADMIN: + return domain.TransactionStatePendingAdmin + case ledgerv1.TransactionState_TRANSACTION_STATE_SUCCESSFUL: + return domain.TransactionStateSuccessful + case ledgerv1.TransactionState_TRANSACTION_STATE_FAILED: + return domain.TransactionStateFailed + case ledgerv1.TransactionState_TRANSACTION_STATE_SUSPENDED: + return domain.TransactionStateSuspended + default: + return "" + } +} + +func transactionStateMessage(value domain.TransactionState) ledgerv1.TransactionState { + switch value { + case domain.TransactionStateCreated: + return ledgerv1.TransactionState_TRANSACTION_STATE_CREATED + case domain.TransactionStatePendingTransaction: + return ledgerv1.TransactionState_TRANSACTION_STATE_PENDING_TRANSACTION + case domain.TransactionStatePendingAdmin: + return ledgerv1.TransactionState_TRANSACTION_STATE_PENDING_ADMIN + case domain.TransactionStateSuccessful: + return ledgerv1.TransactionState_TRANSACTION_STATE_SUCCESSFUL + case domain.TransactionStateFailed: + return ledgerv1.TransactionState_TRANSACTION_STATE_FAILED + case domain.TransactionStateSuspended: + return ledgerv1.TransactionState_TRANSACTION_STATE_SUSPENDED + default: + return ledgerv1.TransactionState_TRANSACTION_STATE_UNSPECIFIED + } +} + +func blockchainReference(value *ledgerv1.BlockchainReference) domain.BlockchainReference { + if value == nil { + return domain.BlockchainReference{} + } + return domain.BlockchainReference{ + Network: value.GetNetwork(), + TransactionHash: value.GetTransactionHash(), + LedgerSequence: value.GetLedgerSequence(), + } +} + +func journalMessage(value domain.Journal) *ledgerv1.Journal { + message := &ledgerv1.Journal{ + JournalId: value.ID, + SourceService: value.SourceService, + IdempotencyKey: value.IdempotencyKey, + SourceTransactionId: value.SourceTransactionID, + TrackingCode: value.TrackingCode, + EffectKind: value.EffectKind, + EventVersion: value.EventVersion, + OccurredAt: timestampMessage(value.OccurredAt), + RecordedAt: timestampMessage(value.RecordedAt), + CorrelationId: value.CorrelationID, + ActorId: value.ActorID, + Blockchain: blockchainMessage(value.Blockchain), + Metadata: value.Metadata, + PayloadHash: value.PayloadHash, + Entries: make([]*ledgerv1.JournalEntry, 0, len(value.Entries)), + } + if value.ReversalOfJournalID != "" { + message.ReversalOfJournalId = &value.ReversalOfJournalID + } + for _, entry := range value.Entries { + message.Entries = append(message.Entries, &ledgerv1.JournalEntry{ + LineNumber: entry.LineNumber, + Account: accountMessage(entry.Account), + Amount: entry.Amount.String(), + Description: entry.Description, + }) + } + return message +} + +func eventMessage(value domain.TransactionEvent) *ledgerv1.TransactionEvent { + return &ledgerv1.TransactionEvent{ + EventId: value.ID, + Event: &ledgerv1.AppendTransactionEventRequest{ + SourceService: value.SourceService, + IdempotencyKey: value.IdempotencyKey, + SourceTransactionId: value.SourceTransactionID, + TrackingCode: value.TrackingCode, + EventVersion: value.EventVersion, + State: transactionStateMessage(value.State), + ErrorCode: value.ErrorCode, + ErrorMessage: value.ErrorMessage, + OccurredAt: timestampMessage(value.OccurredAt), + CorrelationId: value.CorrelationID, + ActorId: value.ActorID, + Blockchain: blockchainMessage(value.Blockchain), + Metadata: value.Metadata, + }, + RecordedAt: timestampMessage(value.RecordedAt), + PayloadHash: value.PayloadHash, + } +} + +func accountMessage(value domain.AccountReference) *ledgerv1.AccountReference { + return &ledgerv1.AccountReference{ + AccountClass: accountClassMessage(value.Class), + OwnerType: value.OwnerType, + OwnerId: value.OwnerID, + AssetId: value.AssetID, + } +} + +func blockchainMessage(value domain.BlockchainReference) *ledgerv1.BlockchainReference { + return &ledgerv1.BlockchainReference{ + Network: value.Network, + TransactionHash: value.TransactionHash, + LedgerSequence: value.LedgerSequence, + } +} + +func timestampMessage(value time.Time) *timestamppb.Timestamp { + if value.IsZero() { + return nil + } + return timestamppb.New(value) +} + +func rpcError(err error) error { + switch { + case errors.Is(err, applicationledger.ErrInvalidArgument): + return status.Error(codes.InvalidArgument, err.Error()) + case errors.Is(err, domain.ErrNotFound): + return status.Error(codes.NotFound, err.Error()) + case errors.Is(err, domain.ErrIdempotencyConflict): + return status.Error(codes.AlreadyExists, err.Error()) + case errors.Is(err, domain.ErrIncompleteJournal): + return status.Error(codes.FailedPrecondition, err.Error()) + case errors.Is(err, domain.ErrAlreadyReversed): + return status.Error(codes.FailedPrecondition, err.Error()) + case errors.Is(err, context.Canceled): + return status.Error(codes.Canceled, err.Error()) + case errors.Is(err, context.DeadlineExceeded): + return status.Error(codes.DeadlineExceeded, err.Error()) + default: + return status.Error(codes.Internal, "internal ledger error") + } +} diff --git a/interface/grpc/ledger_test.go b/interface/grpc/ledger_test.go new file mode 100644 index 0000000..6cec019 --- /dev/null +++ b/interface/grpc/ledger_test.go @@ -0,0 +1,123 @@ +package grpcadapter + +import ( + "context" + "errors" + "testing" + "time" + + applicationledger "gl/application/ledger" + domain "gl/domain/ledger" + ledgerv1 "gl/gen/ledger/v1" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type ledgerRepositoryStub struct { + journal domain.Journal +} + +func (r *ledgerRepositoryStub) Append(_ context.Context, journal domain.Journal) (domain.AppendResult, error) { + journal.RecordedAt = time.Unix(2, 0).UTC() + r.journal = journal + return domain.AppendResult{JournalID: journal.ID}, nil +} + +func (r *ledgerRepositoryStub) AppendEvent(_ context.Context, event domain.TransactionEvent) (domain.TransactionEvent, bool, error) { + event.RecordedAt = time.Unix(2, 0).UTC() + return event, false, nil +} + +func (r *ledgerRepositoryStub) GetByID(context.Context, string) (domain.Journal, error) { + return r.journal, nil +} + +func (r *ledgerRepositoryStub) GetByIdempotencyKey(context.Context, string) (domain.Journal, error) { + return r.journal, nil +} + +func (r *ledgerRepositoryStub) List(context.Context, domain.JournalFilter) ([]domain.Journal, error) { + return []domain.Journal{r.journal}, nil +} + +func (r *ledgerRepositoryStub) Balance(context.Context, domain.AccountReference, time.Time) (domain.Amount, error) { + return domain.ParseAmount("12.5") +} + +func TestAppendJournalMapsProtoToExactDomainAndBack(t *testing.T) { + repository := &ledgerRepositoryStub{} + service := applicationledger.NewService(repository, func() (string, error) { + return "11111111-1111-4111-8111-111111111111", nil + }) + handler := NewHandler(nil, service) + + response, err := handler.AppendJournal(context.Background(), &ledgerv1.AppendJournalRequest{ + SourceService: "wallet", + IdempotencyKey: "wallet:1:internal-transfer:v1", + SourceTransactionId: "1", + EffectKind: "internal-transfer", + EventVersion: 1, + OccurredAt: timestamppb.New(time.Unix(1, 0).UTC()), + Entries: []*ledgerv1.JournalEntry{ + { + LineNumber: 1, + Account: &ledgerv1.AccountReference{ + AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE, + OwnerType: "user", + OwnerId: "1", + AssetId: 5, + }, + Amount: "-1.2500", + }, + { + LineNumber: 2, + Account: &ledgerv1.AccountReference{ + AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE, + OwnerType: "user", + OwnerId: "2", + AssetId: 5, + }, + Amount: "1.25", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if got := response.GetJournal().GetEntries()[0].GetAmount(); got != "-1.25" { + t.Fatalf("unexpected canonical amount: %q", got) + } + if response.GetJournal().GetPayloadHash() == "" { + t.Fatal("payload hash is missing") + } +} + +func TestAppendJournalRejectsMissingTimestamp(t *testing.T) { + handler := NewHandler(nil, nil) + _, err := handler.AppendJournal(context.Background(), &ledgerv1.AppendJournalRequest{}) + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("expected InvalidArgument, got %v", err) + } +} + +func TestRPCErrorMapping(t *testing.T) { + for _, testCase := range []struct { + err error + code codes.Code + }{ + {err: applicationledger.ErrInvalidArgument, code: codes.InvalidArgument}, + {err: domain.ErrNotFound, code: codes.NotFound}, + {err: domain.ErrIdempotencyConflict, code: codes.AlreadyExists}, + {err: domain.ErrIncompleteJournal, code: codes.FailedPrecondition}, + {err: domain.ErrAlreadyReversed, code: codes.FailedPrecondition}, + {err: context.Canceled, code: codes.Canceled}, + {err: context.DeadlineExceeded, code: codes.DeadlineExceeded}, + {err: errors.New("database details must not escape"), code: codes.Internal}, + } { + if got := status.Code(rpcError(testCase.err)); got != testCase.code { + t.Fatalf("rpcError(%v) = %v, want %v", testCase.err, got, testCase.code) + } + } +} diff --git a/interface/grpc/server.go b/interface/grpc/server.go new file mode 100644 index 0000000..877a63f --- /dev/null +++ b/interface/grpc/server.go @@ -0,0 +1,110 @@ +package grpcadapter + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "runtime/debug" + "time" + + ledgerv1 "gl/gen/ledger/v1" + + "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/reflection" + "google.golang.org/grpc/status" +) + +type ServerConfig struct { + Host string + Port int + ShutdownTimeout time.Duration +} + +func Run(ctx context.Context, cfg ServerConfig, handler ledgerv1.GeneralLedgerServiceServer) error { + listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)) + if err != nil { + return fmt.Errorf("listen for grpc: %w", err) + } + return runWithListener(ctx, cfg.ShutdownTimeout, listener, handler) +} + +func runWithListener(ctx context.Context, shutdownTimeout time.Duration, listener net.Listener, handler ledgerv1.GeneralLedgerServiceServer) error { + defer listener.Close() + + server := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler()), grpc.ChainUnaryInterceptor(structuredUnaryLogger(), panicRecovery())) + ledgerv1.RegisterGeneralLedgerServiceServer(server, handler) + reflection.Register(server) + + serveErr := make(chan error, 1) + go func() { serveErr <- server.Serve(listener) }() + + select { + case err := <-serveErr: + if errors.Is(err, grpc.ErrServerStopped) { + return nil + } + return fmt.Errorf("serve grpc: %w", err) + case <-ctx.Done(): + } + + stopped := make(chan struct{}) + go func() { + server.GracefulStop() + close(stopped) + }() + + timer := time.NewTimer(shutdownTimeout) + defer timer.Stop() + select { + case <-stopped: + case <-timer.C: + server.Stop() + <-stopped + } + + err := <-serveErr + if err != nil && !errors.Is(err, grpc.ErrServerStopped) { + return fmt.Errorf("serve grpc: %w", err) + } + return nil +} + +func structuredUnaryLogger() grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + started := time.Now() + response, err := handler(ctx, req) + code := status.Code(err) + level := slog.LevelInfo + if code != codes.OK { + level = slog.LevelError + } + slog.Log(ctx, level, "grpc call finished", + "component", "grpc_server", + "grpc_method", info.FullMethod, + "grpc_code", code.String(), + "duration_ms", float64(time.Since(started).Microseconds())/1000, + ) + return response, err + } +} + +func panicRecovery() grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (response any, err error) { + defer func() { + if recovered := recover(); recovered != nil { + slog.ErrorContext(ctx, "grpc panic recovered", + "component", "grpc_server", + "grpc_method", info.FullMethod, + "panic", recovered, + "stack", string(debug.Stack()), + ) + err = status.Error(codes.Internal, "internal server error") + } + }() + return handler(ctx, req) + } +} diff --git a/interface/grpc/server_test.go b/interface/grpc/server_test.go new file mode 100644 index 0000000..fff9bc7 --- /dev/null +++ b/interface/grpc/server_test.go @@ -0,0 +1,26 @@ +package grpcadapter + +import ( + "context" + "testing" + "time" + + "gl/application/health" + + "google.golang.org/grpc/test/bufconn" +) + +func TestRunStopsWhenContextIsCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := runWithListener( + ctx, + time.Second, + bufconn.Listen(1024), + NewHealthHandler(health.NewService(nil)), + ) + if err != nil { + t.Fatal(err) + } +} diff --git a/interface/grpc/settlement.go b/interface/grpc/settlement.go new file mode 100644 index 0000000..6411ca2 --- /dev/null +++ b/interface/grpc/settlement.go @@ -0,0 +1,60 @@ +package grpcadapter + +import ( + "context" + "crypto/subtle" + + "gl/domain/ledger" + ledgerv1 "gl/gen/ledger/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +func (h *Handler) EnqueueSettlement(ctx context.Context, request *ledgerv1.EnqueueSettlementRequest) (*ledgerv1.EnqueueSettlementResponse, error) { + if h.settlement == nil { + return nil, status.Error(codes.Unimplemented, "settlement is not configured") + } + record, already, err := h.settlement.Enqueue(ctx, request.GetSourceService(), request.GetSourceTransactionId(), request.GetIdempotencyKey(), request.GetNetwork(), request.GetSignedTransactionXdr()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + return &ledgerv1.EnqueueSettlementResponse{SettlementId: record.ID, Status: string(record.Status), AlreadyExisted: already}, nil +} + +func (h *Handler) RetrySettlement(ctx context.Context, request *ledgerv1.RetrySettlementRequest) (*ledgerv1.RetrySettlementResponse, error) { + if h.settlement == nil { + return nil, status.Error(codes.Unimplemented, "settlement is not configured") + } + values := metadata.ValueFromIncomingContext(ctx, "x-admin-token") + if h.adminToken == "" || len(values) != 1 || subtle.ConstantTimeCompare([]byte(values[0]), []byte(h.adminToken)) != 1 { + return nil, status.Error(codes.PermissionDenied, "invalid administrative token") + } + record, err := h.settlement.RetryByOperator(ctx, request.GetSettlementId(), request.GetActorId(), request.GetReason()) + if err != nil { + return nil, status.Error(codes.FailedPrecondition, err.Error()) + } + return &ledgerv1.RetrySettlementResponse{Settlement: settlementMessage(record)}, nil +} + +func settlementMessage(record ledger.Settlement) *ledgerv1.Settlement { + return &ledgerv1.Settlement{SettlementId: record.ID, SourceService: record.SourceService, SourceTransactionId: record.SourceTxID, IdempotencyKey: record.IdempotencyKey, Status: string(record.Status), Network: record.Network, TransactionHash: record.TransactionHash, Attempts: uint32(record.Attempts), LastError: record.LastError} +} + +func (h *Handler) GetSettlement(ctx context.Context, request *ledgerv1.GetSettlementRequest) (*ledgerv1.Settlement, error) { + if h.settlement == nil { + return nil, status.Error(codes.Unimplemented, "settlement is not configured") + } + var id, key string + switch lookup := request.GetLookup().(type) { + case *ledgerv1.GetSettlementRequest_SettlementId: + id = lookup.SettlementId + case *ledgerv1.GetSettlementRequest_IdempotencyKey: + key = lookup.IdempotencyKey + } + record, err := h.settlement.Get(ctx, id, key) + if err != nil { + return nil, status.Error(codes.NotFound, err.Error()) + } + return settlementMessage(record), nil +} diff --git a/interface/web/assets.go b/interface/web/assets.go new file mode 100644 index 0000000..ee9a90b --- /dev/null +++ b/interface/web/assets.go @@ -0,0 +1,25 @@ +package web + +import ( + "bytes" + _ "embed" + "net/http" + "time" +) + +//go:embed assets/favicon.ico +var favicon []byte + +//go:embed assets/theme.js +var themeScript []byte + +func (h *Handler) favicon(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Cache-Control", "public, max-age=86400") + http.ServeContent(response, request, "favicon.ico", time.Time{}, bytes.NewReader(favicon)) +} + +func (h *Handler) theme(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Cache-Control", "public, max-age=3600") + response.Header().Set("Content-Type", "application/javascript; charset=utf-8") + http.ServeContent(response, request, "theme.js", time.Time{}, bytes.NewReader(themeScript)) +} diff --git a/interface/web/assets/favicon.ico b/interface/web/assets/favicon.ico new file mode 100644 index 0000000..545e58f Binary files /dev/null and b/interface/web/assets/favicon.ico differ diff --git a/interface/web/assets/theme.js b/interface/web/assets/theme.js new file mode 100644 index 0000000..70e7d34 --- /dev/null +++ b/interface/web/assets/theme.js @@ -0,0 +1,54 @@ +(() => { + const storageKey = "gl-theme"; + const root = document.documentElement; + + const storedTheme = () => { + try { + const value = localStorage.getItem(storageKey); + return value === "dark" || value === "light" ? value : ""; + } catch (_) { + return ""; + } + }; + + const systemTheme = () => + window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + + const updateControls = (theme) => { + document.querySelectorAll("[data-theme-toggle]").forEach((control) => { + const dark = theme === "dark"; + control.setAttribute("aria-pressed", String(dark)); + control.setAttribute("aria-label", dark ? control.dataset.lightLabel : control.dataset.darkLabel); + const icon = control.querySelector("[data-theme-icon]"); + if (icon) icon.textContent = dark ? "☀" : "☾"; + }); + }; + + const applyTheme = (theme) => { + root.dataset.theme = theme; + root.style.colorScheme = theme; + updateControls(theme); + }; + + applyTheme(storedTheme() || systemTheme()); + + document.addEventListener("DOMContentLoaded", () => updateControls(root.dataset.theme)); + document.addEventListener("htmx:afterSwap", () => updateControls(root.dataset.theme)); + document.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) return; + const control = event.target.closest("[data-theme-toggle]"); + if (!control) return; + const theme = root.dataset.theme === "dark" ? "light" : "dark"; + try { + localStorage.setItem(storageKey, theme); + } catch (_) { + // The selected theme still applies for this page when storage is unavailable. + } + applyTheme(theme); + }); + + const media = window.matchMedia("(prefers-color-scheme: dark)"); + media.addEventListener?.("change", () => { + if (!storedTheme()) applyTheme(systemTheme()); + }); +})(); diff --git a/interface/web/handler.go b/interface/web/handler.go new file mode 100644 index 0000000..14aed1d --- /dev/null +++ b/interface/web/handler.go @@ -0,0 +1,318 @@ +package web + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "syscall" + "time" + _ "time/tzdata" + + "gl/application/explorer" + "gl/domain/ledger" + + "github.com/a-h/templ" +) + +type Explorer interface { + Dashboard(context.Context) (explorer.Dashboard, error) + Assets(context.Context) (explorer.Assets, error) + Transactions(context.Context, explorer.TransactionFilter) (explorer.TransactionListing, error) + Transaction(context.Context, string) ([]ledger.Journal, error) + Account(context.Context, ledger.AccountReference) (explorer.Account, error) + Holder(context.Context, explorer.HolderReference) (explorer.HolderAccount, error) +} + +type Handler struct { + explorer Explorer + routes *http.ServeMux +} + +type TransactionPageData struct { + Query string + Journals []ledger.Journal + Listing *explorer.TransactionListing + Error string +} + +type AccountInput struct { + Class string + OwnerType string + OwnerID string + AssetID string +} + +type AccountPageData struct { + Input AccountInput + Account *explorer.Account + Error string +} + +type HolderPageData struct { + Account *explorer.HolderAccount + Error string +} + +func NewHandler(service Explorer) *Handler { + handler := &Handler{explorer: service, routes: http.NewServeMux()} + handler.routes.HandleFunc("GET /favicon.ico", handler.favicon) + handler.routes.HandleFunc("GET /theme.js", handler.theme) + handler.routes.HandleFunc("GET /{$}", handler.dashboard) + handler.routes.HandleFunc("GET /assets", handler.assets) + handler.routes.HandleFunc("GET /transactions", handler.transaction) + handler.routes.HandleFunc("GET /transactions/{reference}", handler.transaction) + handler.routes.HandleFunc("GET /accounts", handler.account) + handler.routes.HandleFunc("GET /holders", handler.holder) + return handler +} + +func (h *Handler) assets(response http.ResponseWriter, request *http.Request) { + locale := localeForRequest(response, request) + data, err := h.explorer.Assets(request.Context()) + if err != nil { + h.renderFailure(response, request, locale, tr(locale, "assets_unavailable"), err) + return + } + h.render(response, request, http.StatusOK, locale, tr(locale, "title_assets"), "assets", AssetsContent(data, locale)) +} + +func (h *Handler) ServeHTTP(response http.ResponseWriter, request *http.Request) { + response.Header().Set("X-Content-Type-Options", "nosniff") + response.Header().Set("Referrer-Policy", "same-origin") + response.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'") + h.routes.ServeHTTP(response, request) +} + +func (h *Handler) dashboard(response http.ResponseWriter, request *http.Request) { + locale := localeForRequest(response, request) + data, err := h.explorer.Dashboard(request.Context()) + if err != nil { + h.renderFailure(response, request, locale, tr(locale, "dashboard_unavailable"), err) + return + } + h.render(response, request, http.StatusOK, locale, tr(locale, "title_overview"), "dashboard", DashboardContent(data, locale)) +} + +func (h *Handler) transaction(response http.ResponseWriter, request *http.Request) { + locale := localeForRequest(response, request) + query := strings.TrimSpace(request.PathValue("reference")) + if query == "" { + query = strings.TrimSpace(request.URL.Query().Get("q")) + } + data := TransactionPageData{Query: query} + showLatest := query == "" + if query != "" { + journals, err := h.explorer.Transaction(request.Context(), query) + switch { + case err == nil: + data.Journals = journals + case requestEnded(request, err): + return + case explorer.IsNotFound(err): + data.Error = tr(locale, "transaction_missing") + showLatest = true + default: + data.Error = tr(locale, "transaction_lookup_error") + } + } + if showLatest { + page, parseErr := strconv.Atoi(strings.TrimSpace(request.URL.Query().Get("page"))) + if parseErr != nil || page < 1 { + page = 1 + } + listing, err := h.explorer.Transactions(request.Context(), explorer.TransactionFilter{ + Page: page, + Wallet: request.URL.Query().Get("wallet"), + EffectKind: request.URL.Query().Get("effect"), + }) + if requestEnded(request, err) { + return + } else if err != nil { + h.renderFailure(response, request, locale, tr(locale, "transactions_unavailable"), err) + return + } + data.Listing = &listing + } + h.render(response, request, http.StatusOK, locale, tr(locale, "title_transactions"), "transactions", TransactionContent(data, locale)) +} + +func (h *Handler) account(response http.ResponseWriter, request *http.Request) { + locale := localeForRequest(response, request) + data := AccountPageData{Input: AccountInput{ + Class: strings.TrimSpace(request.URL.Query().Get("class")), + OwnerType: strings.TrimSpace(request.URL.Query().Get("owner_type")), + OwnerID: strings.TrimSpace(request.URL.Query().Get("owner_id")), + AssetID: strings.TrimSpace(request.URL.Query().Get("asset_id")), + }} + if request.URL.Query().Has("class") { + assetID, err := strconv.ParseInt(data.Input.AssetID, 10, 64) + if err != nil || assetID <= 0 { + data.Error = tr(locale, "asset_positive") + } else { + result, lookupErr := h.explorer.Account(request.Context(), ledger.AccountReference{ + Class: ledger.AccountClass(data.Input.Class), + OwnerType: data.Input.OwnerType, + OwnerID: data.Input.OwnerID, + AssetID: assetID, + }) + if requestEnded(request, lookupErr) { + return + } else if lookupErr != nil { + data.Error = tr(locale, "account_lookup_error") + } else { + data.Account = &result + } + } + } + h.render(response, request, http.StatusOK, locale, tr(locale, "title_accounts"), "accounts", AccountContent(data, locale)) +} + +func (h *Handler) holder(response http.ResponseWriter, request *http.Request) { + locale := localeForRequest(response, request) + data := HolderPageData{} + assetID, err := strconv.ParseInt(strings.TrimSpace(request.URL.Query().Get("asset_id")), 10, 64) + reference := explorer.HolderReference{ + OwnerType: strings.TrimSpace(request.URL.Query().Get("owner_type")), + OwnerID: strings.TrimSpace(request.URL.Query().Get("owner_id")), + AssetID: assetID, + } + if err != nil || reference.OwnerType == "" || reference.OwnerID == "" || reference.AssetID <= 0 { + data.Error = tr(locale, "holder_reference_invalid") + } else { + result, lookupErr := h.explorer.Holder(request.Context(), reference) + if requestEnded(request, lookupErr) { + return + } else if lookupErr != nil { + data.Error = tr(locale, "account_lookup_error") + } else { + data.Account = &result + } + } + h.render(response, request, http.StatusOK, locale, tr(locale, "title_holder"), "assets", HolderContent(data, locale)) +} + +func (h *Handler) render(response http.ResponseWriter, request *http.Request, status int, locale Locale, title, active string, content templ.Component) { + component := Page(title, active, locale, localeURL(request, LocaleEnglish), localeURL(request, LocalePersian), content) + if request.Header.Get("HX-Request") == "true" && request.Header.Get("HX-History-Restore-Request") != "true" { + component = content + } + response.Header().Set("Content-Type", "text/html; charset=utf-8") + response.WriteHeader(status) + if err := component.Render(request.Context(), response); err != nil { + if requestEnded(request, err) { + return + } + slog.Error("render explorer", "error", err) + } +} + +func (h *Handler) renderFailure(response http.ResponseWriter, request *http.Request, locale Locale, title string, err error) { + if requestEnded(request, err) { + return + } + slog.Error("explorer request failed", "error", err) + h.render(response, request, http.StatusInternalServerError, locale, title, "", FailureContent(title, tr(locale, "read_model_unavailable"), locale)) +} + +func requestEnded(request *http.Request, err error) bool { + return request.Context().Err() != nil || + errors.Is(err, context.Canceled) || + errors.Is(err, syscall.EPIPE) || + errors.Is(err, syscall.ECONNRESET) +} + +func accountClasses() []ledger.AccountClass { + return []ledger.AccountClass{ + ledger.AccountClassUserAvailable, + ledger.AccountClassUserFrozen, + ledger.AccountClassExternalBlockchain, + ledger.AccountClassTreasury, + ledger.AccountClassMarketClearing, + ledger.AccountClassIPGClearing, + ledger.AccountClassCommissionRevenue, + } +} + +func formatTime(value time.Time) string { + if value.IsZero() || value.Unix() == 0 { + return "—" + } + return value.In(defaultTimezone).Format("02 Jan 2006 · 15:04:05") + " Asia/Tehran" +} + +func short(value string, size int) string { + if len(value) <= size { + return value + } + left := size / 2 + return value[:left] + "…" + value[len(value)-(size-left):] +} + +func count(value int64) string { + text := strconv.FormatInt(value, 10) + for index := len(text) - 3; index > 0; index -= 3 { + text = text[:index] + "," + text[index:] + } + return text +} + +func transactionName(journal ledger.Journal, locale Locale) string { + if journal.Blockchain.TransactionHash != "" { + return short(journal.Blockchain.TransactionHash, 22) + } + return tr(locale, "internal") + " · " + short(journal.ID, 14) +} + +func transactionReference(journal ledger.Journal) string { + if journal.Blockchain.TransactionHash != "" { + return journal.Blockchain.TransactionHash + } + return journal.ID +} + +func transactionURL(reference string) string { + return "/transactions/" + url.PathEscape(reference) +} + +func transactionPageURL(filter explorer.TransactionFilter, page int) string { + values := url.Values{"page": []string{strconv.Itoa(page)}} + if filter.Wallet != "" { + values.Set("wallet", filter.Wallet) + } + if filter.EffectKind != "" { + values.Set("effect", filter.EffectKind) + } + return "/transactions?" + values.Encode() +} + +func accountURL(account ledger.AccountReference) string { + values := url.Values{ + "class": []string{string(account.Class)}, + "owner_type": []string{account.OwnerType}, + "owner_id": []string{account.OwnerID}, + "asset_id": []string{strconv.FormatInt(account.AssetID, 10)}, + } + return "/accounts?" + values.Encode() +} + +func holderURL(holder explorer.Holder) string { + values := url.Values{ + "owner_type": []string{holder.OwnerType}, + "owner_id": []string{holder.OwnerID}, + "asset_id": []string{strconv.FormatInt(holder.AssetID, 10)}, + } + return "/holders?" + values.Encode() +} + +func accountName(account ledger.AccountReference, locale Locale) string { + owner := account.OwnerID + if owner == "" { + owner = tr(locale, "system") + } + return fmt.Sprintf("%s / %s", account.Class, owner) +} diff --git a/interface/web/handler_test.go b/interface/web/handler_test.go new file mode 100644 index 0000000..fb830d9 --- /dev/null +++ b/interface/web/handler_test.go @@ -0,0 +1,301 @@ +package web + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "gl/application/explorer" + "gl/domain/ledger" +) + +type explorerStub struct { + dashboard explorer.Dashboard + dashboardErr error + assets explorer.Assets + assetsErr error + listing explorer.TransactionListing + listingErr error + transaction []ledger.Journal + transactionErr error + account explorer.Account + accountErr error + holder explorer.HolderAccount + holderErr error + lastHash string + lastFilter explorer.TransactionFilter + lastAccount ledger.AccountReference +} + +func (s *explorerStub) Dashboard(context.Context) (explorer.Dashboard, error) { + return s.dashboard, s.dashboardErr +} + +func (s *explorerStub) Assets(context.Context) (explorer.Assets, error) { + return s.assets, s.assetsErr +} + +func (s *explorerStub) Transactions(_ context.Context, filter explorer.TransactionFilter) (explorer.TransactionListing, error) { + s.lastFilter = filter + return s.listing, s.listingErr +} + +func (s *explorerStub) Transaction(_ context.Context, hash string) ([]ledger.Journal, error) { + s.lastHash = hash + return s.transaction, s.transactionErr +} + +func (s *explorerStub) Account(_ context.Context, account ledger.AccountReference) (explorer.Account, error) { + s.lastAccount = account + return s.account, s.accountErr +} + +func (s *explorerStub) Holder(_ context.Context, reference explorer.HolderReference) (explorer.HolderAccount, error) { + s.holder.Reference = reference + return s.holder, s.holderErr +} + +func TestDashboardRendersFullTemplPage(t *testing.T) { + service := &explorerStub{dashboard: explorer.Dashboard{ + Stats: explorer.Stats{JournalCount: 1234, LastRecordedAt: time.Unix(10, 0).UTC()}, + Journals: []ledger.Journal{{ + ID: "journal-1", EffectKind: "deposit", SourceService: "wallet", + Blockchain: ledger.BlockchainReference{TransactionHash: "abc123"}, + }}, + SettlementStats: explorer.SettlementStats{Confirmed: 9, Pending: 2}, + Settlements: []explorer.Settlement{{ID: "settlement-1", Status: ledger.SettlementConfirmed, Network: "kuknos", TransactionHash: "chain-hash", SourceService: "wallet", Attempts: 1}}, + }} + request := httptest.NewRequest(http.MethodGet, "/", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + body := response.Body.String() + for _, expected := range []string{"", "DARANO", "1,234", "abc123", "Blockchain settlements", "Confirmed on chain", "chain-hash", "htmx.org@2.0.10", "src=\"/theme.js\"", "data-theme-toggle", "html[data-theme=\"dark\"]", "class=\"mark\" src=\"/favicon.ico\"", "#F5F5F5", "#DFF1F1", "#BBD5DA", "#FF0000"} { + if !strings.Contains(body, expected) { + t.Fatalf("response did not contain %q", expected) + } + } + if response.Header().Get("Content-Security-Policy") == "" { + t.Fatal("expected content security policy") + } +} + +func TestDashboardLinksInternalTransactionsByJournalID(t *testing.T) { + const journalID = "5c1e31b0-0000-4000-8000-0000084accc8" + service := &explorerStub{dashboard: explorer.Dashboard{Journals: []ledger.Journal{{ID: journalID}}}} + request := httptest.NewRequest(http.MethodGet, "/", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + if !strings.Contains(response.Body.String(), `href="/transactions/`+journalID+`"`) { + t.Fatalf("internal transaction was not linked: %s", response.Body.String()) + } +} + +func TestAssetsPageRendersLinkedTopAssetHolders(t *testing.T) { + balance, err := ledger.ParseAmount("987.25") + if err != nil { + t.Fatal(err) + } + service := &explorerStub{assets: explorer.Assets{TopHolders: []explorer.Holder{{ + Rank: 1, OwnerType: "user", OwnerID: "holder-42", AssetID: 7, Balance: balance, + }}}} + request := httptest.NewRequest(http.MethodGet, "/assets", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + body := response.Body.String() + for _, expected := range []string{"Top asset holders", "holder-42", "Asset 7", "987.25", "/holders?asset_id=7&owner_id=holder-42&owner_type=user"} { + if !strings.Contains(body, expected) { + t.Fatalf("top holder response did not contain %q: %s", expected, body) + } + } +} + +func TestHolderPageLoadsAggregateAccountDetail(t *testing.T) { + balance, err := ledger.ParseAmount("42.5") + if err != nil { + t.Fatal(err) + } + service := &explorerStub{holder: explorer.HolderAccount{Balance: balance}} + request := httptest.NewRequest(http.MethodGet, "/holders?owner_type=user&owner_id=holder-42&asset_id=7", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + body := response.Body.String() + for _, expected := range []string{"holder-42", "Asset 7", "42.5", "AVAILABLE + FROZEN"} { + if !strings.Contains(body, expected) { + t.Fatalf("holder response did not contain %q: %s", expected, body) + } + } +} + +func TestDashboardDefaultsToEnglishAndSupportsPersian(t *testing.T) { + service := &explorerStub{} + handler := NewHandler(service) + + englishRequest := httptest.NewRequest(http.MethodGet, "/", nil) + englishResponse := httptest.NewRecorder() + handler.ServeHTTP(englishResponse, englishRequest) + if !strings.Contains(englishResponse.Body.String(), `lang="en" dir="ltr"`) || !strings.Contains(englishResponse.Body.String(), "Every movement") { + t.Fatalf("dashboard did not default to English: %s", englishResponse.Body.String()) + } + + persianRequest := httptest.NewRequest(http.MethodGet, "/?lang=fa", nil) + persianResponse := httptest.NewRecorder() + handler.ServeHTTP(persianResponse, persianRequest) + if !strings.Contains(persianResponse.Body.String(), `lang="fa" dir="rtl"`) || !strings.Contains(persianResponse.Body.String(), "هر جابه‌جایی") { + t.Fatalf("dashboard did not render Persian: %s", persianResponse.Body.String()) + } + if !strings.Contains(persianResponse.Header().Get("Set-Cookie"), "gl_locale=fa") { + t.Fatal("Persian locale was not persisted") + } +} + +func TestDashboardUsesLocaleCookie(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/transactions", nil) + request.AddCookie(&http.Cookie{Name: localeCookie, Value: "fa"}) + response := httptest.NewRecorder() + + NewHandler(&explorerStub{}).ServeHTTP(response, request) + + if !strings.Contains(response.Body.String(), "کاوشگر تراکنش") { + t.Fatalf("locale cookie was ignored: %s", response.Body.String()) + } +} + +func TestFaviconIsServedFromEmbeddedBrandAsset(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil) + response := httptest.NewRecorder() + + NewHandler(&explorerStub{}).ServeHTTP(response, request) + + if response.Code != http.StatusOK || response.Body.Len() < 1000 { + t.Fatalf("unexpected favicon response: status=%d bytes=%d", response.Code, response.Body.Len()) + } + if !strings.Contains(response.Header().Get("Content-Type"), "image/") { + t.Fatalf("unexpected favicon content type: %s", response.Header().Get("Content-Type")) + } +} + +func TestThemeScriptIsServedFromEmbeddedAsset(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/theme.js", nil) + response := httptest.NewRecorder() + + NewHandler(&explorerStub{}).ServeHTTP(response, request) + + body := response.Body.String() + if response.Code != http.StatusOK || !strings.Contains(body, "localStorage") || !strings.Contains(body, "prefers-color-scheme") { + t.Fatalf("unexpected theme script response: status=%d body=%s", response.Code, body) + } + if !strings.Contains(response.Header().Get("Content-Type"), "javascript") { + t.Fatalf("unexpected theme script content type: %s", response.Header().Get("Content-Type")) + } +} + +func TestTransactionHTMXRequestRendersOnlyExplorerContent(t *testing.T) { + service := &explorerStub{transaction: []ledger.Journal{{ + ID: "journal-1", EffectKind: "withdrawal", + Blockchain: ledger.BlockchainReference{TransactionHash: "tx-hash"}, + }}} + request := httptest.NewRequest(http.MethodGet, "/transactions?q=tx-hash", nil) + request.Header.Set("HX-Request", "true") + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + body := response.Body.String() + if strings.Contains(body, "") || !strings.Contains(body, `id="explorer-content"`) { + t.Fatalf("unexpected HTMX fragment: %s", body) + } + if service.lastHash != "tx-hash" || !strings.Contains(body, "COMMITTED") { + t.Fatalf("transaction was not rendered: %s", body) + } +} + +func TestTransactionsPageShowsLatestTransactionsWithPagination(t *testing.T) { + service := &explorerStub{listing: explorer.TransactionListing{ + Journals: []ledger.Journal{{ID: "latest-journal", EffectKind: "transfer"}}, + Filter: explorer.TransactionFilter{Page: 2, Wallet: "wallet-42", EffectKind: "transfer"}, + HasPrevious: true, HasNext: true, + }} + request := httptest.NewRequest(http.MethodGet, "/transactions?page=2&wallet=wallet-42&effect=transfer", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + body := response.Body.String() + for _, expected := range []string{"Latest transactions", "latest-journal", "value=\"wallet-42\"", "value=\"transfer\"", "/transactions?effect=transfer&page=1&wallet=wallet-42", "/transactions?effect=transfer&page=3&wallet=wallet-42"} { + if !strings.Contains(body, expected) { + t.Fatalf("transaction listing did not contain %q: %s", expected, body) + } + } + if service.lastFilter.Page != 2 || service.lastFilter.Wallet != "wallet-42" || service.lastFilter.EffectKind != "transfer" { + t.Fatalf("unexpected transaction filter: %+v", service.lastFilter) + } +} + +func TestMissingTransactionShowsLatestTransactions(t *testing.T) { + service := &explorerStub{ + transactionErr: ledger.ErrNotFound, + listing: explorer.TransactionListing{Journals: []ledger.Journal{{ID: "latest-journal"}}, Filter: explorer.TransactionFilter{Page: 1}}, + } + request := httptest.NewRequest(http.MethodGet, "/transactions?q=missing", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + body := response.Body.String() + if !strings.Contains(body, "No committed transaction matches") || !strings.Contains(body, "latest-journal") { + t.Fatalf("missing transaction did not include latest activity: %s", body) + } +} + +func TestAccountExplorerParsesStableAccountIdentity(t *testing.T) { + balance, err := ledger.ParseAmount("42.5") + if err != nil { + t.Fatal(err) + } + service := &explorerStub{account: explorer.Account{Balance: balance, Reference: ledger.AccountReference{ + Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "17", AssetID: 9, + }}} + request := httptest.NewRequest(http.MethodGet, "/accounts?class=USER_AVAILABLE&owner_type=user&owner_id=17&asset_id=9", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + if service.lastAccount.OwnerID != "17" || service.lastAccount.AssetID != 9 { + t.Fatalf("unexpected account lookup: %+v", service.lastAccount) + } + if !strings.Contains(response.Body.String(), "42.5") { + t.Fatal("account balance was not rendered") + } +} + +func TestCanceledDashboardRequestDoesNotRenderAnErrorPage(t *testing.T) { + service := &explorerStub{dashboardErr: context.Canceled} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + request := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + if response.Body.Len() != 0 { + t.Fatalf("expected canceled request to produce no response, got %q", response.Body.String()) + } +} + +func TestFormatTimeUsesTehranTimezone(t *testing.T) { + value := time.Date(2026, time.August, 14, 0, 0, 0, 0, time.UTC) + if got := formatTime(value); got != "14 Aug 2026 · 03:30:00 Asia/Tehran" { + t.Fatalf("unexpected localized time: %q", got) + } +} diff --git a/interface/web/i18n.go b/interface/web/i18n.go new file mode 100644 index 0000000..8f8fbc7 --- /dev/null +++ b/interface/web/i18n.go @@ -0,0 +1,314 @@ +package web + +import ( + "net/http" + "net/url" + "time" +) + +type Locale string + +const ( + LocaleEnglish Locale = "en" + LocalePersian Locale = "fa" + localeCookie = "gl_locale" +) + +var defaultTimezone = mustLoadLocation("Asia/Tehran") + +func mustLoadLocation(name string) *time.Location { + location, err := time.LoadLocation(name) + if err != nil { + panic("load dashboard timezone: " + err.Error()) + } + return location +} + +func localeForRequest(response http.ResponseWriter, request *http.Request) Locale { + if locale, ok := validLocale(request.URL.Query().Get("lang")); ok { + http.SetCookie(response, &http.Cookie{ + Name: localeCookie, + Value: string(locale), + Path: "/", + MaxAge: 365 * 24 * 60 * 60, + SameSite: http.SameSiteLaxMode, + }) + return locale + } + if cookie, err := request.Cookie(localeCookie); err == nil { + if locale, ok := validLocale(cookie.Value); ok { + return locale + } + } + return LocaleEnglish +} + +func validLocale(value string) (Locale, bool) { + switch Locale(value) { + case LocaleEnglish: + return LocaleEnglish, true + case LocalePersian: + return LocalePersian, true + default: + return "", false + } +} + +func (l Locale) Direction() string { + if l == LocalePersian { + return "rtl" + } + return "ltr" +} + +func localeURL(request *http.Request, locale Locale) string { + query := cloneQuery(request.URL.Query()) + query.Set("lang", string(locale)) + return request.URL.Path + "?" + query.Encode() +} + +func cloneQuery(source url.Values) url.Values { + result := make(url.Values, len(source)) + for key, values := range source { + result[key] = append([]string(nil), values...) + } + return result +} + +func tr(locale Locale, key string) string { + if locale == LocalePersian { + if value, ok := persian[key]; ok { + return value + } + } + if value, ok := english[key]; ok { + return value + } + return key +} + +var english = map[string]string{ + "site_name": "DARANO", + "site_subtitle": "LEDGER NETWORK", + "overview": "Overview", + "transactions": "Transactions", + "assets": "Assets", + "accounts": "Accounts", + "read_only": "READ ONLY", + "dark_mode": "Use dark mode", + "light_mode": "Use light mode", + "general_ledger_explorer": "General ledger explorer", + "hero_line_one": "Every movement.", + "hero_line_two": "One durable record.", + "hero_description": "Inspect committed journals, trace blockchain transaction hashes, and reconstruct any ledger account without touching the write path.", + "transaction_placeholder": "Enter a transaction hash or journal ID", + "transaction_hash": "Transaction hash", + "explore_transaction": "Explore transaction", + "committed_journals": "Committed journals", + "ledger_entries": "Ledger entries", + "tracked_accounts": "Tracked accounts", + "last_recorded": "Last recorded", + "latest_activity": "Latest ledger activity", + "blockchain_settlements": "Blockchain settlements", + "settlement_summary": "LATEST SETTLEMENT STATE", + "confirmed_on_chain": "Confirmed on chain", + "pending_submission": "Pending submission", + "retryable": "Retryable", + "manual_review": "Manual review", + "blockchain_transaction": "Blockchain transaction", + "status": "Status", + "attempts": "Attempts", + "updated": "Updated", + "not_submitted": "Not submitted", + "no_settlements": "No blockchain settlements have been queued yet.", + "newest_first": "NEWEST FIRST", + "top_asset_holders": "Top asset holders", + "holder_balance_scope": "AVAILABLE + FROZEN · RECENT ASSETS", + "no_asset_holders": "No positive user holdings yet.", + "assets_explorer": "Asset explorer", + "track_asset_ownership": "Track asset ownership.", + "assets_description": "Review the leading positive user balances for the most recently active ledger assets.", + "rank": "Rank", + "holder": "Holder", + "balance": "Balance", + "holder_account": "Holder account", + "inspect_holder": "Inspect a holder.", + "holder_description": "This account combines the holder's available and frozen balances and activity for one asset.", + "combined_balance": "Combined balance", + "available_and_frozen": "AVAILABLE + FROZEN", + "holder_reference_invalid": "A holder identity and positive asset ID are required.", + "transaction": "Transaction", + "effect": "Effect", + "source": "Source", + "entries": "Entries", + "recorded": "Recorded", + "no_journals": "No committed journals yet.", + "network": "NETWORK", + "transaction_explorer": "Transaction explorer", + "trace_settlement": "Trace a settlement.", + "transaction_description": "Search an exact blockchain hash or internal journal ID retained on committed ledger journals.", + "transaction_not_found": "Transaction not found", + "transaction_missing": "No committed transaction matches that hash or journal ID.", + "transaction_lookup_error": "The transaction could not be loaded. Try again shortly.", + "enter_hash": "Enter a hash or journal ID to inspect its balanced financial effects.", + "latest_transactions": "Latest transactions", + "page": "PAGE", + "pagination": "Transaction pages", + "previous": "Previous", + "next": "Next", + "user_wallet": "User / wallet", + "user_wallet_placeholder": "Exact owner or wallet ID", + "effect_type": "Effect type", + "effect_type_placeholder": "Exact effect type", + "apply_filters": "Apply filters", + "clear_filters": "Clear", + "committed": "COMMITTED", + "journal_id": "Journal ID", + "ledger_sequence": "Ledger sequence", + "balanced_entries": "Balanced entries", + "asset": "Asset", + "internal": "internal", + "system": "system", + "account_explorer": "Account explorer", + "rebuild_account": "Rebuild an account.", + "account_description": "Select the stable ledger identity and asset to calculate its current balance from immutable entries.", + "account_class": "Account class", + "owner_type": "Owner type", + "owner_id": "Owner ID", + "stable_owner_id": "Stable owner ID", + "asset_id": "Asset ID", + "explore": "Explore", + "account_unavailable": "Account unavailable", + "asset_positive": "Asset ID must be a positive integer.", + "account_lookup_error": "The account could not be loaded. Check its identity and try again.", + "ledger_identity": "Ledger identity", + "current_balance": "Current balance", + "account_activity": "Account activity", + "journals": "JOURNALS", + "choose_account": "Choose an account class, owner, and asset to begin.", + "explorer_error": "Explorer error", + "dashboard_unavailable": "Dashboard unavailable", + "assets_unavailable": "Assets unavailable", + "transactions_unavailable": "Transactions unavailable", + "read_model_unavailable": "The ledger read model could not be reached. Try again shortly.", + "footer_description": "Darano General Ledger · immutable financial history", + "timezone_notice": "All times shown in Asia/Tehran", + "title_overview": "Network overview", + "title_transactions": "Transaction explorer", + "title_assets": "Asset explorer", + "title_accounts": "Account explorer", + "title_holder": "Holder account", +} + +var persian = map[string]string{ + "site_name": "دارانو", + "site_subtitle": "شبکه دفتر کل", + "overview": "نمای کلی", + "transactions": "تراکنش‌ها", + "assets": "دارایی‌ها", + "accounts": "حساب‌ها", + "read_only": "فقط خواندنی", + "dark_mode": "استفاده از حالت تیره", + "light_mode": "استفاده از حالت روشن", + "general_ledger_explorer": "کاوشگر دفتر کل", + "hero_line_one": "هر جابه‌جایی.", + "hero_line_two": "یک سابقه ماندگار.", + "hero_description": "دفاتر ثبت‌شده را ببینید، هش تراکنش‌های بلاکچین را ردیابی کنید و هر حساب دفتر کل را بدون دسترسی به مسیر نوشتن بازسازی کنید.", + "transaction_placeholder": "هش تراکنش یا شناسه دفتر را وارد کنید", + "transaction_hash": "هش تراکنش", + "explore_transaction": "جست‌وجوی تراکنش", + "committed_journals": "دفاتر ثبت‌شده", + "ledger_entries": "ردیف‌های دفتر کل", + "tracked_accounts": "حساب‌های ردیابی‌شده", + "last_recorded": "آخرین ثبت", + "latest_activity": "آخرین فعالیت دفتر کل", + "blockchain_settlements": "تسویه‌های بلاکچین", + "settlement_summary": "آخرین وضعیت تسویه", + "confirmed_on_chain": "تأییدشده روی زنجیره", + "pending_submission": "در انتظار ارسال", + "retryable": "قابل تلاش مجدد", + "manual_review": "نیازمند بررسی دستی", + "blockchain_transaction": "تراکنش بلاکچین", + "status": "وضعیت", + "attempts": "تعداد تلاش", + "updated": "آخرین تغییر", + "not_submitted": "ارسال‌نشده", + "no_settlements": "هنوز تسویه‌ای برای بلاکچین در صف قرار نگرفته است.", + "newest_first": "جدیدترین ابتدا", + "top_asset_holders": "دارندگان برتر دارایی", + "holder_balance_scope": "در دسترس + مسدود · دارایی‌های اخیر", + "no_asset_holders": "هنوز موجودی مثبت کاربری ثبت نشده است.", + "assets_explorer": "کاوشگر دارایی", + "track_asset_ownership": "مالکیت دارایی را ردیابی کنید.", + "assets_description": "بالاترین موجودی‌های مثبت کاربران را برای دارایی‌های فعال اخیر دفتر کل بررسی کنید.", + "rank": "رتبه", + "holder": "دارنده", + "balance": "موجودی", + "holder_account": "حساب دارنده", + "inspect_holder": "دارنده را بررسی کنید.", + "holder_description": "این حساب موجودی و فعالیت در دسترس و مسدود دارنده را برای یک دارایی ترکیب می‌کند.", + "combined_balance": "موجودی ترکیبی", + "available_and_frozen": "در دسترس + مسدود", + "holder_reference_invalid": "شناسه دارنده و شناسه مثبت دارایی الزامی است.", + "transaction": "تراکنش", + "effect": "اثر", + "source": "منبع", + "entries": "ردیف‌ها", + "recorded": "زمان ثبت", + "no_journals": "هنوز دفتری ثبت نشده است.", + "network": "شبکه", + "transaction_explorer": "کاوشگر تراکنش", + "trace_settlement": "تسویه را ردیابی کنید.", + "transaction_description": "هش دقیق بلاکچین یا شناسه دفتر داخلی را در دفاتر قطعی جست‌وجو کنید.", + "transaction_not_found": "تراکنش پیدا نشد", + "transaction_missing": "هیچ تراکنش قطعی با این هش یا شناسه دفتر پیدا نشد.", + "transaction_lookup_error": "بارگذاری تراکنش ممکن نشد. کمی بعد دوباره تلاش کنید.", + "enter_hash": "برای مشاهده اثرهای مالی تراز، هش یا شناسه دفتر را وارد کنید.", + "latest_transactions": "آخرین تراکنش‌ها", + "page": "صفحه", + "pagination": "صفحه‌های تراکنش", + "previous": "قبلی", + "next": "بعدی", + "user_wallet": "کاربر / کیف پول", + "user_wallet_placeholder": "شناسه دقیق مالک یا کیف پول", + "effect_type": "نوع اثر", + "effect_type_placeholder": "نوع دقیق اثر", + "apply_filters": "اعمال فیلترها", + "clear_filters": "پاک‌کردن", + "committed": "ثبت‌شده", + "journal_id": "شناسه دفتر", + "ledger_sequence": "شماره دفتر", + "balanced_entries": "ردیف‌های تراز", + "asset": "دارایی", + "internal": "داخلی", + "system": "سیستم", + "account_explorer": "کاوشگر حساب", + "rebuild_account": "یک حساب را بازسازی کنید.", + "account_description": "شناسه پایدار حساب و دارایی را انتخاب کنید تا موجودی فعلی از ردیف‌های تغییرناپذیر محاسبه شود.", + "account_class": "نوع حساب", + "owner_type": "نوع مالک", + "owner_id": "شناسه مالک", + "stable_owner_id": "شناسه پایدار مالک", + "asset_id": "شناسه دارایی", + "explore": "جست‌وجو", + "account_unavailable": "حساب در دسترس نیست", + "asset_positive": "شناسه دارایی باید یک عدد صحیح مثبت باشد.", + "account_lookup_error": "بارگذاری حساب ممکن نشد. شناسه آن را بررسی و دوباره تلاش کنید.", + "ledger_identity": "شناسه دفتر کل", + "current_balance": "موجودی فعلی", + "account_activity": "فعالیت حساب", + "journals": "دفتر", + "choose_account": "برای شروع، نوع حساب، مالک و دارایی را انتخاب کنید.", + "explorer_error": "خطای کاوشگر", + "dashboard_unavailable": "داشبورد در دسترس نیست", + "assets_unavailable": "دارایی‌ها در دسترس نیستند", + "transactions_unavailable": "تراکنش‌ها در دسترس نیستند", + "read_model_unavailable": "مدل خواندنی دفتر کل در دسترس نیست. کمی بعد دوباره تلاش کنید.", + "footer_description": "دفتر کل دارانو · تاریخچه مالی تغییرناپذیر", + "timezone_notice": "همه زمان‌ها بر پایه منطقه زمانی تهران نمایش داده می‌شوند", + "title_overview": "نمای کلی شبکه", + "title_transactions": "کاوشگر تراکنش", + "title_assets": "کاوشگر دارایی", + "title_accounts": "کاوشگر حساب", + "title_holder": "حساب دارنده", +} diff --git a/interface/web/i18n_test.go b/interface/web/i18n_test.go new file mode 100644 index 0000000..d04f058 --- /dev/null +++ b/interface/web/i18n_test.go @@ -0,0 +1,23 @@ +package web + +import ( + "net/http/httptest" + "strings" + "testing" +) + +func TestLocaleURLPreservesExplorerQuery(t *testing.T) { + request := httptest.NewRequest("GET", "/accounts?class=USER_AVAILABLE&asset_id=9", nil) + value := localeURL(request, LocalePersian) + for _, expected := range []string{"/accounts?", "class=USER_AVAILABLE", "asset_id=9", "lang=fa"} { + if !strings.Contains(value, expected) { + t.Fatalf("locale URL %q did not preserve %q", value, expected) + } + } +} + +func TestTranslationFallsBackToEnglish(t *testing.T) { + if got := tr(Locale("invalid"), "overview"); got != "Overview" { + t.Fatalf("unexpected fallback translation: %q", got) + } +} diff --git a/interface/web/server.go b/interface/web/server.go new file mode 100644 index 0000000..69fd076 --- /dev/null +++ b/interface/web/server.go @@ -0,0 +1,49 @@ +package web + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" +) + +type ServerConfig struct { + Host string + Port int + ReadHeaderTimeout time.Duration + ShutdownTimeout time.Duration +} + +func Run(ctx context.Context, cfg ServerConfig, handler http.Handler) error { + server := &http.Server{ + Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), + Handler: otelhttp.NewHandler(handler, "gl-dashboard"), + ReadHeaderTimeout: cfg.ReadHeaderTimeout, + } + serveErr := make(chan error, 1) + go func() { serveErr <- server.ListenAndServe() }() + + select { + case err := <-serveErr: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return fmt.Errorf("serve explorer: %w", err) + case <-ctx.Done(): + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + _ = server.Close() + return fmt.Errorf("shut down explorer: %w", err) + } + err := <-serveErr + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("serve explorer: %w", err) + } + return nil +} diff --git a/interface/web/templates.templ b/interface/web/templates.templ new file mode 100644 index 0000000..1590951 --- /dev/null +++ b/interface/web/templates.templ @@ -0,0 +1,470 @@ +package web + +import ( + "strconv" + + "gl/application/explorer" + "gl/domain/ledger" +) + +templ Page(title string, active string, locale Locale, englishURL string, persianURL string, content templ.Component) { + + + + + + + + { title } · { tr(locale, "site_name") } + + + + + + +
+ + + { tr(locale, "site_name") } { tr(locale, "site_subtitle") } + + +
+ @content +
+ { tr(locale, "footer_description") } + { tr(locale, "timezone_notice") } +
+ + +} + +templ TransactionSearch(value string, locale Locale) { + +} + +templ DashboardContent(data explorer.Dashboard, locale Locale) { +
+
+
{ tr(locale, "general_ledger_explorer") }
+

{ tr(locale, "hero_line_one") }
{ tr(locale, "hero_line_two") }

+

{ tr(locale, "hero_description") }

+ @TransactionSearch("", locale) +
+
+
{ tr(locale, "committed_journals") }{ count(data.Stats.JournalCount) }
+
{ tr(locale, "ledger_entries") }{ count(data.Stats.EntryCount) }
+
{ tr(locale, "tracked_accounts") }{ count(data.Stats.AccountCount) }
+
{ tr(locale, "last_recorded") }
+
+
+

{ tr(locale, "blockchain_settlements") }

{ tr(locale, "settlement_summary") }
+
+
{ tr(locale, "confirmed_on_chain") }{ count(data.SettlementStats.Confirmed) }
+
{ tr(locale, "pending_submission") }{ count(data.SettlementStats.Pending) }
+
{ tr(locale, "retryable") }{ count(data.SettlementStats.Retryable) }
+
{ tr(locale, "manual_review") }{ count(data.SettlementStats.ManualReview) }
+
+ @SettlementTable(data.Settlements, locale) +
+
+

{ tr(locale, "latest_activity") }

{ tr(locale, "newest_first") }
+ @JournalTable(data.Journals, locale) +
+
+} + +templ SettlementTable(settlements []explorer.Settlement, locale Locale) { +
+ if len(settlements) == 0 { +
{ tr(locale, "no_settlements") }
+ } else { + + + + for _, settlement := range settlements { + + + + + + + + + } + +
{ tr(locale, "blockchain_transaction") }{ tr(locale, "status") }{ tr(locale, "network") }{ tr(locale, "source") }{ tr(locale, "attempts") }{ tr(locale, "updated") }
+ if settlement.TransactionHash != "" { + { short(settlement.TransactionHash, 22) } + } else { + { tr(locale, "not_submitted") } · { short(settlement.ID, 14) } + } + if settlement.LastError != "" { + { short(settlement.LastError, 70) } + } + { string(settlement.Status) }{ settlement.Network }{ settlement.SourceService }{ short(settlement.SourceTransactionID, 18) }{ strconv.Itoa(settlement.Attempts) }{ formatTime(settlement.UpdatedAt) }
+ } +
+} + +templ AssetsContent(data explorer.Assets, locale Locale) { +
+ +
+
{ tr(locale, "assets_explorer") }
+

{ tr(locale, "track_asset_ownership") }

+

{ tr(locale, "assets_description") }

+
+
+

{ tr(locale, "top_asset_holders") }

{ tr(locale, "holder_balance_scope") }
+ @HolderTable(data.TopHolders, locale) +
+
+} + +templ HolderTable(holders []explorer.Holder, locale Locale) { +
+ if len(holders) == 0 { +
{ tr(locale, "no_asset_holders") }
+ } else { + + + + for _, holder := range holders { + + + + + + + } + +
{ tr(locale, "asset") }{ tr(locale, "rank") }{ tr(locale, "holder") }{ tr(locale, "balance") }
{ tr(locale, "asset") } { strconv.FormatInt(holder.AssetID, 10) }#{ strconv.FormatInt(holder.Rank, 10) }{ holder.OwnerID }{ holder.OwnerType }{ holder.Balance.String() }
+ } +
+} + +templ HolderContent(data HolderPageData, locale Locale) { +
+ +
+
{ tr(locale, "holder_account") }
+

{ tr(locale, "inspect_holder") }

+

{ tr(locale, "holder_description") }

+
+ if data.Error != "" { +
{ tr(locale, "account_unavailable") }{ data.Error }
+ } else if data.Account != nil { + +
+

{ tr(locale, "account_activity") }

{ tr(locale, "available_and_frozen") }
+ @JournalTable(data.Account.Journals, locale) +
+ } +
+} + +templ JournalTable(journals []ledger.Journal, locale Locale) { +
+ if len(journals) == 0 { +
{ tr(locale, "no_journals") }
+ } else { + + + + for _, journal := range journals { + + + + + + + + } + +
{ tr(locale, "transaction") }{ tr(locale, "effect") }{ tr(locale, "source") }{ tr(locale, "entries") }{ tr(locale, "recorded") }
+ { transactionName(journal, locale) } + { journal.EffectKind }{ journal.SourceService }{ strconv.Itoa(len(journal.Entries)) }{ formatTime(journal.RecordedAt) }
+ } +
+} + +templ TransactionContent(data TransactionPageData, locale Locale) { +
+ +
+
{ tr(locale, "transaction_explorer") }
+

{ tr(locale, "trace_settlement") }

+

{ tr(locale, "transaction_description") }

+ @TransactionSearch(data.Query, locale) +
+ if data.Error != "" { +
{ tr(locale, "transaction_not_found") }{ data.Error }
+ } + if data.Query != "" && data.Error == "" { +
+ for _, journal := range data.Journals { + @JournalCard(journal, locale) + } +
+ } + if data.Listing != nil { +
+

{ tr(locale, "latest_transactions") }

{ tr(locale, "page") } { strconv.Itoa(data.Listing.Filter.Page) }
+
+
+
+ + { tr(locale, "clear_filters") } +
+ @JournalTable(data.Listing.Journals, locale) + @Pagination(*data.Listing, locale) +
+ } +
+} + +templ Pagination(listing explorer.TransactionListing, locale Locale) { + +} + +templ JournalCard(journal ledger.Journal, locale Locale) { +
+
+
+ if journal.Blockchain.TransactionHash != "" { + { tr(locale, "transaction_hash") } + } else { + { tr(locale, "journal_id") } + } +

{ transactionReference(journal) }

+
+ { tr(locale, "committed") } +
+
+
{ journal.EffectKind }
+
{ journal.ID }
+
{ formatTime(journal.RecordedAt) }
+
{ journal.SourceService } · { journal.SourceTransactionID }
+
{ journal.Blockchain.Network }
+
{ journal.Blockchain.LedgerSequence }
+
+
+

{ tr(locale, "balanced_entries") }

+ for _, entry := range journal.Entries { +
+ #{ strconv.FormatUint(uint64(entry.LineNumber), 10) } + + if len(entry.Amount.String()) > 0 && entry.Amount.String()[0] == '-' { +
{ entry.Amount.String() }
+ } else { +
+{ entry.Amount.String() }
+ } +
+ } +
+
+} + +templ AccountContent(data AccountPageData, locale Locale) { +
+ +
+
{ tr(locale, "account_explorer") }
+

{ tr(locale, "rebuild_account") }

+

{ tr(locale, "account_description") }

+
+ + if data.Error != "" { +
{ tr(locale, "account_unavailable") }{ data.Error }
+ } else if data.Account != nil { + +

{ tr(locale, "account_activity") }

{ strconv.Itoa(len(data.Account.Journals)) } { tr(locale, "journals") }
+ @JournalTable(data.Account.Journals, locale) + } else { +
{ tr(locale, "choose_account") }
+ } +
+} + +templ FailureContent(title string, message string, locale Locale) { +
+
{ tr(locale, "explorer_error") }

{ title }

{ message }

+
+} diff --git a/interface/web/templates_templ.go b/interface/web/templates_templ.go new file mode 100644 index 0000000..fb5a8ea --- /dev/null +++ b/interface/web/templates_templ.go @@ -0,0 +1,3508 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package web + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "strconv" + + "gl/application/explorer" + "gl/domain/ledger" +) + +func Page(title string, active string, locale Locale, englishURL string, persianURL string, content templ.Component) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 18, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "site_name")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 18, Col: 48} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
\"\" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "site_name")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 135, Col: 36} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "site_subtitle")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 135, Col: 75} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = content.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "footer_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 176, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "timezone_notice")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 177, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func TransactionSearch(value string, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var27 := templ.GetChildren(ctx) + if templ_7745c5c3_Var27 == nil { + templ_7745c5c3_Var27 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func DashboardContent(data explorer.Dashboard, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var32 := templ.GetChildren(ctx) + if templ_7745c5c3_Var32 == nil { + templ_7745c5c3_Var32 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var33 string + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "general_ledger_explorer")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 193, Col: 63} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "hero_line_one")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 194, Col: 36} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "hero_line_two")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 194, Col: 72} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var36 string + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "hero_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 195, Col: 51} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = TransactionSearch("", locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var38 string + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "committed_journals")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 199, Col: 87} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var39 string + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.Stats.JournalCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 199, Col: 136} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var40 string + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "ledger_entries")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 200, Col: 76} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var41 string + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.Stats.EntryCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 200, Col: 123} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var42 string + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "tracked_accounts")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 201, Col: 78} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var43 string + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.Stats.AccountCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 201, Col: 127} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "last_recorded")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 202, Col: 75} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var46 string + templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "blockchain_settlements")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 205, Col: 71} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var47 string + templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "settlement_summary")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 205, Col: 118} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var49 string + templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "confirmed_on_chain")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 207, Col: 81} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var50 string + templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.SettlementStats.Confirmed)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 207, Col: 137} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var51 string + templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "pending_submission")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 208, Col: 81} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var52 string + templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.SettlementStats.Pending)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 208, Col: 135} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var53 string + templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "retryable")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 209, Col: 72} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var54 string + templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.SettlementStats.Retryable)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 209, Col: 128} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var55 string + templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "manual_review")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 210, Col: 76} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var56 string + templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.SettlementStats.ManualReview)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 210, Col: 135} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = SettlementTable(data.Settlements, locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var57 string + templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "latest_activity")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 215, Col: 64} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var58 string + templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "newest_first")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 215, Col: 105} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = JournalTable(data.Journals, locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func SettlementTable(settlements []explorer.Settlement, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var59 := templ.GetChildren(ctx) + if templ_7745c5c3_Var59 == nil { + templ_7745c5c3_Var59 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(settlements) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var60 string + templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "no_settlements")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 224, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, settlement := range settlements { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var61 string + templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "blockchain_transaction")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 227, Col: 57} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var62 string + templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "status")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 227, Col: 90} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var63 string + templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 227, Col: 124} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var64 string + templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "source")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 227, Col: 157} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var65 string + templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "attempts")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 227, Col: 192} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var66 string + templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "updated")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 227, Col: 226} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if settlement.TransactionHash != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var69 string + templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(short(settlement.TransactionHash, 22)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 233, Col: 169} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var71 string + templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "not_submitted")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 235, Col: 68} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var72 string + templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(short(settlement.ID, 14)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 235, Col: 100} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if settlement.LastError != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var73 string + templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(short(settlement.LastError, 70)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 238, Col: 49} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var74 string + templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(string(settlement.Status)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 241, Col: 57} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var75 string + templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(settlement.Network) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 242, Col: 31} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var76 string + templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(settlement.SourceService) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 243, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var77 string + templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(short(settlement.SourceTransactionID, 18)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 243, Col: 102} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var78 string + templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(settlement.Attempts)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 244, Col: 59} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var79 string + templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(formatTime(settlement.UpdatedAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 245, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func AssetsContent(data explorer.Assets, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var80 := templ.GetChildren(ctx) + if templ_7745c5c3_Var80 == nil { + templ_7745c5c3_Var80 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var81 string + templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 256, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, " / ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var82 string + templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 256, Col: 92} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var83 string + templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets_explorer")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 258, Col: 55} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var84 string + templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "track_asset_ownership")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 259, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var85 string + templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 260, Col: 53} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var86 string + templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "top_asset_holders")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 263, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var87 string + templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder_balance_scope")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 263, Col: 115} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = HolderTable(data.TopHolders, locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func HolderTable(holders []explorer.Holder, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var88 := templ.GetChildren(ctx) + if templ_7745c5c3_Var88 == nil { + templ_7745c5c3_Var88 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(holders) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var89 string + templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "no_asset_holders")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 272, Col: 54} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, holder := range holders { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var90 string + templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 275, Col: 40} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var91 string + templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "rank")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 275, Col: 71} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var92 string + templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 275, Col: 104} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var93 string + templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "balance")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 275, Col: 138} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var94 string + templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 279, Col: 51} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var95 string + templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(holder.AssetID, 10)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 279, Col: 93} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "#") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var96 string + templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(holder.Rank, 10)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 280, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var98 string + templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(holder.OwnerID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 281, Col: 109} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var99 string + templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(holder.OwnerType) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 281, Col: 140} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var100 string + templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(holder.Balance.String()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 282, Col: 60} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func HolderContent(data HolderPageData, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var101 := templ.GetChildren(ctx) + if templ_7745c5c3_Var101 == nil { + templ_7745c5c3_Var101 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var102 string + templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 293, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, " / ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var103 string + templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 293, Col: 110} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, " / ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var104 string + templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 293, Col: 141} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var105 string + templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder_account")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 295, Col: 54} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var106 string + templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "inspect_holder")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 296, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var107 string + templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 297, Col: 53} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var108 string + templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_unavailable")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 300, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var109 string + templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 300, Col: 89} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if data.Account != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var110 string + templ_7745c5c3_Var110, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 303, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var110)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var111 string + templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Reference.OwnerID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 303, Col: 114} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var112 string + templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Reference.OwnerType) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 303, Col: 171} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var113 string + templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 303, Col: 198} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var114 string + templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(data.Account.Reference.AssetID, 10)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 303, Col: 256} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var114)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var115 string + templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "combined_balance")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 304, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var116 string + templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Balance.String()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 304, Col: 127} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var117 string + templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_activity")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 307, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var117)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var118 string + templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "available_and_frozen")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 307, Col: 115} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = JournalTable(data.Account.Journals, locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func JournalTable(journals []ledger.Journal, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var119 := templ.GetChildren(ctx) + if templ_7745c5c3_Var119 == nil { + templ_7745c5c3_Var119 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(journals) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var120 string + templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "no_journals")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 317, Col: 49} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var120)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, journal := range journals { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var121 string + templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 320, Col: 46} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var122 string + templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "effect")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 320, Col: 79} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var122)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var123 string + templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "source")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 320, Col: 112} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var123)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var124 string + templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "entries")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 320, Col: 146} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var124)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var125 string + templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "recorded")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 320, Col: 181} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var127 string + templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.JoinStringErrs(transactionName(journal, locale)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 325, Col: 129} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var127)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var128 string + templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinStringErrs(journal.EffectKind) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 327, Col: 50} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var129 string + templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.JoinStringErrs(journal.SourceService) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 328, Col: 34} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var129)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var130 string + templ_7745c5c3_Var130, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(len(journal.Entries))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 329, Col: 47} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var130)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var131 string + templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(formatTime(journal.RecordedAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 330, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func TransactionContent(data TransactionPageData, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var132 := templ.GetChildren(ctx) + if templ_7745c5c3_Var132 == nil { + templ_7745c5c3_Var132 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var133 string + templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 341, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, " / ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var134 string + templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transactions")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 341, Col: 98} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var135 string + templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_explorer")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 343, Col: 60} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var136 string + templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "trace_settlement")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 344, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var137 string + templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 345, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var137)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = TransactionSearch(data.Query, locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var138 string + templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_not_found")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 349, Col: 68} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var139 string + templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 349, Col: 91} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.Query != "" && data.Error == "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, journal := range data.Journals { + templ_7745c5c3_Err = JournalCard(journal, locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.Listing != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var140 string + templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "latest_transactions")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 360, Col: 69} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var141 string + templ_7745c5c3_Var141, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "page")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 360, Col: 102} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var141)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var142 string + templ_7745c5c3_Var142, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(data.Listing.Filter.Page)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 360, Col: 145} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var142)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var150 string + templ_7745c5c3_Var150, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "clear_filters")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 365, Col: 79} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var150)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = JournalTable(data.Listing.Journals, locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Pagination(*data.Listing, locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func Pagination(listing explorer.TransactionListing, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var151 := templ.GetChildren(ctx) + if templ_7745c5c3_Var151 == nil { + templ_7745c5c3_Var151 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func JournalCard(journal ledger.Journal, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var160 := templ.GetChildren(ctx) + if templ_7745c5c3_Var160 == nil { + templ_7745c5c3_Var160 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if journal.Blockchain.TransactionHash != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var161 string + templ_7745c5c3_Var161, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_hash")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 395, Col: 62} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var161)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var162 string + templ_7745c5c3_Var162, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "journal_id")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 397, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var162)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var163 string + templ_7745c5c3_Var163, templ_7745c5c3_Err = templ.JoinStringErrs(transactionReference(journal)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 399, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var163)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var164 string + templ_7745c5c3_Var164, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "committed")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 401, Col: 49} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var164)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var166 string + templ_7745c5c3_Var166, templ_7745c5c3_Err = templ.JoinStringErrs(journal.EffectKind) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 404, Col: 106} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var166)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var168 string + templ_7745c5c3_Var168, templ_7745c5c3_Err = templ.JoinStringErrs(journal.ID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 405, Col: 96} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var168)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var170 string + templ_7745c5c3_Var170, templ_7745c5c3_Err = templ.JoinStringErrs(formatTime(journal.RecordedAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 406, Col: 101} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var170)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var172 string + templ_7745c5c3_Var172, templ_7745c5c3_Err = templ.JoinStringErrs(journal.SourceService) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 407, Col: 90} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var172)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var173 string + templ_7745c5c3_Var173, templ_7745c5c3_Err = templ.JoinStringErrs(journal.SourceTransactionID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 407, Col: 125} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var173)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var175 string + templ_7745c5c3_Var175, templ_7745c5c3_Err = templ.JoinStringErrs(journal.Blockchain.Network) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 408, Col: 96} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var175)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var177 string + templ_7745c5c3_Var177, templ_7745c5c3_Err = templ.JoinStringErrs(journal.Blockchain.LedgerSequence) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 409, Col: 124} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var177)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var178 string + templ_7745c5c3_Var178, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "balanced_entries")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 412, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var178)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, entry := range journal.Entries { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "
#") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var179 string + templ_7745c5c3_Var179, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatUint(uint64(entry.LineNumber), 10)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 415, Col: 83} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var179)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var181 string + templ_7745c5c3_Var181, templ_7745c5c3_Err = templ.JoinStringErrs(accountName(entry.Account, locale)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 416, Col: 117} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var181)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var182 string + templ_7745c5c3_Var182, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 416, Col: 151} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var182)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var183 string + templ_7745c5c3_Var183, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(entry.Account.AssetID, 10)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 416, Col: 200} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var183)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var184 string + templ_7745c5c3_Var184, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Account.OwnerType) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 416, Col: 231} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var184)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(entry.Amount.String()) > 0 && entry.Amount.String()[0] == '-' { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var185 string + templ_7745c5c3_Var185, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Amount.String()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 418, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var185)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, "
+") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var186 string + templ_7745c5c3_Var186, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Amount.String()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 420, Col: 59} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var186)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 248, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 249, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func AccountContent(data AccountPageData, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var187 := templ.GetChildren(ctx) + if templ_7745c5c3_Var187 == nil { + templ_7745c5c3_Var187 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 250, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var188 string + templ_7745c5c3_Var188, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 430, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var188)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 251, " / ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var189 string + templ_7745c5c3_Var189, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "accounts")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 430, Col: 94} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var189)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 252, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var190 string + templ_7745c5c3_Var190, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_explorer")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 432, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var190)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 253, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var191 string + templ_7745c5c3_Var191, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "rebuild_account")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 433, Col: 38} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var191)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 254, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var192 string + templ_7745c5c3_Var192, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 434, Col: 54} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var192)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 255, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 272, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var206 string + templ_7745c5c3_Var206, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_unavailable")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 452, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var206)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 273, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var207 string + templ_7745c5c3_Var207, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 452, Col: 89} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var207)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 274, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if data.Account != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 275, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var208 string + templ_7745c5c3_Var208, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "ledger_identity")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 455, Col: 65} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var208)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 276, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var209 string + templ_7745c5c3_Var209, templ_7745c5c3_Err = templ.JoinStringErrs(accountName(data.Account.Reference, locale)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 455, Col: 123} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var209)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 277, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var210 string + templ_7745c5c3_Var210, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Reference.OwnerType) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 455, Col: 180} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var210)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 278, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var211 string + templ_7745c5c3_Var211, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 455, Col: 207} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var211)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 279, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var212 string + templ_7745c5c3_Var212, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(data.Account.Reference.AssetID, 10)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 455, Col: 265} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var212)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 280, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var213 string + templ_7745c5c3_Var213, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "current_balance")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 456, Col: 65} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var213)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 281, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var214 string + templ_7745c5c3_Var214, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Balance.String()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 456, Col: 126} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var214)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 282, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var215 string + templ_7745c5c3_Var215, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_activity")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 458, Col: 65} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var215)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 283, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var216 string + templ_7745c5c3_Var216, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(len(data.Account.Journals))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 458, Col: 120} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var216)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 284, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var217 string + templ_7745c5c3_Var217, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "journals")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 458, Col: 147} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var217)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 285, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = JournalTable(data.Account.Journals, locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 286, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var218 string + templ_7745c5c3_Var218, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "choose_account")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 461, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var218)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 287, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 288, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func FailureContent(title string, message string, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var219 := templ.GetChildren(ctx) + if templ_7745c5c3_Var219 == nil { + templ_7745c5c3_Var219 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 289, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var220 string + templ_7745c5c3_Var220, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "explorer_error")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 468, Col: 81} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var220)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 290, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var221 string + templ_7745c5c3_Var221, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 468, Col: 100} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var221)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 291, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var222 string + templ_7745c5c3_Var222, templ_7745c5c3_Err = templ.JoinStringErrs(message) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 468, Col: 132} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var222)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 292, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate